From 18c47c4a5784b49ab93c6459647c5b06113df603 Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Thu, 19 Jun 2025 16:01:26 -0700 Subject: [PATCH 01/15] implement --- Cargo.lock | 40 +++++++++++++++++- dsp-core/Cargo.toml | 7 ++-- dsp-core/src/logging.rs | 93 +++++++++++++++++++++++++++++++++++++---- studio/src/main.rs | 14 +++++-- 4 files changed, 138 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d37bb5a3..603486f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -944,9 +944,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", @@ -1247,6 +1247,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1483,10 +1492,12 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "chrono", "futuresdr", "log", "thingbuf", "tracing", + "tracing-appender", "tracing-log", "tracing-subscriber", ] @@ -5505,6 +5516,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror 1.0.69", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.28" @@ -5537,6 +5560,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.19" @@ -5547,12 +5580,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] diff --git a/dsp-core/Cargo.toml b/dsp-core/Cargo.toml index b3377779..75bcd685 100644 --- a/dsp-core/Cargo.toml +++ b/dsp-core/Cargo.toml @@ -10,9 +10,10 @@ publish = false anyhow.workspace = true async-trait = "0.1.83" log = "0.4.22" -thingbuf = "0.1.6" - futuresdr = { workspace = true } -tracing-subscriber = "0.3.19" +thingbuf = "0.1.6" +tracing-subscriber = { version = "0.3.19", features = ["json"] } tracing = "0.1.41" tracing-log = "0.2.0" +tracing-appender = "0.2.3" +chrono = { version = "0.4.41", default-features = false, features = ["std", "now", "clock"] } diff --git a/dsp-core/src/logging.rs b/dsp-core/src/logging.rs index 14001a23..080ecb38 100644 --- a/dsp-core/src/logging.rs +++ b/dsp-core/src/logging.rs @@ -1,15 +1,86 @@ +use std::path::{Path, PathBuf}; use tracing_subscriber::filter::EnvFilter; use tracing_subscriber::fmt; +use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::prelude::*; /// Initializes [`tracing`] and sets a [`log`] logging adapter with [`tracing_log`]. /// /// Reads `FOXHUNTER_LOG` from env (if set) to determine the log level, otherwise defaults to INFO level. pub fn init() { - init_with_level(tracing::Level::INFO); + init_with_level(tracing::Level::INFO, None); } -pub fn init_with_level(level: tracing::Level) { +/// Drop guard from `tracing`. This must be dropped on application exit +#[allow(dead_code)] +#[must_use] +pub struct LogGuard(Box); + +pub fn init_with_level(level: tracing::Level, file_output: Option<&Path>) -> Vec { + let subscriber = tracing_subscriber::registry(); + let mut guards = vec![]; + + // ========== FILE LOG ========== + + fn log_file_name() -> String { + chrono::Local::now() + .naive_local() + .format("dsp-studio-log_%Y-%m-%d_%H:%M:%S.txt") + .to_string() + } + + // Use user specified file or create in tempdir + let log_file_path = match file_output { + Some(path) => { + if path.is_dir() { + let mut path = PathBuf::from(path); + path.push(log_file_name()); + path + } else { + path.to_owned() + } + } + None => { + let mut path = std::env::temp_dir(); + path.push(log_file_name()); + path + } + }; + + let file_layer = Some(&log_file_path) + // Disable file logging on android + .filter(|_| !cfg!(target_os = "android")) + .and_then(|log_file_path| { + std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(log_file_path) + .inspect_err(|e| { + eprintln!("Failed to open output logfile {log_file_path:?}: {e:?}") + }) + .ok() + }) + .map(|file| { + let (non_blocking, guard) = tracing_appender::non_blocking(file); + guards.push(LogGuard(Box::new(guard))); + + fmt::layer() + .json() + .flatten_event(true) + .with_current_span(false) + .with_span_list(false) + .with_writer(non_blocking) + .with_filter( + // log-debug file, to log the debug + tracing::level_filters::LevelFilter::from_level(tracing::Level::DEBUG), + ) + }); + + let subscriber = subscriber.with(file_layer); + + // ========== STDOUT LOG ========== + let log_crate_level = match level { tracing::Level::ERROR => log::LevelFilter::Error, tracing::Level::WARN => log::LevelFilter::Warn, @@ -35,14 +106,18 @@ pub fn init_with_level(level: tracing::Level) { .with_env_var("FOXHUNTER_LOG") .from_env_lossy(); - let subscriber = tracing_subscriber::registry().with(filter).with(format); + let subscriber = subscriber.with(format.with_filter(filter)); + + // ========== ANDROID LOG ========== + #[cfg(target_os = "android")] - let subscriber = { - use tracing_subscriber::layer::SubscriberExt; - subscriber.with(tracing_android::layer("foxhunter_log_jni").unwrap()) - }; + let subscriber = { subscriber.with(tracing_android::layer("foxhunter_log_jni").unwrap()) }; + + // ========== SETUP ========== - let _ = tracing::subscriber::set_global_default(subscriber); + tracing::subscriber::set_global_default(subscriber).expect("Failed to set global logger"); + + tracing::info!("Writing verbose logs to {log_file_path:?}"); #[cfg(target_os = "android")] { @@ -67,4 +142,6 @@ pub fn init_with_level(level: tracing::Level) { tracing::error!("foxhunterjni PANICKED: {msg}, at {file}:{line}"); })); } + + guards } diff --git a/studio/src/main.rs b/studio/src/main.rs index fe524c31..0f92e76c 100644 --- a/studio/src/main.rs +++ b/studio/src/main.rs @@ -6,7 +6,7 @@ use tracing::info; #[cfg(not(target_arch = "wasm32"))] fn main() -> eframe::Result<()> { use anyhow::Context; - use std::io::Read; + use std::{io::Read, path::PathBuf}; use clap::Parser; @@ -24,6 +24,12 @@ fn main() -> eframe::Result<()> { /// .fds flowgraph save to open #[arg(value_parser)] file: Option, + + /// File path to write application logs to. + /// + /// Defaults creating a timestamped file in your temporary directory. + #[clap(long)] + log_path: Option, } let args = Cli::parse(); @@ -45,7 +51,7 @@ fn main() -> eframe::Result<()> { tracing::Level::INFO }; - dsp_core::logging::init_with_level(level); + let _guards = dsp_core::logging::init_with_level(level, args.log_path.as_deref()); info!( "Running DSP Studio version: {}", blocks::DspStudioVersion::default() @@ -94,7 +100,9 @@ fn main() -> eframe::Result<()> { } Ok(Box::new(app)) }), - ) + )?; + + Ok(()) } #[cfg(target_arch = "wasm32")] From b1d002d683d745854bbcfd51de0df4286e5599d4 Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Thu, 19 Jun 2025 16:59:28 -0700 Subject: [PATCH 02/15] wip --- dsp-core/Cargo.toml | 6 +++++- dsp-core/src/logging.rs | 8 ++++++-- studio/src/main.rs | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/dsp-core/Cargo.toml b/dsp-core/Cargo.toml index 75bcd685..b97a4a76 100644 --- a/dsp-core/Cargo.toml +++ b/dsp-core/Cargo.toml @@ -16,4 +16,8 @@ tracing-subscriber = { version = "0.3.19", features = ["json"] } tracing = "0.1.41" tracing-log = "0.2.0" tracing-appender = "0.2.3" -chrono = { version = "0.4.41", default-features = false, features = ["std", "now", "clock"] } +chrono = { version = "0.4.41", default-features = false, features = [ + "std", + "now", + "clock", +] } diff --git a/dsp-core/src/logging.rs b/dsp-core/src/logging.rs index 080ecb38..8ab97fa4 100644 --- a/dsp-core/src/logging.rs +++ b/dsp-core/src/logging.rs @@ -11,11 +11,15 @@ pub fn init() { init_with_level(tracing::Level::INFO, None); } -/// Drop guard from `tracing`. This must be dropped on application exit +/// Drop guard from `tracing`. This must be dropped on application exit for logs to be flushed properly. #[allow(dead_code)] #[must_use] pub struct LogGuard(Box); +/// Initializes [`tracing`] and sets a [`log`] logging adapter with [`tracing_log`]. +/// +/// On desktop platforms json logs will be written to `file_output` (if set), otherwise uses the user's temp dir. +/// Reads `FOXHUNTER_LOG` from env (if set) to determine the log level, otherwise defaults to INFO level. pub fn init_with_level(level: tracing::Level, file_output: Option<&Path>) -> Vec { let subscriber = tracing_subscriber::registry(); let mut guards = vec![]; @@ -25,7 +29,7 @@ pub fn init_with_level(level: tracing::Level, file_output: Option<&Path>) -> Vec fn log_file_name() -> String { chrono::Local::now() .naive_local() - .format("dsp-studio-log_%Y-%m-%d_%H:%M:%S.txt") + .format("dsp-studio-log_%Y-%m-%d_%H:%M:%S.json") .to_string() } diff --git a/studio/src/main.rs b/studio/src/main.rs index 0f92e76c..7b4b3142 100644 --- a/studio/src/main.rs +++ b/studio/src/main.rs @@ -25,7 +25,7 @@ fn main() -> eframe::Result<()> { #[arg(value_parser)] file: Option, - /// File path to write application logs to. + /// File path to write json application logs to. /// /// Defaults creating a timestamped file in your temporary directory. #[clap(long)] From cabdcaea4c030302e2ddaa8e2986a48583678ce4 Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Thu, 19 Jun 2025 20:29:40 -0700 Subject: [PATCH 03/15] make .foxhunter-studio dir hidden on windows --- Cargo.lock | 80 +++++++++++++++++++++++++++++++++++++++-- dsp-core/Cargo.toml | 3 ++ dsp-core/src/logging.rs | 45 ++++++++++++++++++++--- 3 files changed, 120 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 603486f9..bc7f1cde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1500,6 +1500,7 @@ dependencies = [ "tracing-appender", "tracing-log", "tracing-subscriber", + "windows-sys 0.60.2", ] [[package]] @@ -6371,9 +6372,9 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-result" @@ -6454,6 +6455,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -6493,13 +6503,29 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -6518,6 +6544,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -6536,6 +6568,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -6554,12 +6592,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -6578,6 +6628,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -6596,6 +6652,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -6614,6 +6676,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -6632,6 +6700,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "winit" version = "0.30.9" diff --git a/dsp-core/Cargo.toml b/dsp-core/Cargo.toml index b97a4a76..19c766ff 100644 --- a/dsp-core/Cargo.toml +++ b/dsp-core/Cargo.toml @@ -21,3 +21,6 @@ chrono = { version = "0.4.41", default-features = false, features = [ "now", "clock", ] } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.60.2", features = ["Win32_Storage_FileSystem"] } diff --git a/dsp-core/src/logging.rs b/dsp-core/src/logging.rs index 8ab97fa4..8e470fc3 100644 --- a/dsp-core/src/logging.rs +++ b/dsp-core/src/logging.rs @@ -29,7 +29,7 @@ pub fn init_with_level(level: tracing::Level, file_output: Option<&Path>) -> Vec fn log_file_name() -> String { chrono::Local::now() .naive_local() - .format("dsp-studio-log_%Y-%m-%d_%H:%M:%S.json") + .format("dsp-studio-log_%Y-%m-%d_%H-%M-%S.json") .to_string() } @@ -45,9 +45,44 @@ pub fn init_with_level(level: tracing::Level, file_output: Option<&Path>) -> Vec } } None => { - let mut path = std::env::temp_dir(); - path.push(log_file_name()); - path + #[cfg(target_os = "windows")] + { + #[allow(deprecated)] + let mut path = std::env::home_dir() + .map(|mut home| { + home.push(".foxhunter-studio"); + home + }) + .unwrap_or_else(|| std::env::temp_dir()); + if !path.exists() { + if let Err(e) = std::fs::create_dir_all(&path) { + eprintln!("WARN: failed to create log directory {path:?}: {e:?}"); + } else { + // Mark as hidden + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem; + + let mut win_path: Vec = path.as_os_str().encode_wide().collect(); + win_path.push(0); + + let attrs = unsafe { FileSystem::GetFileAttributesW(win_path.as_ptr()) }; + + let attrs = attrs | FileSystem::FILE_ATTRIBUTE_HIDDEN; + unsafe { FileSystem::SetFileAttributesW(win_path.as_ptr(), attrs) }; + } + } + + home.push("logs"); + // TODO(tjn, #129): delete (> ~2 days) old log files + path.push(log_file_name()); + path + } + #[cfg(not(target_os = "windows"))] + { + let mut path = std::env::temp_dir(); + path.push(log_file_name()); + path + } } }; @@ -61,7 +96,7 @@ pub fn init_with_level(level: tracing::Level, file_output: Option<&Path>) -> Vec .write(true) .open(log_file_path) .inspect_err(|e| { - eprintln!("Failed to open output logfile {log_file_path:?}: {e:?}") + eprintln!("ERROR: Failed to open output logfile {log_file_path:?}: {e:?}") }) .ok() }) From a962be1fb9c7656fc2841b9d9c635957a68e314c Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Thu, 19 Jun 2025 21:08:44 -0700 Subject: [PATCH 04/15] fix error --- dsp-core/src/logging.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dsp-core/src/logging.rs b/dsp-core/src/logging.rs index 8e470fc3..bddf884d 100644 --- a/dsp-core/src/logging.rs +++ b/dsp-core/src/logging.rs @@ -72,7 +72,7 @@ pub fn init_with_level(level: tracing::Level, file_output: Option<&Path>) -> Vec } } - home.push("logs"); + path.push("logs"); // TODO(tjn, #129): delete (> ~2 days) old log files path.push(log_file_name()); path From 6c448495ed30ef89721f89ef0d4ab2606e3e8778 Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Sat, 21 Jun 2025 06:45:22 -0700 Subject: [PATCH 05/15] wip --- .gitignore | 2 + Cargo.lock | 335 ++++++++++++++++++++++++++++++++----- blocks/src/blocks/math.rs | 8 +- dsp-core/src/logging.rs | 62 ++++--- examples/windows-crash.fds | 108 ++++++++++++ flake.nix | 3 + studio/Cargo.toml | 5 +- studio/README.md | 29 ++++ studio/src/main.rs | 24 ++- 9 files changed, 488 insertions(+), 88 deletions(-) create mode 100644 examples/windows-crash.fds create mode 100644 studio/README.md diff --git a/.gitignore b/.gitignore index 6e03358f..326c33d8 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,5 @@ hostedx*.rbf **/dist/ result + +tmp/ diff --git a/Cargo.lock b/Cargo.lock index bc7f1cde..f75f5fcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,7 +63,7 @@ dependencies = [ "accesskit_consumer", "hashbrown 0.15.2", "objc2 0.5.2", - "objc2-app-kit", + "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] @@ -306,7 +306,7 @@ dependencies = [ "image", "log", "objc2 0.5.2", - "objc2-app-kit", + "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "parking_lot", "windows-sys 0.48.0", @@ -337,6 +337,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -722,7 +728,7 @@ dependencies = [ "bitflags 2.9.0", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -785,6 +791,15 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "block2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +dependencies = [ + "objc2 0.6.1", +] + [[package]] name = "blocking" version = "1.6.1" @@ -918,7 +933,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -1086,7 +1101,7 @@ dependencies = [ "async-trait", "convert_case", "json5", - "nom", + "nom 7.1.3", "pathdiff", "ron", "rust-ini", @@ -1406,6 +1421,15 @@ dependencies = [ "dirs-sys 0.4.1", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + [[package]] name = "dirs-sys" version = "0.4.1" @@ -1427,7 +1451,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.0", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1436,6 +1460,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.1", + "libc", + "objc2 0.6.1", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1526,6 +1562,7 @@ dependencies = [ "futuresdr", "image", "macros", + "native-dialog", "num-complex", "open", "parse-changelog", @@ -1589,7 +1626,7 @@ dependencies = [ "js-sys", "log", "objc2 0.5.2", - "objc2-app-kit", + "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "parking_lot", "percent-encoding", @@ -1870,6 +1907,12 @@ dependencies = [ "regex", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "env_logger" version = "0.8.4" @@ -2052,6 +2095,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "formatx" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8866fac38f53fc87fa3ae1b09ddd723e0482f8fa74323518b4c59df2c55a00a" + [[package]] name = "foxhunter-profile" version = "0.1.0" @@ -2220,7 +2269,7 @@ dependencies = [ "config", "core_affinity", "cpal", - "dirs", + "dirs 5.0.1", "dyn-clone", "futuredsp 0.0.6 (git+https://github.com/MerchGuardian/FutureSDR.git?branch=main)", "futures", @@ -2449,7 +2498,7 @@ dependencies = [ "glutin_wgl_sys", "libloading", "objc2 0.5.2", - "objc2-app-kit", + "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "once_cell", "raw-window-handle", @@ -3004,6 +3053,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -3355,6 +3413,30 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "native-dialog" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f006431cea71a83e6668378cb5abc2d52af299cbac6dca1780c6eeca90822df" +dependencies = [ + "ascii", + "block2 0.6.1", + "dirs 6.0.0", + "dispatch2", + "formatx", + "objc2 0.6.1", + "objc2-app-kit 0.3.1", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.1", + "raw-window-handle", + "thiserror 2.0.12", + "versions", + "wfd", + "which", + "winapi", +] + [[package]] name = "ndk" version = "0.8.0" @@ -3437,6 +3519,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -3569,9 +3660,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3531f65190d9cff863b77a99857e74c314dd16bf56c538c4b57c7cbc3f3a6e59" +checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" dependencies = [ "objc2-encode", ] @@ -3583,13 +3674,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.9.0", - "block2", + "block2 0.5.1", "libc", "objc2 0.5.2", - "objc2-core-data", - "objc2-core-image", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", "objc2-foundation 0.2.2", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.1", + "libc", + "objc2 0.6.1", + "objc2-cloud-kit 0.3.1", + "objc2-core-data 0.3.1", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.1", + "objc2-foundation 0.3.1", + "objc2-quartz-core 0.3.1", ] [[package]] @@ -3599,19 +3709,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.9.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.1", + "objc2-foundation 0.3.1", +] + [[package]] name = "objc2-contacts" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3623,21 +3744,71 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.9.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-data" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.1", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.1", + "dispatch2", + "libc", + "objc2 0.6.1", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.1", + "dispatch2", + "libc", + "objc2 0.6.1", + "objc2-core-foundation", + "objc2-io-surface", + "objc2-metal 0.3.1", +] + [[package]] name = "objc2-core-image" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" +dependencies = [ + "objc2 0.6.1", + "objc2-foundation 0.3.1", ] [[package]] @@ -3646,7 +3817,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", @@ -3665,7 +3836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.9.0", - "block2", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -3673,12 +3844,26 @@ dependencies = [ [[package]] name = "objc2-foundation" -version = "0.3.0" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.1", + "libc", + "objc2 0.6.1", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a21c6c9014b82c39515db5b396f91645182611c97d24637cf56ac01e5f8d998" +checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" dependencies = [ "bitflags 2.9.0", - "objc2 0.6.0", + "objc2 0.6.1", + "objc2-core-foundation", ] [[package]] @@ -3687,9 +3872,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", - "objc2-app-kit", + "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] @@ -3700,11 +3885,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.9.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-metal" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f246c183239540aab1782457b35ab2040d4259175bd1d0c58e46ada7b47a874" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.1", + "objc2-foundation 0.3.1", +] + [[package]] name = "objc2-quartz-core" version = "0.2.2" @@ -3712,10 +3908,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.9.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.1", + "objc2-foundation 0.3.1", ] [[package]] @@ -3735,15 +3942,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.9.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-image", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", "objc2-user-notifications", @@ -3755,7 +3962,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3767,7 +3974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.9.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -4485,9 +4692,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.3" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e56a18552996ac8d29ecc3b190b4fdbb2d91ca4ec396de7bbffaf43f3d637e96" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ "bitflags 2.9.0", "errno", @@ -4581,7 +4788,7 @@ source = "git+https://github.com/MerchGuardian/seify.git?branch=main#fde7968ea2d dependencies = [ "futures", "log", - "nom", + "nom 7.1.3", "num-complex", "once_cell", "seify-hackrfone", @@ -5217,7 +5424,7 @@ dependencies = [ "fastrand", "getrandom 0.3.2", "once_cell", - "rustix 1.0.3", + "rustix 1.0.7", "windows-sys 0.59.0", ] @@ -5847,6 +6054,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "versions" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80a7e511ce1795821207a837b7b1c8d8aca0c648810966ad200446ae58f6667f" +dependencies = [ + "itertools 0.14.0", + "nom 8.0.0", +] + [[package]] name = "vmcircbuffer" version = "0.0.10" @@ -6097,8 +6314,8 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.0", - "objc2-foundation 0.3.0", + "objc2 0.6.1", + "objc2-foundation 0.3.1", "url", "web-sys", ] @@ -6118,6 +6335,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" +[[package]] +name = "wfd" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e713040b67aae5bf1a0ae3e1ebba8cc29ab2b90da9aa1bff6e09031a8a41d7a8" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "wgpu" version = "24.0.1" @@ -6221,6 +6448,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix 1.0.7", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6716,7 +6955,7 @@ dependencies = [ "android-activity", "atomic-waker", "bitflags 2.9.0", - "block2", + "block2 0.5.1", "bytemuck", "calloop", "cfg_aliases", @@ -6730,7 +6969,7 @@ dependencies = [ "memmap2", "ndk 0.9.0", "objc2 0.5.2", - "objc2-app-kit", + "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "objc2-ui-kit", "orbclient", @@ -6767,6 +7006,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/blocks/src/blocks/math.rs b/blocks/src/blocks/math.rs index a68418e8..b6b94583 100644 --- a/blocks/src/blocks/math.rs +++ b/blocks/src/blocks/math.rs @@ -6,10 +6,10 @@ use crate::{BoolValue, prelude::*}; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, macros::Block)] #[block(new = "construct_sine_source")] pub struct SineSourceBlock { - #[block(range = { initial: 915MHz, range: 1..=20GHz })] + #[block(range = { initial: 50kHz, range: 1..=20GHz })] freq: FloatValue, - #[block(range = { initial: 2MHz, range: 50kHz..=60MHz })] + #[block(range = { initial: 20kHz, range: 10kHz..=60MHz })] sample_rate: FloatValue, #[block(range = { initial: 0, range: 0..=1e12 })] @@ -64,7 +64,7 @@ pub struct CosineSourceBlock { #[block(range = { initial: 915MHz, range: 1..=20GHz })] freq: FloatValue, - #[block(range = { initial: 2MHz, range: 50kHz..=60MHz })] + #[block(range = { initial: 20kHz, range: 10kHz..=60MHz })] sample_rate: FloatValue, #[block(range = { initial: 0, range: 0..=1e12 })] @@ -119,7 +119,7 @@ pub struct SquareWaveSourceBlock { #[block(range = { initial: 915MHz, range: 50MHz..=2GHz })] freq: FloatValue, - #[block(range = { initial: 2MHz, range: 50kHz..=60MHz })] + #[block(range = { initial: 20kHz, range: 10kHz..=60MHz })] sample_rate: FloatValue, #[block(range = { initial: 0, range: 0..=1e12 })] diff --git a/dsp-core/src/logging.rs b/dsp-core/src/logging.rs index bddf884d..64eaf7a8 100644 --- a/dsp-core/src/logging.rs +++ b/dsp-core/src/logging.rs @@ -29,7 +29,7 @@ pub fn init_with_level(level: tracing::Level, file_output: Option<&Path>) -> Vec fn log_file_name() -> String { chrono::Local::now() .naive_local() - .format("dsp-studio-log_%Y-%m-%d_%H-%M-%S.json") + .format("foxhunter-studio-log_%Y-%m-%d_%H-%M-%S.json") .to_string() } @@ -65,14 +65,20 @@ pub fn init_with_level(level: tracing::Level, file_output: Option<&Path>) -> Vec let mut win_path: Vec = path.as_os_str().encode_wide().collect(); win_path.push(0); + // SAFETY: Comes from `windows-sys` crate, string is null terminated. let attrs = unsafe { FileSystem::GetFileAttributesW(win_path.as_ptr()) }; let attrs = attrs | FileSystem::FILE_ATTRIBUTE_HIDDEN; + // SAFETY: Comes from `windows-sys` crate, string is null terminated. unsafe { FileSystem::SetFileAttributesW(win_path.as_ptr(), attrs) }; } } path.push("logs"); + if let Err(e) = std::fs::create_dir_all(&path) { + eprintln!("WARN: failed to create log directory {path:?}: {e:?}"); + } + // TODO(tjn, #129): delete (> ~2 days) old log files path.push(log_file_name()); path @@ -120,16 +126,8 @@ pub fn init_with_level(level: tracing::Level, file_output: Option<&Path>) -> Vec // ========== STDOUT LOG ========== - let log_crate_level = match level { - tracing::Level::ERROR => log::LevelFilter::Error, - tracing::Level::WARN => log::LevelFilter::Warn, - tracing::Level::INFO => log::LevelFilter::Info, - tracing::Level::DEBUG => log::LevelFilter::Debug, - tracing::Level::TRACE => log::LevelFilter::Trace, - }; - tracing_log::LogTracer::builder() - .with_max_level(log_crate_level) + .with_max_level(log::LevelFilter::Debug) .init() .expect("Failed to build tracing_log tracer"); @@ -158,29 +156,27 @@ pub fn init_with_level(level: tracing::Level, file_output: Option<&Path>) -> Vec tracing::info!("Writing verbose logs to {log_file_path:?}"); - #[cfg(target_os = "android")] - { - std::panic::set_hook(Box::new(|info| { - let (file, line) = { - if let Some(location) = info.location() { - (location.file(), location.line()) - } else { - ("", 0) - } - }; - - // Try our best to extract a string message from payload - let msg = match info.payload().downcast_ref::<&'static str>() { - Some(s) => *s, - None => match info.payload().downcast_ref::() { - Some(s) => &s[..], - None => "Box", - }, - }; - - tracing::error!("foxhunterjni PANICKED: {msg}, at {file}:{line}"); - })); - } + std::panic::set_hook(Box::new(|info| { + let (file, line) = { + if let Some(location) = info.location() { + (location.file(), location.line()) + } else { + ("", 0) + } + }; + + // Try our best to extract a string message from payload + let msg = match info.payload().downcast_ref::<&'static str>() { + Some(s) => *s, + None => match info.payload().downcast_ref::() { + Some(s) => s.as_str(), + None => "Box", + }, + }; + + #[cfg(not(target_os = "android"))] + tracing::error!("PANIC: {msg}, at {file}:{line}"); + })); guards } diff --git a/examples/windows-crash.fds b/examples/windows-crash.fds new file mode 100644 index 00000000..c4609dbb --- /dev/null +++ b/examples/windows-crash.fds @@ -0,0 +1,108 @@ +[manifest.created] +save_format_version = "0.1.0" +dsp_studio_version = "0.0.1" +dsp_studio_version_commit_hash = "a962be1fb9c7656fc2841b9d9c635957a68e314c-dirty" + +[manifest.modified] +save_format_version = "0.1.0" +dsp_studio_version = "0.0.1" +dsp_studio_version_commit_hash = "a962be1fb9c7656fc2841b9d9c635957a68e314c-dirty" + +[blocks.0.position] +x = 310.1569519042969 +y = 200.38938903808594 + +[blocks.0.SineSource.freq] +value = 50000.0 + +[blocks.0.SineSource.freq.range] +start = 1.0 +end = 20000000000.0 + +[blocks.0.SineSource.sample_rate] +value = 20000.0 + +[blocks.0.SineSource.sample_rate.range] +start = 10000.0 +end = 60000000.0 + +[blocks.0.SineSource.offset] +value = 0.0 + +[blocks.0.SineSource.offset.range] +start = 0.0 +end = 1000000000000.0 + +[blocks.0.SineSource.amplitude] +value = 1.0 + +[blocks.0.SineSource.amplitude.range] +start = 0.0 +end = 1000000000000.0 + +[blocks.0.SineSource.initial_phase] +value = 0.0 + +[blocks.0.SineSource.initial_phase.range] +start = 0.0 +end = 6.29 + +[blocks.0.SineSource.output] +name = "out" +supported = ["RealSlice32", "ComplexSlice32"] +current = 0 + +[blocks.0.SineSource.metadata] +pin_types_match = false + +[blocks.1.position] +x = 369.3246154785156 +y = 455.82696533203125 + +[blocks.1.AddConst.add] +value = 1.0 + +[blocks.1.AddConst.add.range] +start = 0.0 +end = 10000000.0 + +[blocks.1.AddConst.input] +name = "in" +supported = ["ComplexSlice32", "RealSlice32"] +current = 1 + +[blocks.1.AddConst.output] +name = "out" +supported = ["ComplexSlice32", "RealSlice32"] +current = 1 + +[blocks.1.AddConst.metadata] +pin_types_match = true + +[blocks.2.position] +x = 425.1732482910156 +y = 663.0885009765625 + +[blocks.2.AudioSink.audio_rate] +index = 0 +supported = [24000.0, 44100.0, 48000.0, 96000.0, 384000.0] + +[blocks.2.AudioSink.input] +name = "in" +supported = ["RealSlice32"] +current = 0 + +[blocks.2.AudioSink.metadata] +pin_types_match = false + +[[connections]] +src_index = "0" +src_port_index = 0 +dst_index = "1" +dst_port_index = 0 + +[[connections]] +src_index = "1" +src_port_index = 0 +dst_index = "2" +dst_port_index = 0 diff --git a/flake.nix b/flake.nix index d6bae22e..80501ac7 100644 --- a/flake.nix +++ b/flake.nix @@ -161,6 +161,9 @@ TARGET_CC = "${pkgs.pkgsCross.mingwW64.stdenv.cc}/bin/${pkgs.pkgsCross.mingwW64.stdenv.cc.targetPrefix}cc"; + separateDebugInfo = true; + dontStrip = true; + depsBuildBuild = with pkgs; [ pkgsCross.mingwW64.stdenv.cc pkgsCross.mingwW64.windows.pthreads diff --git a/studio/Cargo.toml b/studio/Cargo.toml index 4a55891f..161361aa 100644 --- a/studio/Cargo.toml +++ b/studio/Cargo.toml @@ -14,6 +14,8 @@ pretty_env_logger.workspace = true serde_json.workspace = true serde.workspace = true semver.workspace = true +strum.workspace = true +strum_macros.workspace = true clap = { version = "4.5.27", features = ["derive"] } clio = { version = "0.3.5", features = ["clap-parse"] } @@ -32,9 +34,8 @@ image = { version = "0.25", default-features = false, features = [ "jpeg", "png", ] } +native-dialog = { version = "0.9.0", features = ["windows_dpi_awareness"] } num-complex = "0.4.6" -strum.workspace = true -strum_macros.workspace = true macros = { path = "../macros" } syn = "2.0.90" thiserror = "2.0.6" diff --git a/studio/README.md b/studio/README.md new file mode 100644 index 00000000..1a2f4e0e --- /dev/null +++ b/studio/README.md @@ -0,0 +1,29 @@ + +# foxhunter-studio + +## Running + +``` +troy@battlestation ~/f/foxhunter (master)> cargo b --bin dsp-studio && ./target/debug/dsp-studio --help + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.22s +Usage: dsp-studio [OPTIONS] [FILE] + +Arguments: + [FILE] + .fds flowgraph save to open + +Options: + -v, --verbose + Enable verbose logging + + --version + Print version information and exit + + --log-path + File path to write json application logs to. + + If unspecified, the default log locations are as follows: Windows: Logs are written to C:\Users\\.foxhunter-studio\... Unix: Logs are written to $TMPDIR or (if unset), /tmp + + -h, --help + Print help (see a summary with '-h') +``` diff --git a/studio/src/main.rs b/studio/src/main.rs index 7b4b3142..83e9192e 100644 --- a/studio/src/main.rs +++ b/studio/src/main.rs @@ -1,4 +1,4 @@ -#![windows_subsystem = "windows"] +// #![windows_subsystem = "windows"] use dsp_studio::DspStudioApp; use tracing::info; @@ -7,6 +7,7 @@ use tracing::info; fn main() -> eframe::Result<()> { use anyhow::Context; use std::{io::Read, path::PathBuf}; + use tracing::error; use clap::Parser; @@ -27,7 +28,9 @@ fn main() -> eframe::Result<()> { /// File path to write json application logs to. /// - /// Defaults creating a timestamped file in your temporary directory. + /// If unspecified, the default log locations are as follows: + /// Windows: Logs are written to C:\Users\\.foxhunter-studio\... + /// Unix: Logs are written to $TMPDIR or (if unset), /tmp #[clap(long)] log_path: Option, } @@ -82,7 +85,7 @@ fn main() -> eframe::Result<()> { ..Default::default() }; - eframe::run_native( + if let Err(e) = eframe::run_native( "Foxhunter DSP Studio", native_options, Box::new(|cx| { @@ -100,7 +103,20 @@ fn main() -> eframe::Result<()> { } Ok(Box::new(app)) }), - )?; + ) { + error!("Failed to start: {e:?}"); + + let r = native_dialog::DialogBuilder::message() + .set_title("foxhunter-studio could not start") + .set_text(e.to_string()) + .set_level(native_dialog::MessageLevel::Error) + .confirm() + .show(); + if let Err(e) = r { + error!("Failed to show startup error with native_dialog: {e:?}"); + } + return Err(e.into()); + } Ok(()) } From 8225cf296f8dfa23d83a8ee4fbf258b60cad4924 Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Sat, 21 Jun 2025 22:26:28 -0700 Subject: [PATCH 06/15] almost done! --- Cargo.lock | 52 +++++- blocks/Cargo.toml | 2 +- blocks/src/block.rs | 6 + blocks/src/blocks/audio.rs | 306 ++++++++++++++++++++++++++------- blocks/src/blocks/fm.rs | 3 +- blocks/src/types.rs | 6 + examples/audio-source3.fx | 146 ++++++++++++++++ flake.nix | 3 +- macros/src/lib.rs | 194 +++++++++++++-------- studio/src/flowgraph_viewer.rs | 2 + 10 files changed, 576 insertions(+), 144 deletions(-) create mode 100644 examples/audio-source3.fx diff --git a/Cargo.lock b/Cargo.lock index 8e80200c..9b0484f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,7 +351,7 @@ version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ - "libloading", + "libloading 0.8.8", ] [[package]] @@ -994,7 +994,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.8", ] [[package]] @@ -1283,6 +1283,7 @@ dependencies = [ "alsa", "coreaudio-rs 0.13.0", "dasp_sample", + "jack", "jni", "js-sys", "libc", @@ -1545,7 +1546,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ - "libloading", + "libloading 0.8.8", ] [[package]] @@ -2508,7 +2509,7 @@ dependencies = [ "glutin_egl_sys", "glutin_glx_sys", "glutin_wgl_sys", - "libloading", + "libloading 0.8.8", "objc2 0.6.1", "objc2-app-kit 0.3.1", "objc2-core-foundation", @@ -3029,6 +3030,33 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jack" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70ca699f44c04a32d419fc9ed699aaea89657fc09014bf3fa238e91d13041b9" +dependencies = [ + "bitflags 2.9.1", + "jack-sys", + "lazy_static", + "libc", + "log", +] + +[[package]] +name = "jack-sys" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6013b7619b95a22b576dfb43296faa4ecbe40abbdb97dfd22ead520775fc86ab" +dependencies = [ + "bitflags 1.3.2", + "lazy_static", + "libc", + "libloading 0.7.4", + "log", + "pkg-config", +] + [[package]] name = "jni" version = "0.21.1" @@ -3095,7 +3123,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", - "libloading", + "libloading 0.8.8", "pkg-config", ] @@ -3141,6 +3169,16 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libloading" version = "0.8.8" @@ -6574,7 +6612,7 @@ dependencies = [ "js-sys", "khronos-egl", "libc", - "libloading", + "libloading 0.8.8", "log", "metal", "naga", @@ -7302,7 +7340,7 @@ dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "libloading", + "libloading 0.8.8", "once_cell", "rustix 0.38.44", "x11rb-protocol", diff --git a/blocks/Cargo.toml b/blocks/Cargo.toml index 7f53d41d..3b058648 100644 --- a/blocks/Cargo.toml +++ b/blocks/Cargo.toml @@ -26,7 +26,7 @@ macros = { path = "../macros" } dsp-core = { path = "../dsp-core" } toml = { version = "0.8.19", features = ["preserve_order"] } tracing = "0.1.41" -cpal = "0.16.0" +cpal = { version = "0.16.0", features = ["jack"] } thingbuf = { version = "0.1.6", features = ["alloc"] } async-trait = "0.1.88" diff --git a/blocks/src/block.rs b/blocks/src/block.rs index e44f6b85..d0447687 100644 --- a/blocks/src/block.rs +++ b/blocks/src/block.rs @@ -257,6 +257,8 @@ pub trait BlockTrait { fn is_sink(&self) -> bool { self.outputs().is_empty() } + + fn render(&mut self, ui: &mut egui::Ui); } impl Block { @@ -424,6 +426,10 @@ impl BlockTrait for Block { fn metadata(&self) -> &BlockMetadata { dispatch_to_block!(self, metadata) } + + fn render(&mut self, ui: &mut egui::Ui) { + dispatch_to_block!(self, render, ui) + } } #[cfg(test)] diff --git a/blocks/src/blocks/audio.rs b/blocks/src/blocks/audio.rs index 8d84355c..223fdcd7 100644 --- a/blocks/src/blocks/audio.rs +++ b/blocks/src/blocks/audio.rs @@ -1,7 +1,12 @@ use std::sync::Arc; +use std::time::{Duration, Instant}; +// TODO: +// 1. Graceful shutdown (threads still running?) +// 2. libjack support (nix) + +use crate::prelude::*; use crate::{BlockMetadata, BlockPin, ParameterValueMut, ParameterValueRef}; -use crate::{F64Picker, prelude::*}; use async_trait::async_trait; use atomic_slot::AtomicSlot; @@ -9,8 +14,8 @@ use cpal::BufferSize; use cpal::SampleRate; use cpal::Stream; use cpal::StreamConfig; -use cpal::traits::DeviceTrait; use cpal::traits::StreamTrait; +use cpal::traits::{DeviceTrait, HostTrait}; use dsp_core::byte_buf::ByteBuf; use futuresdr::runtime::BlockMeta; use futuresdr::runtime::BlockMetaBuilder; @@ -25,29 +30,13 @@ use futuresdr::runtime::WorkIo; use thingbuf::mpsc::errors::TryRecvError; use tracing::{error, warn}; -pub fn supported_audio_rates() -> Vec { - const DEFAULT_RATES: &[f64] = &[44_100., 48_000., 96_000., 384_000.]; - #[cfg(not(target_arch = "wasm32"))] - { - let rates: Vec = futuresdr::blocks::audio::AudioSink::supported_sample_rates() - .into_iter() - .map(Into::into) - .collect(); - if rates.is_empty() { - Vec::from(DEFAULT_RATES) - } else { - rates - } - } - #[cfg(target_arch = "wasm32")] - Vec::from(DEFAULT_RATES) -} - #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, macros::Block)] #[block(new = "construct_audio_source")] +#[block(custom_render = "render_audio_source")] pub struct AudioSourceBlock { - #[block(default = "F64Picker::with_supported_fn(supported_audio_rates)")] - audio_rate: F64Picker, + #[block(skip)] + #[serde(skip)] + audio_device: CpalDevicePicker, #[block(output_kinds = [PinKind::RealSlice32])] output: BlockPin, @@ -61,27 +50,21 @@ fn construct_audio_source( block: &AudioSourceBlock, _ctx: &mut ConstructContext, ) -> anyhow::Result { - use cpal::traits::HostTrait; - - let host = cpal::default_host(); - let device = host.default_output_device().ok_or_else(|| { - anyhow!( - "No output audio devices found on default host {}", - host.id().name() - ) - })?; - tracing::info!( - "Opening {} on {}", - device.name().unwrap_or_default(), - host.id().name() - ); - - Ok(FutureSdrBlock::from_typed(AudioSourceFsdr::new( - &device, - *block.audio_rate.current() as u32, - 1, - )?) - .into()) + let device = block + .audio_device + .get_device() + .ok_or_else(|| anyhow!("No audio device selected"))?; + + let sample_rate = block + .audio_device + .get_sample_rate() + .ok_or_else(|| anyhow!("No audio sample rate selected"))?; + + Ok(FutureSdrBlock::from_typed(AudioSourceFsdr::new(device, sample_rate, 1)?).into()) +} + +fn render_audio_source(block: &mut AudioSourceBlock, ui: &mut egui::Ui) { + block.audio_device.render(ui); } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, macros::Block)] @@ -109,9 +92,11 @@ fn construct_audio_file_source( #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, macros::Block)] #[block(new = "construct_audio_sink")] +#[block(custom_render = "render_audio_sink")] pub struct AudioSinkBlock { - #[block(default = "F64Picker::with_supported_fn(supported_audio_rates)")] - audio_rate: F64Picker, + #[block(skip)] + #[serde(skip)] + audio_device: CpalDevicePicker, #[block(input_kinds = [PinKind::RealSlice32])] input: BlockPin, @@ -125,27 +110,21 @@ fn construct_audio_sink( block: &AudioSinkBlock, _ctx: &mut ConstructContext, ) -> anyhow::Result { - use cpal::traits::HostTrait; - - let host = cpal::default_host(); - let device = host.default_output_device().ok_or_else(|| { - anyhow!( - "No output audio devices found on default host {}", - host.id().name() - ) - })?; - tracing::info!( - "Opening {} on {}", - device.name().unwrap_or_default(), - host.id().name() - ); - - Ok(FutureSdrBlock::from_typed(AudioSinkFsdr::new( - &device, - *block.audio_rate.current() as u32, - 1, - )?) - .into()) + let device = block + .audio_device + .get_device() + .ok_or_else(|| anyhow!("No audio device selected"))?; + + let sample_rate = block + .audio_device + .get_sample_rate() + .ok_or_else(|| anyhow!("No audio sample rate selected"))?; + + Ok(FutureSdrBlock::from_typed(AudioSinkFsdr::new(device, sample_rate, 1)?).into()) +} + +fn render_audio_sink(block: &mut AudioSinkBlock, ui: &mut egui::Ui) { + block.audio_device.render(ui); } /// Audio Source. @@ -413,3 +392,198 @@ impl Kernel for AudioSinkFsdr { Ok(()) } } + +#[derive(Clone, Default)] +pub struct CpalDevicePicker { + hosts: Vec>, + host_index: usize, + devices: Vec, + device_index: usize, + sample_rates: Vec, + sample_rate_index: usize, + last_device_scan: Option, + is_sink: bool, +} + +const AUDIO_DEVICE_SCAN_INTERVAL: Duration = Duration::from_secs(1); + +impl CpalDevicePicker { + fn render(&mut self, ui: &mut egui::Ui) { + if self + .last_device_scan + .map(|last| last.elapsed() > AUDIO_DEVICE_SCAN_INTERVAL) + .unwrap_or(true) + { + self.scan(); + } + + ui.horizontal(|ui| { + ui.label("Audio backend: "); + if !self.hosts.is_empty() { + let mut selected = self.hosts[self.host_index].id(); + let initial_selected = selected; + egui::ComboBox::from_id_salt("AudioSource host") + .selected_text(selected.name()) + .show_ui(ui, |ui| { + for host in &self.hosts { + ui.selectable_value(&mut selected, host.id(), host.id().name()); + } + }); + + if initial_selected != selected { + self.host_index = self + .hosts + .iter() + .position(|h| h.id() == selected) + .unwrap_or(0); + + // New host -> new devices so rescan + self.last_device_scan = None; + } + } + }); + + if !self.devices.is_empty() { + ui.horizontal(|ui| { + ui.label("Device: "); + let mut selected = self.devices[self.device_index].name().unwrap_or_default(); + + egui::ComboBox::from_id_salt("AudioSource device") + .selected_text(&selected) + .show_ui(ui, |ui| { + for (i, dev) in self.devices.iter().enumerate() { + let name = dev.name().unwrap(); + ui.selectable_value(&mut selected, name.clone(), name.clone()); + if selected == name { + self.device_index = i; + } + } + }); + }); + } + + if !self.sample_rates.is_empty() { + ui.horizontal(|ui| { + ui.label("Sample rate: "); + let mut selected = self.sample_rates[self.sample_rate_index]; + + egui::ComboBox::from_id_salt("AudioSource sample rate") + .selected_text(selected.to_string()) + .show_ui(ui, |ui| { + for (i, rate) in self.sample_rates.iter().enumerate() { + ui.selectable_value(&mut selected, *rate, rate.to_string()); + if selected == *rate { + self.sample_rate_index = i; + } + } + }); + }); + } + } + + pub fn get_device(&self) -> Option<&cpal::Device> { + if self.devices.is_empty() { + assert_eq!(self.device_index, 0); + None + } else { + Some(&self.devices[self.device_index]) + } + } + + pub fn supported_samples_rates(&self) -> &[u32] { + &self.sample_rates + } + + pub fn get_sample_rate(&self) -> Option { + if self.sample_rates.is_empty() { + assert_eq!(self.sample_rate_index, 0); + None + } else { + Some(self.sample_rates[self.sample_rate_index]) + } + } + + fn scan(&mut self) { + self.last_device_scan = Some(Instant::now()); + + // Check hosts + self.hosts = cpal::available_hosts() + .into_iter() + .flat_map(|h| cpal::platform::host_from_id(h).ok()) + .map(|h| Arc::new(h)) + .collect(); + + self.host_index = self.host_index.min(self.hosts.len().saturating_sub(1)); + + // Check devices + assert!(!self.is_sink); + + let selected_host = &self.hosts[self.host_index]; + self.devices = selected_host + .output_devices() + .map(|devs| devs.collect()) + .unwrap_or_default(); + + self.device_index = self.device_index.min(self.devices.len().saturating_sub(1)); + let Some(device) = self.get_device() else { + return; + }; + + // Sample rates (min, max) + let configs: Vec<_> = if self.is_sink { + device + .supported_output_configs() + .into_iter() + .map(|c| c.collect::>()) + .flatten() + .collect() + } else { + device + .supported_input_configs() + .into_iter() + .map(|c| c.collect::>()) + .flatten() + .collect() + }; + + const STANDARD_RATES: [u32; 4] = [24000, 44100, 48000, 96000]; + + let mut rates = Vec::new(); + for c in configs { + let min = c.min_sample_rate().0; + let max = c.max_sample_rate().0; + if min >= 10000 { + rates.push(min); + } + if max >= 10000 { + rates.push(max); + } + + rates.extend(STANDARD_RATES.iter().filter(|x| *x >= &min && *x <= &max)); + } + rates.sort(); + rates.dedup(); + + self.sample_rates = rates; + self.sample_rate_index = self + .sample_rate_index + .min(self.sample_rates.len().saturating_sub(1)); + } +} + +impl std::fmt::Debug for CpalDevicePicker { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CpalDevicePicker") + .field("host_index", &self.host_index) + .field("devices_count", &self.devices.len()) + .field("device_index", &self.device_index) + .finish() + } +} + +impl std::cmp::PartialEq for CpalDevicePicker { + fn eq(&self, _: &Self) -> bool { + // Ignore. User defined so assume all are the same + true + } +} diff --git a/blocks/src/blocks/fm.rs b/blocks/src/blocks/fm.rs index 77cbd568..40c44e9e 100644 --- a/blocks/src/blocks/fm.rs +++ b/blocks/src/blocks/fm.rs @@ -1,4 +1,3 @@ -use super::audio::supported_audio_rates; use crate::{BlockMetadata, BlockPin, FloatValue, ParameterValueMut, ParameterValueRef}; use crate::{F64Picker, prelude::*}; @@ -7,7 +6,7 @@ use crate::{F64Picker, prelude::*}; pub struct FmDemodBlock { #[block(range = { initial: 2MHz, range: 50kHz..=60MHz })] sample_rate: FloatValue, - #[block(default = "F64Picker::with_supported_fn(supported_audio_rates)")] + #[block(default = "F64Picker::new(vec![24000.0, 44100.0, 48000.0, 96000.0])")] audio_rate: F64Picker, #[block(range = { initial: 5, range: 1..=10 })] audio_multiplier: FloatValue, diff --git a/blocks/src/types.rs b/blocks/src/types.rs index 08facdc9..638b6dab 100644 --- a/blocks/src/types.rs +++ b/blocks/src/types.rs @@ -215,6 +215,12 @@ impl Picker { } } + pub fn set_supported(&mut self, supported: Vec) { + assert!(!supported.is_empty(), "Picker cannot be empty"); + self.index = std::cmp::min(supported.len() - 1, self.index); + self.supported = supported; + } + pub fn supported(&self) -> impl Iterator { self.supported.iter() } diff --git a/examples/audio-source3.fx b/examples/audio-source3.fx new file mode 100644 index 00000000..2a159654 --- /dev/null +++ b/examples/audio-source3.fx @@ -0,0 +1,146 @@ +[manifest.created] +save_format_version = "0.1.0" +dsp_studio_version = "0.0.1" +dsp_studio_version_commit_hash = "a962be1fb9c7656fc2841b9d9c635957a68e314c-dirty" + +[manifest.modified] +save_format_version = "0.1.0" +dsp_studio_version = "0.0.1" +dsp_studio_version_commit_hash = "d449f9f786b1c71bcd096245e878125d3612b77e-dirty" + +[blocks.0.position] +x = 161.76022338867188 +y = 95.3421630859375 + +[blocks.0.AudioSource.output] +name = "out" +supported = ["RealSlice32"] +current = 0 + +[blocks.0.AudioSource.metadata] +pin_types_match = false + +[blocks.1.position] +x = 842.0177001953125 +y = 227.5792999267578 + +[blocks.1.Spectrogram] +name = "spectrogram" + +[blocks.1.Spectrogram.bins] +value = 1865 + +[blocks.1.Spectrogram.bins.range] +start = 32 +end = 8192 + +[blocks.1.Spectrogram.input] +name = "in" +supported = ["ComplexSlice32"] +current = 0 + +[blocks.1.Spectrogram.metadata] +pin_types_match = false + +[blocks.2.position] +x = 555.4561157226563 +y = 222.5496063232422 + +[blocks.2.RealToComplex.real] +name = "in0" +supported = ["RealSlice32"] +current = 0 + +[blocks.2.RealToComplex.im] +name = "in1" +supported = ["RealSlice32"] +current = 0 + +[blocks.2.RealToComplex.output] +name = "out" +supported = ["ComplexSlice32"] +current = 0 + +[blocks.2.RealToComplex.metadata] +pin_types_match = false + +[blocks.3.position] +x = 242.08436584472656 +y = 294.5403137207031 + +[blocks.3.ConstSource] +value = "0" + +[blocks.3.ConstSource.output] +name = "out" +supported = ["RealSlice32"] +current = 0 + +[blocks.3.ConstSource.metadata] +pin_types_match = false + +[blocks.4.position] +x = 788.7567749023438 +y = 15.407486915588379 + +[blocks.4.AudioSink.input] +name = "in" +supported = ["RealSlice32"] +current = 0 + +[blocks.4.AudioSink.metadata] +pin_types_match = false + +[blocks.5.position] +x = 476.85589599609375 +y = -58.19428634643555 + +[blocks.5.MultiplyConst.multiply_by] +value = 0.1 + +[blocks.5.MultiplyConst.multiply_by.range] +start = 0.0 +end = 10000000.0 + +[blocks.5.MultiplyConst.input] +name = "in" +supported = ["ComplexSlice32", "RealSlice32"] +current = 1 + +[blocks.5.MultiplyConst.output] +name = "out" +supported = ["ComplexSlice32", "RealSlice32"] +current = 1 + +[blocks.5.MultiplyConst.metadata] +pin_types_match = true + +[[connections]] +src_index = "3" +src_port_index = 0 +dst_index = "2" +dst_port_index = 1 + +[[connections]] +src_index = "0" +src_port_index = 0 +dst_index = "5" +dst_port_index = 0 + +[[connections]] +src_index = "0" +src_port_index = 0 +dst_index = "2" +dst_port_index = 0 + +[[connections]] +src_index = "2" +src_port_index = 0 +dst_index = "1" +dst_port_index = 0 + +[[connections]] +src_index = "5" +src_port_index = 0 +dst_index = "4" +dst_port_index = 0 diff --git a/flake.nix b/flake.nix index eb305060..8f3cc1cb 100644 --- a/flake.nix +++ b/flake.nix @@ -90,7 +90,7 @@ }); # Required libs that get added to LD_LIBRARY_PATH for `cargo r` + used by autoPatchelfHook dynamic-libs = [] ++ lib.optionals pkgs.stdenv.isLinux (with pkgs; [ - pkgs.alsa-lib + alsa-lib libxkbcommon vulkan-loader xorg.libX11 @@ -117,6 +117,7 @@ buildInputs = [] ++ lib.optionals pkgs.stdenv.isLinux [ pkgs.alsa-lib + pkgs.libjack2 ] ++ lib.optionals pkgs.stdenv.isDarwin [ pkgs.libiconv ]; diff --git a/macros/src/lib.rs b/macros/src/lib.rs index de9ecea3..01f493e2 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -141,16 +141,23 @@ struct BlockDeriver { struct_ident: Ident, /// Generics on the struct generics: syn::Generics, - /// The user-supplied `#[block(new = "")]`. - new_fn_path: Path, + attributes: BlockAttributes, /// Each named field in the struct parameters: Vec, inputs: Vec, outputs: Vec, + skipped: Vec, metadata: FieldInfo, errors: Vec, } +struct BlockAttributes { + /// The user-supplied `#[block(new = "")]`. + new_fn_path: Path, + /// If the user set `#[block(custom_render = "")]`. + custom_render: Option, +} + impl BlockDeriver { /// Parses the input struct into validated state. fn new(input_ast: DeriveInput) -> Result { @@ -176,13 +183,14 @@ impl BlockDeriver { } }; - let new_fn_path = parse_block_new_fn(&input_ast.attrs)?; + let attributes = parse_block_attributes(&input_ast.attrs)?; let mut errors = Vec::new(); let mut fields = Vec::new(); for f in fields_named { match FieldInfo::parse(f) { - Ok(info) => fields.push(info), + Ok(Some(info)) => fields.push(info), + Ok(None) => {} Err(e) => errors.push(e), } } @@ -190,21 +198,25 @@ impl BlockDeriver { let mut inputs = vec![]; let mut parameters = vec![]; let mut outputs = vec![]; + let mut skipped = vec![]; let mut metadata = None; let mut last_kind = FieldKind::Param; for f in fields { // Enforce order - if f.kind < last_kind { - let msg = match f.kind { - FieldKind::Param => "parameter fields must come first", - FieldKind::Input => "input fields must come after parameters", - FieldKind::Output => "output fields must before metadata", - FieldKind::Metadata => "the metadata field must come last", - }; - errors.push(Error::new(f.span, msg)); + if f.kind != FieldKind::Skip { + if f.kind < last_kind { + let msg = match f.kind { + FieldKind::Param => "parameter fields must come first", + FieldKind::Input => "input fields must come after parameters", + FieldKind::Output => "output fields must come before metadata", + FieldKind::Metadata => "the metadata field must come last", + FieldKind::Skip => unreachable!(), + }; + errors.push(Error::new(f.span, msg)); + } + last_kind = f.kind; } - last_kind = f.kind; // Put in correct vec match f.kind { @@ -220,6 +232,7 @@ impl BlockDeriver { } metadata = Some(f); } + FieldKind::Skip => skipped.push(f), } } @@ -240,10 +253,11 @@ impl BlockDeriver { Ok(Self { struct_ident, generics, - new_fn_path, + attributes, parameters, inputs, outputs, + skipped, metadata, errors, }) @@ -265,7 +279,7 @@ impl BlockDeriver { let struct_ident = &self.struct_ident; let generics = &self.generics; - let new_fn_path = &self.new_fn_path; + let new_fn_path = &self.attributes.new_fn_path; let construct_fn = quote! { #[cfg(not(target_arch = "wasm32"))] @@ -348,6 +362,20 @@ impl BlockDeriver { }; let metadata = [self.metadata.clone()]; + let render_impl = if let Some(render_path) = self.attributes.custom_render { + quote! { + #render_path(self, ui) + } + } else { + quote! {} + }; + + let render_fn = quote! { + fn render(&mut self, ui: &mut egui::Ui) { + #render_impl + } + }; + let default_impl = { let inits = self .parameters @@ -355,6 +383,7 @@ impl BlockDeriver { .chain(self.inputs.iter()) .chain(self.outputs.iter()) .chain(metadata.iter()) + .chain(self.skipped.iter()) .map(|field| { let field_name = &field.ident; let expr = &field.default_expr; @@ -384,6 +413,7 @@ impl BlockDeriver { #parameters_fn #parameters_mut_fn #metadata_fn + #render_fn } }; @@ -400,6 +430,7 @@ enum FieldKind { Input = 1, Output = 2, Metadata = 3, + Skip, } /// Info about one named field: param, input, output, metadata, plus the default expression. @@ -413,7 +444,7 @@ struct FieldInfo { } impl FieldInfo { - fn parse(field: &syn::Field) -> Result { + fn parse(field: &syn::Field) -> Result> { let span = field.span(); let ident = match &field.ident { Some(id) => id.clone(), @@ -421,9 +452,30 @@ impl FieldInfo { return Err(Error::new(span, "expected a named field")); } }; + + // Must have exactly one `#[block(...)]` attribute per field + let block_attrs: Vec<_> = field + .attrs + .clone() + .into_iter() + .filter(|a| a.path().is_ident("block")) + .collect(); + + let mut skip = false; + for attr in block_attrs.clone() { + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("skip") { + skip = true; + } + Ok(()) + }); + } + let ty = &field.ty; - let kind = if is_param_type(ty) { + let kind = if skip { + Some(FieldKind::Skip) + } else if is_param_type(ty) { Some(FieldKind::Param) } else if is_block_pin(ty) { None @@ -436,20 +488,12 @@ impl FieldInfo { )); }; - // Must have exactly one `#[block(...)]` attribute per field - let block_attrs: Vec<_> = field - .attrs - .iter() - .filter(|a| a.path().is_ident("block")) - .collect(); - - if block_attrs.len() != 1 { + let Some(block_attr) = block_attrs.get(0) else { return Err(Error::new( span, "each field must have exactly one `#[block(...)]` attribute", )); - } - let block_attr = block_attrs[0]; + }; // We'll parse out the relevant keys: // - default = "some_expr" @@ -463,7 +507,9 @@ impl FieldInfo { block_attr.parse_nested_meta(|meta| { let path = meta.path.clone(); - if path.is_ident("default") { + if kind == Some(FieldKind::Skip) { + // Nop + } else if path.is_ident("default") { let value = meta.value()?; let lit = value.parse::()?; match lit { @@ -555,16 +601,17 @@ impl FieldInfo { }; (kind, expr) } + FieldKind::Skip => (kind, syn::parse_quote!(::std::default::Default::default())), _ => unreachable!(), }, }; - Ok(FieldInfo { + Ok(Some(FieldInfo { ident, span, default_expr, kind, - }) + })) } } @@ -573,49 +620,62 @@ impl FieldInfo { // ------------------------------------------------------------------------ /// Parse the struct-level attribute `#[block(new = "some_fn_path")]`. -fn parse_block_new_fn(attrs: &[Attribute]) -> Result { - let mut block_attrs = attrs.iter().filter(|a| a.path().is_ident("block")); - let block_attr = match block_attrs.next() { - Some(a) => a, +fn parse_block_attributes(attrs: &[Attribute]) -> Result { + let mut construct_fn_path: Option = None; + let mut custom_render: Option = None; + + let block_attrs = attrs.iter().filter(|a| a.path().is_ident("block")); + + for attr in block_attrs { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("new") { + let val = meta.value()?; + let s = val.parse::()?; + let p = syn::parse_str::(&s.value()) + .map_err(|_| meta.error("Invalid path for `#[block(new = \"...\")]`"))?; + if construct_fn_path.is_some() { + return Err(Error::new_spanned( + attr, + r#"Multiple `#[block(new = "...")]` are not allowed"#, + )); + } + construct_fn_path = Some(p); + Ok(()) + } else if meta.path.is_ident("custom_render") { + let val = meta.value()?; + let s = val.parse::()?; + let p = syn::parse_str::(&s.value()) + .map_err(|_| meta.error("Invalid path for `#[block(new = \"...\")]`"))?; + + if custom_render.is_some() { + return Err(Error::new_spanned( + attr, + r#"Multiple `#[block(custom_render = "...")]` are not allowed"#, + )); + } + custom_render = Some(p); + + Ok(()) + } else { + return Err(Error::new_spanned(attr, "Unknown attribute")); + } + })?; + } + + let new_fn_path = match construct_fn_path { + Some(p) => p, None => { return Err(Error::new_spanned( - quote! { ... }, - "Missing required #[block(new = \"...\")] attribute on the struct", + attrs[0].clone(), + r#"Missing `#[block(new = "my_constructor")]` on the struct"#, )); } }; - if block_attrs.next().is_some() { - return Err(Error::new_spanned( - block_attr, - "Only one #[block(...)] attribute allowed on the struct", - )); - } - - let mut construct_fn_path: Option = None; - - block_attr.parse_nested_meta(|meta| { - if meta.path.is_ident("new") { - let val = meta.value()?; - let s = val.parse::()?; - let p = syn::parse_str::(&s.value()) - .map_err(|_| meta.error("Invalid path for `#[block(new = \"...\")]`"))?; - construct_fn_path = Some(p); - Ok(()) - } else { - // ignore or consume - meta.input.parse::()?; - Ok(()) - } - })?; - - match construct_fn_path { - Some(p) => Ok(p), - None => Err(Error::new_spanned( - block_attr, - r#"Expected `#[block(new = "my_constructor")]` on the struct"#, - )), - } + Ok(BlockAttributes { + new_fn_path, + custom_render, + }) } /// True if `ty` is recognized as param (String, FloatValue, F64Picker, UsizeValue, BoolValue). diff --git a/studio/src/flowgraph_viewer.rs b/studio/src/flowgraph_viewer.rs index d56093df..80ea726a 100644 --- a/studio/src/flowgraph_viewer.rs +++ b/studio/src/flowgraph_viewer.rs @@ -197,6 +197,8 @@ impl SnarlViewer for FlowgraphViewer { /// Draws this block to the studio UI. fn render_block(block: &mut Block, ui: &mut egui::Ui) { ui.vertical(|ui| { + block.render(ui); + for (name, mut value) in block.parameters_mut() { let mut name = String::from(name).replace("_", " "); name[0..1].make_ascii_uppercase(); From 8eab605fe63a639ae58f3ae9d7b6f35786f371d1 Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Sun, 22 Jun 2025 10:50:34 -0700 Subject: [PATCH 07/15] fix logspam and device jumping --- Cargo.lock | 52 ++---------- blocks/Cargo.toml | 2 +- blocks/src/blocks/audio.rs | 105 ++++++++++++++++------- dsp-core/src/logging.rs | 9 ++ flake.nix | 1 + macros/src/lib.rs | 147 ++++++++++++++++++--------------- studio/src/flowgraph_viewer.rs | 9 +- 7 files changed, 179 insertions(+), 146 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9b0484f4..8e80200c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,7 +351,7 @@ version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ - "libloading 0.8.8", + "libloading", ] [[package]] @@ -994,7 +994,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading 0.8.8", + "libloading", ] [[package]] @@ -1283,7 +1283,6 @@ dependencies = [ "alsa", "coreaudio-rs 0.13.0", "dasp_sample", - "jack", "jni", "js-sys", "libc", @@ -1546,7 +1545,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ - "libloading 0.8.8", + "libloading", ] [[package]] @@ -2509,7 +2508,7 @@ dependencies = [ "glutin_egl_sys", "glutin_glx_sys", "glutin_wgl_sys", - "libloading 0.8.8", + "libloading", "objc2 0.6.1", "objc2-app-kit 0.3.1", "objc2-core-foundation", @@ -3030,33 +3029,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" -[[package]] -name = "jack" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f70ca699f44c04a32d419fc9ed699aaea89657fc09014bf3fa238e91d13041b9" -dependencies = [ - "bitflags 2.9.1", - "jack-sys", - "lazy_static", - "libc", - "log", -] - -[[package]] -name = "jack-sys" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6013b7619b95a22b576dfb43296faa4ecbe40abbdb97dfd22ead520775fc86ab" -dependencies = [ - "bitflags 1.3.2", - "lazy_static", - "libc", - "libloading 0.7.4", - "log", - "pkg-config", -] - [[package]] name = "jni" version = "0.21.1" @@ -3123,7 +3095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", - "libloading 0.8.8", + "libloading", "pkg-config", ] @@ -3169,16 +3141,6 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - [[package]] name = "libloading" version = "0.8.8" @@ -6612,7 +6574,7 @@ dependencies = [ "js-sys", "khronos-egl", "libc", - "libloading 0.8.8", + "libloading", "log", "metal", "naga", @@ -7340,7 +7302,7 @@ dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "libloading 0.8.8", + "libloading", "once_cell", "rustix 0.38.44", "x11rb-protocol", diff --git a/blocks/Cargo.toml b/blocks/Cargo.toml index 3b058648..5b1f2d01 100644 --- a/blocks/Cargo.toml +++ b/blocks/Cargo.toml @@ -26,7 +26,7 @@ macros = { path = "../macros" } dsp-core = { path = "../dsp-core" } toml = { version = "0.8.19", features = ["preserve_order"] } tracing = "0.1.41" -cpal = { version = "0.16.0", features = ["jack"] } +cpal = { version = "0.16.0" } thingbuf = { version = "0.1.6", features = ["alloc"] } async-trait = "0.1.88" diff --git a/blocks/src/blocks/audio.rs b/blocks/src/blocks/audio.rs index 223fdcd7..e020647c 100644 --- a/blocks/src/blocks/audio.rs +++ b/blocks/src/blocks/audio.rs @@ -34,7 +34,9 @@ use tracing::{error, warn}; #[block(new = "construct_audio_source")] #[block(custom_render = "render_audio_source")] pub struct AudioSourceBlock { + #[block(default = "CpalDevicePicker::source()")] #[block(skip)] + #[serde(default = "CpalDevicePicker::source")] #[serde(skip)] audio_device: CpalDevicePicker, @@ -94,7 +96,9 @@ fn construct_audio_file_source( #[block(new = "construct_audio_sink")] #[block(custom_render = "render_audio_sink")] pub struct AudioSinkBlock { + #[block(default = "CpalDevicePicker::sink()")] #[block(skip)] + #[serde(default = "CpalDevicePicker::sink")] #[serde(skip)] audio_device: CpalDevicePicker, @@ -393,9 +397,8 @@ impl Kernel for AudioSinkFsdr { } } -#[derive(Clone, Default)] +#[derive(Clone)] pub struct CpalDevicePicker { - hosts: Vec>, host_index: usize, devices: Vec, device_index: usize, @@ -405,38 +408,70 @@ pub struct CpalDevicePicker { is_sink: bool, } +static HOSTS: std::sync::OnceLock> = std::sync::OnceLock::new(); + +fn get_hosts() -> &'static [cpal::Host] { + HOSTS.get_or_init(|| { + cpal::available_hosts() + .into_iter() + .flat_map(|h| cpal::platform::host_from_id(h).ok()) + .collect() + }) +} + const AUDIO_DEVICE_SCAN_INTERVAL: Duration = Duration::from_secs(1); impl CpalDevicePicker { + pub fn source() -> Self { + Self::new(false) + } + + pub fn sink() -> Self { + Self::new(true) + } + + pub fn new(is_sink: bool) -> Self { + Self { + host_index: 0, + devices: vec![], + device_index: 0, + sample_rates: vec![], + sample_rate_index: 0, + last_device_scan: None, + is_sink, + } + } + fn render(&mut self, ui: &mut egui::Ui) { - if self + let time_to_scan = self .last_device_scan .map(|last| last.elapsed() > AUDIO_DEVICE_SCAN_INTERVAL) - .unwrap_or(true) - { + .unwrap_or(false) + // Disable constant scan on linux since alsa devices + && !cfg!(target_os = "linux"); + + if time_to_scan || self.last_device_scan.is_none() { self.scan(); } ui.horizontal(|ui| { ui.label("Audio backend: "); - if !self.hosts.is_empty() { - let mut selected = self.hosts[self.host_index].id(); - let initial_selected = selected; + let hosts = get_hosts(); + if !hosts.is_empty() { + let mut selected = hosts[self.host_index].id(); + let last_selected = selected; egui::ComboBox::from_id_salt("AudioSource host") .selected_text(selected.name()) .show_ui(ui, |ui| { - for host in &self.hosts { + for (i, host) in hosts.iter().enumerate() { ui.selectable_value(&mut selected, host.id(), host.id().name()); + if selected == host.id() { + self.host_index = i; + } } }); - if initial_selected != selected { - self.host_index = self - .hosts - .iter() - .position(|h| h.id() == selected) - .unwrap_or(0); - + if selected != last_selected { // New host -> new devices so rescan self.last_device_scan = None; } @@ -445,7 +480,7 @@ impl CpalDevicePicker { if !self.devices.is_empty() { ui.horizontal(|ui| { - ui.label("Device: "); + ui.label(format!("Device ({}): ", self.devices.len())); let mut selected = self.devices[self.device_index].name().unwrap_or_default(); egui::ComboBox::from_id_salt("AudioSource device") @@ -505,24 +540,28 @@ impl CpalDevicePicker { fn scan(&mut self) { self.last_device_scan = Some(Instant::now()); - - // Check hosts - self.hosts = cpal::available_hosts() - .into_iter() - .flat_map(|h| cpal::platform::host_from_id(h).ok()) - .map(|h| Arc::new(h)) - .collect(); - - self.host_index = self.host_index.min(self.hosts.len().saturating_sub(1)); + tracing::info!("SCAN CALLED: {self:?}"); // Check devices - assert!(!self.is_sink); + let selected_host = &get_hosts()[self.host_index]; + self.devices = if self.is_sink { + selected_host + .output_devices() + .map(|devs| devs.collect()) + .unwrap_or_default() + } else { + selected_host + .input_devices() + .map(|devs| devs.collect()) + .unwrap_or_default() + }; - let selected_host = &self.hosts[self.host_index]; - self.devices = selected_host - .output_devices() - .map(|devs| devs.collect()) - .unwrap_or_default(); + let d: Vec<_> = self + .devices + .iter() + .map(|d| d.name().unwrap_or_else(|e| format!(""))) + .collect(); + tracing::info!("sink {} devices: {d:?}", self.is_sink); self.device_index = self.device_index.min(self.devices.len().saturating_sub(1)); let Some(device) = self.get_device() else { @@ -577,6 +616,8 @@ impl std::fmt::Debug for CpalDevicePicker { .field("host_index", &self.host_index) .field("devices_count", &self.devices.len()) .field("device_index", &self.device_index) + .field("last_device_scan", &self.last_device_scan) + .field("is_sink", &self.is_sink) .finish() } } diff --git a/dsp-core/src/logging.rs b/dsp-core/src/logging.rs index dd3ee720..010459fd 100644 --- a/dsp-core/src/logging.rs +++ b/dsp-core/src/logging.rs @@ -144,6 +144,15 @@ pub fn init_with_level(level: tracing::Level, file_output: Option<&Path>) -> Vec .with_env_var("FOXHUNTER_LOG") .from_env_lossy(); + let off_str = "jack=off"; + let off_filter = EnvFilter::builder() + .with_default_directive(tracing::Level::TRACE.into()) + .parse(off_str) + .map_err(|e| eprintln!("Invalid log directive `{off_str}`: {e:?}")) + .unwrap_or_default(); + + // let subscriber = subscriber.with(off_filter); + let subscriber = subscriber.with(format.with_filter(filter)); // ========== ANDROID LOG ========== diff --git a/flake.nix b/flake.nix index 8f3cc1cb..7cc01738 100644 --- a/flake.nix +++ b/flake.nix @@ -100,6 +100,7 @@ wayland fontconfig stdenv.cc.cc.lib + jack2 ]) ++ lib.optionals pkgs.stdenv.isDarwin [ pkgs.libiconv ]; diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 01f493e2..cf1033db 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -453,24 +453,24 @@ impl FieldInfo { } }; - // Must have exactly one `#[block(...)]` attribute per field + let mut skip = false; let block_attrs: Vec<_> = field .attrs .clone() .into_iter() .filter(|a| a.path().is_ident("block")) + .filter(|a| { + // Remove skip tag and flag if present + let _ = a.parse_nested_meta(|meta| { + if meta.path.is_ident("skip") { + skip = true; + } + Ok(()) + }); + !skip + }) .collect(); - let mut skip = false; - for attr in block_attrs.clone() { - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("skip") { - skip = true; - } - Ok(()) - }); - } - let ty = &field.ty; let kind = if skip { @@ -488,13 +488,6 @@ impl FieldInfo { )); }; - let Some(block_attr) = block_attrs.get(0) else { - return Err(Error::new( - span, - "each field must have exactly one `#[block(...)]` attribute", - )); - }; - // We'll parse out the relevant keys: // - default = "some_expr" // - input_kinds = "..., ..., ..." @@ -505,54 +498,74 @@ impl FieldInfo { let mut input_kinds_opt: Option> = None; let mut output_kinds_opt: Option> = None; - block_attr.parse_nested_meta(|meta| { - let path = meta.path.clone(); - if kind == Some(FieldKind::Skip) { - // Nop - } else if path.is_ident("default") { - let value = meta.value()?; - let lit = value.parse::()?; - match lit { - Lit::Str(s) => { - default_expr_opt = Some(syn::parse_str(&s.value())?); + if block_attrs.is_empty() { + return Err(Error::new( + span, + "each field must have a `#[block(...)]` attribute", + )); + }; + + if block_attrs.len() > 1 { + return Err(Error::new( + span, + "only one `#[block(...)]` attribute allowed", + )); + }; + + let parse_result = block_attrs.get(0).map(|b| { + b.parse_nested_meta(|meta| { + let path = meta.path.clone(); + if path.is_ident("default") { + let value = meta.value()?; + let lit = value.parse::()?; + match lit { + Lit::Str(s) => { + default_expr_opt = Some(syn::parse_str(&s.value())?); + } + _ => return Err(meta.error("`default` expects string literal")), } - _ => return Err(meta.error("`default` expects string literal")), + } else if path.is_ident("range") { + let value = meta.value()?; + + let content; + braced!(content in value); + let expr = parse_range_dsl_float(&content)?; + default_expr_opt = Some(expr); + } else if path.is_ident("range_usize") { + let value = meta.value()?; + + let content; + braced!(content in value); + let expr = parse_range_dsl_usize(&content)?; + default_expr_opt = Some(expr); + } else if path.is_ident("input_kinds") { + let value = meta.value()?; + + let content; + syn::bracketed!(content in value); + let content = content.parse_terminated(Path::parse, Token![,])?; + + input_kinds_opt = Some(content.into_iter().collect()); + } else if path.is_ident("output_kinds") { + let value = meta.value()?; + + let content; + syn::bracketed!(content in value); + let content = content.parse_terminated(Path::parse, Token![,])?; + + output_kinds_opt = Some(content.into_iter().collect()); + } else if path.is_ident("skip") { + // Nop + } else { + return Err(meta.error("expected default, input_kinds, or output_kinds")); } - } else if path.is_ident("range") { - let value = meta.value()?; - - let content; - braced!(content in value); - let expr = parse_range_dsl_float(&content)?; - default_expr_opt = Some(expr); - } else if path.is_ident("range_usize") { - let value = meta.value()?; - - let content; - braced!(content in value); - let expr = parse_range_dsl_usize(&content)?; - default_expr_opt = Some(expr); - } else if path.is_ident("input_kinds") { - let value = meta.value()?; - - let content; - syn::bracketed!(content in value); - let content = content.parse_terminated(Path::parse, Token![,])?; - - input_kinds_opt = Some(content.into_iter().collect()); - } else if path.is_ident("output_kinds") { - let value = meta.value()?; - - let content; - syn::bracketed!(content in value); - let content = content.parse_terminated(Path::parse, Token![,])?; - - output_kinds_opt = Some(content.into_iter().collect()); - } else { - return Err(meta.error("expected default, input_kinds, or output_kinds")); - } - Ok(()) - })?; + Ok(()) + }) + }); + + if let Some(Err(e)) = parse_result { + return Err(e); + } // Build final default expr: let (kind, default_expr) = match kind { @@ -601,7 +614,11 @@ impl FieldInfo { }; (kind, expr) } - FieldKind::Skip => (kind, syn::parse_quote!(::std::default::Default::default())), + FieldKind::Skip => ( + kind, + default_expr_opt + .unwrap_or_else(|| syn::parse_quote!(::std::default::Default::default())), + ), _ => unreachable!(), }, }; diff --git a/studio/src/flowgraph_viewer.rs b/studio/src/flowgraph_viewer.rs index 80ea726a..4971da53 100644 --- a/studio/src/flowgraph_viewer.rs +++ b/studio/src/flowgraph_viewer.rs @@ -76,14 +76,17 @@ impl SnarlViewer for FlowgraphViewer { fn show_body( &mut self, - node: NodeId, + node_id: NodeId, _inputs: &[InPin], _outputs: &[OutPin], ui: &mut Ui, snarl: &mut Snarl, ) { - let node = &mut snarl[node]; - render_block(node, ui); + let node = &mut snarl[node_id]; + + ui.scope_builder(egui::UiBuilder::new().id_salt(node_id), |ui| { + render_block(node, ui); + }); } fn show_input( From f5295ac412f5e809888b3ed45b5b3f40765dde57 Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Sun, 22 Jun 2025 12:28:56 -0700 Subject: [PATCH 08/15] background audio scanner --- blocks/src/block.rs | 4 + blocks/src/blocks/audio.rs | 347 ++++++++++++++++++++++--------------- dsp-core/src/logging.rs | 9 - studio/docs/changelog.md | 5 + 4 files changed, 214 insertions(+), 151 deletions(-) diff --git a/blocks/src/block.rs b/blocks/src/block.rs index d0447687..0a54572e 100644 --- a/blocks/src/block.rs +++ b/blocks/src/block.rs @@ -459,6 +459,10 @@ mod tests { // TODO(troy): Add sample audio file continue; } + if kind == BlockKind::AudioSource || kind == BlockKind::AudioSink { + // Missing in CI + continue; + } println!("Constructing {kind:?}"); println!("Constructing {block:?}"); let _ = block diff --git a/blocks/src/blocks/audio.rs b/blocks/src/blocks/audio.rs index e020647c..85262e78 100644 --- a/blocks/src/blocks/audio.rs +++ b/blocks/src/blocks/audio.rs @@ -1,9 +1,4 @@ use std::sync::Arc; -use std::time::{Duration, Instant}; - -// TODO: -// 1. Graceful shutdown (threads still running?) -// 2. libjack support (nix) use crate::prelude::*; use crate::{BlockMetadata, BlockPin, ParameterValueMut, ParameterValueRef}; @@ -62,7 +57,7 @@ fn construct_audio_source( .get_sample_rate() .ok_or_else(|| anyhow!("No audio sample rate selected"))?; - Ok(FutureSdrBlock::from_typed(AudioSourceFsdr::new(device, sample_rate, 1)?).into()) + Ok(FutureSdrBlock::from_typed(AudioSourceFsdr::new(&device.inner, sample_rate, 1)?).into()) } fn render_audio_source(block: &mut AudioSourceBlock, ui: &mut egui::Ui) { @@ -124,7 +119,7 @@ fn construct_audio_sink( .get_sample_rate() .ok_or_else(|| anyhow!("No audio sample rate selected"))?; - Ok(FutureSdrBlock::from_typed(AudioSinkFsdr::new(device, sample_rate, 1)?).into()) + Ok(FutureSdrBlock::from_typed(AudioSinkFsdr::new(&device.inner, sample_rate, 1)?).into()) } fn render_audio_sink(block: &mut AudioSinkBlock, ui: &mut egui::Ui) { @@ -397,15 +392,19 @@ impl Kernel for AudioSinkFsdr { } } -#[derive(Clone)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum AudioDeviceKind { + Source, + Sink, +} + +#[derive(Clone, Debug)] pub struct CpalDevicePicker { host_index: usize, - devices: Vec, + devices: Vec, device_index: usize, - sample_rates: Vec, sample_rate_index: usize, - last_device_scan: Option, - is_sink: bool, + kind: AudioDeviceKind, } static HOSTS: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -419,47 +418,188 @@ fn get_hosts() -> &'static [cpal::Host] { }) } -const AUDIO_DEVICE_SCAN_INTERVAL: Duration = Duration::from_secs(1); +/// Wrapper around `cpal::Device` which comes from the background device scanner +#[derive(Clone)] +pub struct AudioDevice { + inner: Arc, + name: String, + host: cpal::HostId, + kind: AudioDeviceKind, + sample_rates: Vec, +} + +impl std::fmt::Debug for AudioDevice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = self + .inner + .name() + .unwrap_or_else(|e| format!("Failed to get device name: {e:?}")); + + f.debug_struct("AudioDevice") + .field("name", &name) + .field("host", &self.host) + .field("kind", &self.kind) + .field("sample_rates", &self.sample_rates) + .finish() + } +} + +fn get_devices(host: cpal::HostId, kind: AudioDeviceKind) -> Vec { + let guard = DEVICES.get_or_init(|| { + let _thread = audio_device_scanner(); + + std::sync::Mutex::new(vec![]) + }); + let all_devs = { guard.lock().unwrap().clone() }; + + all_devs + .into_iter() + .filter(|d| d.host == host && kind == d.kind) + .collect() +} + +static DEVICES: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +#[cfg(not(target_os = "linux"))] +const AUDIO_DEVICE_SCAN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); + +fn audio_device_scanner() -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + tracing::info!("Starting audio device scanner"); + loop { + let mut new_devs = vec![]; + //for host in get_hosts() { + for host_id in cpal::available_hosts() { + let Ok(host) = cpal::host_from_id(host_id) else { + continue; + }; + + let Ok(devices) = host.devices().map_err(|e| { + warn!( + "Failed to scan for devices on audio host {:?}: {e:?}", + host.id() + ) + }) else { + continue; + }; + + for (i, device) in devices.into_iter().enumerate() { + let Ok(name) = device.name() else { + continue; + }; + tracing::info!("{i}: {name}"); + + let device = Arc::new(device); + let inputs: Vec<_> = device + .supported_input_configs() + .into_iter() + .map(|c| c.collect::>()) + .flatten() + .collect(); + + if !inputs.is_empty() { + new_devs.push(AudioDevice { + inner: Arc::clone(&device), + host: host.id(), + kind: AudioDeviceKind::Source, + sample_rates: get_sample_rates(&inputs), + name: name.clone(), + }); + } + + let outputs: Vec<_> = device + .supported_output_configs() + .into_iter() + .map(|c| c.collect::>()) + .flatten() + .collect(); + + if !outputs.is_empty() { + new_devs.push(AudioDevice { + inner: Arc::clone(&device), + host: host.id(), + kind: AudioDeviceKind::Sink, + sample_rates: get_sample_rates(&outputs), + name, + }); + } + + fn get_sample_rates(config: &[cpal::SupportedStreamConfigRange]) -> Vec { + const STANDARD_RATES: [u32; 4] = [24000, 44100, 48000, 96000]; + + let mut rates = Vec::new(); + for c in config { + let min = c.min_sample_rate().0; + let max = c.max_sample_rate().0; + if min >= 10000 { + rates.push(min); + } + if max >= 10000 { + rates.push(max); + } + + rates + .extend(STANDARD_RATES.iter().filter(|x| *x >= &min && *x <= &max)); + } + rates.sort(); + rates.dedup(); + + rates + } + } + } + + { + if let Some(guard) = DEVICES.get() { + tracing::info!("Updating devices: {new_devs:?}"); + *guard.lock().unwrap() = new_devs; + } + } + + #[cfg(target_os = "linux")] + { + // ALSA will omit devices from the scan if there are outstanding handles to its devices. + // Were in this situation because: + // 1. cpal doesnt make the distinction from opening a device and browsing its capabilities + // 2. The main thread needs to have references to available devices so we can + // create streams + // The combination of these two mean that its easier to not continuously scan on linux. + tracing::info!("Device scanner exiting"); + break; + } + + #[cfg(not(target_os = "linux"))] + std::thread::sleep(AUDIO_DEVICE_SCAN_INTERVAL); + } + }) +} impl CpalDevicePicker { pub fn source() -> Self { - Self::new(false) + Self::new(AudioDeviceKind::Source) } pub fn sink() -> Self { - Self::new(true) + Self::new(AudioDeviceKind::Sink) } - pub fn new(is_sink: bool) -> Self { + pub fn new(kind: AudioDeviceKind) -> Self { Self { host_index: 0, devices: vec![], device_index: 0, - sample_rates: vec![], sample_rate_index: 0, - last_device_scan: None, - is_sink, + kind, } } fn render(&mut self, ui: &mut egui::Ui) { - let time_to_scan = self - .last_device_scan - .map(|last| last.elapsed() > AUDIO_DEVICE_SCAN_INTERVAL) - .unwrap_or(false) - // Disable constant scan on linux since alsa devices - && !cfg!(target_os = "linux"); - - if time_to_scan || self.last_device_scan.is_none() { - self.scan(); - } - ui.horizontal(|ui| { ui.label("Audio backend: "); let hosts = get_hosts(); if !hosts.is_empty() { let mut selected = hosts[self.host_index].id(); - let last_selected = selected; egui::ComboBox::from_id_salt("AudioSource host") .selected_text(selected.name()) .show_ui(ui, |ui| { @@ -470,26 +610,24 @@ impl CpalDevicePicker { } } }); - - if selected != last_selected { - // New host -> new devices so rescan - self.last_device_scan = None; - } } }); + let host = get_hosts()[self.host_index].id(); + self.devices = get_devices(host, self.kind); + self.device_index = self.device_index.min(self.devices.len().saturating_sub(1)); + if !self.devices.is_empty() { ui.horizontal(|ui| { ui.label(format!("Device ({}): ", self.devices.len())); - let mut selected = self.devices[self.device_index].name().unwrap_or_default(); + let mut selected = self.devices[self.device_index].name.clone(); egui::ComboBox::from_id_salt("AudioSource device") .selected_text(&selected) .show_ui(ui, |ui| { for (i, dev) in self.devices.iter().enumerate() { - let name = dev.name().unwrap(); - ui.selectable_value(&mut selected, name.clone(), name.clone()); - if selected == name { + ui.selectable_value(&mut selected, dev.name.clone(), dev.name.clone()); + if selected == dev.name { self.device_index = i; } } @@ -497,26 +635,33 @@ impl CpalDevicePicker { }); } - if !self.sample_rates.is_empty() { - ui.horizontal(|ui| { - ui.label("Sample rate: "); - let mut selected = self.sample_rates[self.sample_rate_index]; - - egui::ComboBox::from_id_salt("AudioSource sample rate") - .selected_text(selected.to_string()) - .show_ui(ui, |ui| { - for (i, rate) in self.sample_rates.iter().enumerate() { - ui.selectable_value(&mut selected, *rate, rate.to_string()); - if selected == *rate { - self.sample_rate_index = i; + let mut sample_rate_index = self.sample_rate_index; + if let Some(device) = self.get_device() { + if !device.sample_rates.is_empty() { + ui.horizontal(|ui| { + ui.label("Sample rate: "); + let mut selected = device.sample_rates[sample_rate_index]; + + egui::ComboBox::from_id_salt("AudioSource sample rate") + .selected_text(selected.to_string()) + .show_ui(ui, |ui| { + for (i, rate) in device.sample_rates.iter().enumerate() { + ui.selectable_value(&mut selected, *rate, rate.to_string()); + if selected == *rate { + sample_rate_index = i; + } } - } - }); - }); + }); + + sample_rate_index = + sample_rate_index.min(device.sample_rates.len().saturating_sub(1)); + }); + } } + self.sample_rate_index = sample_rate_index; } - pub fn get_device(&self) -> Option<&cpal::Device> { + pub fn get_device(&self) -> Option<&AudioDevice> { if self.devices.is_empty() { assert_eq!(self.device_index, 0); None @@ -526,99 +671,17 @@ impl CpalDevicePicker { } pub fn supported_samples_rates(&self) -> &[u32] { - &self.sample_rates + self.get_device().map_or(&[], |d| &d.sample_rates) } pub fn get_sample_rate(&self) -> Option { - if self.sample_rates.is_empty() { + let rates = self.supported_samples_rates(); + if rates.is_empty() { assert_eq!(self.sample_rate_index, 0); None } else { - Some(self.sample_rates[self.sample_rate_index]) - } - } - - fn scan(&mut self) { - self.last_device_scan = Some(Instant::now()); - tracing::info!("SCAN CALLED: {self:?}"); - - // Check devices - let selected_host = &get_hosts()[self.host_index]; - self.devices = if self.is_sink { - selected_host - .output_devices() - .map(|devs| devs.collect()) - .unwrap_or_default() - } else { - selected_host - .input_devices() - .map(|devs| devs.collect()) - .unwrap_or_default() - }; - - let d: Vec<_> = self - .devices - .iter() - .map(|d| d.name().unwrap_or_else(|e| format!(""))) - .collect(); - tracing::info!("sink {} devices: {d:?}", self.is_sink); - - self.device_index = self.device_index.min(self.devices.len().saturating_sub(1)); - let Some(device) = self.get_device() else { - return; - }; - - // Sample rates (min, max) - let configs: Vec<_> = if self.is_sink { - device - .supported_output_configs() - .into_iter() - .map(|c| c.collect::>()) - .flatten() - .collect() - } else { - device - .supported_input_configs() - .into_iter() - .map(|c| c.collect::>()) - .flatten() - .collect() - }; - - const STANDARD_RATES: [u32; 4] = [24000, 44100, 48000, 96000]; - - let mut rates = Vec::new(); - for c in configs { - let min = c.min_sample_rate().0; - let max = c.max_sample_rate().0; - if min >= 10000 { - rates.push(min); - } - if max >= 10000 { - rates.push(max); - } - - rates.extend(STANDARD_RATES.iter().filter(|x| *x >= &min && *x <= &max)); + Some(rates[self.sample_rate_index]) } - rates.sort(); - rates.dedup(); - - self.sample_rates = rates; - self.sample_rate_index = self - .sample_rate_index - .min(self.sample_rates.len().saturating_sub(1)); - } -} - -impl std::fmt::Debug for CpalDevicePicker { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CpalDevicePicker") - .field("host_index", &self.host_index) - .field("devices_count", &self.devices.len()) - .field("device_index", &self.device_index) - .field("last_device_scan", &self.last_device_scan) - .field("is_sink", &self.is_sink) - .finish() } } diff --git a/dsp-core/src/logging.rs b/dsp-core/src/logging.rs index 010459fd..dd3ee720 100644 --- a/dsp-core/src/logging.rs +++ b/dsp-core/src/logging.rs @@ -144,15 +144,6 @@ pub fn init_with_level(level: tracing::Level, file_output: Option<&Path>) -> Vec .with_env_var("FOXHUNTER_LOG") .from_env_lossy(); - let off_str = "jack=off"; - let off_filter = EnvFilter::builder() - .with_default_directive(tracing::Level::TRACE.into()) - .parse(off_str) - .map_err(|e| eprintln!("Invalid log directive `{off_str}`: {e:?}")) - .unwrap_or_default(); - - // let subscriber = subscriber.with(off_filter); - let subscriber = subscriber.with(format.with_filter(filter)); // ========== ANDROID LOG ========== diff --git a/studio/docs/changelog.md b/studio/docs/changelog.md index 2068e09d..53ca1a27 100644 --- a/studio/docs/changelog.md +++ b/studio/docs/changelog.md @@ -1,5 +1,10 @@ Changelog for Foxhunter DSP Studio. +# unreleased + +## Limitations +- Audio devices will not refresh on linux (you will have to restart the application to use a newly plugged in device) + # v0.0.1 - Dev start From c2cb0c9cf245687c637d5fff86633ddc9ab4b388 Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Sun, 22 Jun 2025 14:06:59 -0700 Subject: [PATCH 09/15] wip --- blocks/src/blocks/audio.rs | 195 ++++++++++++++++++++++++++------ runner/src/flowgraph_builder.rs | 10 +- 2 files changed, 164 insertions(+), 41 deletions(-) diff --git a/blocks/src/blocks/audio.rs b/blocks/src/blocks/audio.rs index 85262e78..33c819c7 100644 --- a/blocks/src/blocks/audio.rs +++ b/blocks/src/blocks/audio.rs @@ -57,7 +57,13 @@ fn construct_audio_source( .get_sample_rate() .ok_or_else(|| anyhow!("No audio sample rate selected"))?; - Ok(FutureSdrBlock::from_typed(AudioSourceFsdr::new(&device.inner, sample_rate, 1)?).into()) + Ok(FutureSdrBlock::from_typed(AudioSourceFsdr::new( + &device.inner, + sample_rate, + // TODO: make channels user-configurable + device.channels, + )?) + .into()) } fn render_audio_source(block: &mut AudioSourceBlock, ui: &mut egui::Ui) { @@ -119,7 +125,12 @@ fn construct_audio_sink( .get_sample_rate() .ok_or_else(|| anyhow!("No audio sample rate selected"))?; - Ok(FutureSdrBlock::from_typed(AudioSinkFsdr::new(&device.inner, sample_rate, 1)?).into()) + Ok(FutureSdrBlock::from_typed(AudioSinkFsdr::new( + &device.inner, + sample_rate, + device.channels, + )?) + .into()) } fn render_audio_sink(block: &mut AudioSinkBlock, ui: &mut egui::Ui) { @@ -133,6 +144,8 @@ pub struct AudioSourceFsdr { rx: thingbuf::mpsc::Receiver>, err: Arc>, buf: ByteBuf, + de_interleave: Vec, + channels: u16, } // cpal::Stream is !Send for API reasons not for safety reasons @@ -149,8 +162,13 @@ impl AudioSourceFsdr { device: &cpal::platform::Device, sample_rate: u32, channels: u16, - ) -> Result, cpal::BuildStreamError> { - let (tx, rx) = thingbuf::mpsc::channel::>(16); + ) -> anyhow::Result> { + ensure!( + channels == 1 || channels == 2, + "Unsupported channel count {channels}" + ); + + let (tx, rx) = thingbuf::mpsc::channel::>(32); let config = StreamConfig { channels, @@ -160,15 +178,27 @@ impl AudioSourceFsdr { let err = Arc::new(AtomicSlot::empty()); let err2 = Arc::clone(&err); + let err3 = Arc::clone(&err); + let mut send_err_count = 0; let stream = device.build_input_stream( &config, move |data: &[f32], _| match tx.try_send_ref() { Ok(mut slot) => { + send_err_count = 0; slot.clear(); slot.extend_from_slice(data); } Err(_) => { + send_err_count += 1; + if send_err_count >= 10 { + err3.store(Some(Box::new(cpal::StreamError::BackendSpecific { + err: cpal::BackendSpecificError { + description: "Buffer overflow sending source data to flowgraph" + .to_string(), + }, + }))); + } tracing::warn!("Failed to send {} samples to flowgraph", data.len()); } }, @@ -187,12 +217,23 @@ impl AudioSourceFsdr { stream, rx, err, - buf: ByteBuf::new(32 * 1024), + buf: ByteBuf::new(64 * 1024), + de_interleave: vec![], + channels, }, )) } } +impl Drop for AudioSourceFsdr { + fn drop(&mut self) { + tracing::info!("Dropping AudioSource"); + if let Err(e) = self.stream.pause() { + warn!("Failed to pause AudioSource: {e:?}"); + } + } +} + #[doc(hidden)] #[async_trait] impl Kernel for AudioSourceFsdr { @@ -215,6 +256,7 @@ impl Kernel for AudioSourceFsdr { ) -> Result<()> { if self.err.is_some() { if let Some(err) = self.err.take() { + warn!("AudioSource detected error in callback. Stopping block: {err:?}"); io.finished = true; return Err(err.into()); } @@ -229,8 +271,24 @@ impl Kernel for AudioSourceFsdr { } match self.rx.recv_ref().await { - Some(input) => { + Some(raw_input) => { let output = sio.output(0).slice::(); + + let input = if self.channels == 1 { + &raw_input + } else if self.channels == 2 { + self.de_interleave.resize(raw_input.len() / 2, 0.0); + + for i in 0..raw_input.len() / 2 { + let a = raw_input[2 * i + 0]; + let b = raw_input[2 * i + 1]; + self.de_interleave[i] = f32::sqrt(a * a + b * b); + } + &self.de_interleave + } else { + unreachable!("Checked before that channels was 1 or 2"); + }; + let len = std::cmp::min(input.len(), output.len()); let (to_write, rest) = input.as_slice().split_at(len); @@ -259,6 +317,7 @@ pub struct AudioSinkFsdr { stream: Stream, tx: thingbuf::mpsc::Sender>, err: Arc>, + channels: u16, } // cpal::Stream is !Send for API reasons not for safety reasons @@ -275,8 +334,13 @@ impl AudioSinkFsdr { device: &cpal::platform::Device, sample_rate: u32, channels: u16, - ) -> Result, cpal::BuildStreamError> { - let (tx, rx) = thingbuf::mpsc::channel::>(16); + ) -> anyhow::Result> { + ensure!( + channels == 1 || channels == 2, + "Unsupported channel count {channels}" + ); + + let (tx, rx) = thingbuf::mpsc::channel::>(32); let config = StreamConfig { channels, @@ -288,7 +352,7 @@ impl AudioSinkFsdr { let err2 = Arc::clone(&err); let err3 = Arc::clone(&err); - let mut buf = ByteBuf::::new(16 * 1024); + let mut buf = ByteBuf::::new(64 * 1024); let stream = device.build_output_stream( &config, // Loop since we only return once we have filled `data` completely @@ -308,7 +372,7 @@ impl AudioSinkFsdr { } else { // Buffer and handle later if buf.append(&input).is_err() { - warn!("Buffer overflow sending data to cpal stream"); + warn!("Buffer overflow sending data to cpal output"); } } } @@ -337,7 +401,12 @@ impl AudioSinkFsdr { BlockMetaBuilder::new("AudioSink").build(), StreamIoBuilder::new().add_input::("in").build(), MessageIoBuilder::new().build(), - AudioSinkFsdr { stream, tx, err }, + AudioSinkFsdr { + stream, + tx, + err, + channels, + }, )) } } @@ -376,11 +445,25 @@ impl Kernel for AudioSinkFsdr { Ok(mut dst) => { let input = sio.input(0).slice::(); - if input.len() > dst.len() { - dst.resize(input.len(), 0.0); + if self.channels == 1 { + if input.len() > dst.len() { + dst.resize(input.len(), 0.0); + } + + (&mut dst[..input.len()]).copy_from_slice(&input); + } else if self.channels == 2 { + // Fan out to stereo audio + if input.len() * 2 > dst.len() { + dst.resize(input.len() * 2, 0.0); + } + for i in 0..input.len() { + dst[2 * i + 0] = input[i]; + dst[2 * i + 1] = input[i]; + } + } else { + unreachable!("Checked before that channels was 1 or 2"); } - (&mut dst[..input.len()]).copy_from_slice(&input); sio.input(0).consume(input.len()); } Err(_) => { @@ -392,6 +475,15 @@ impl Kernel for AudioSinkFsdr { } } +impl Drop for AudioSinkFsdr { + fn drop(&mut self) { + tracing::debug!("Dropping AudioSink"); + if let Err(e) = self.stream.pause() { + warn!("Failed to pause AudioSink: {e:?}"); + } + } +} + #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum AudioDeviceKind { Source, @@ -426,6 +518,7 @@ pub struct AudioDevice { host: cpal::HostId, kind: AudioDeviceKind, sample_rates: Vec, + channels: u16, } impl std::fmt::Debug for AudioDevice { @@ -440,6 +533,7 @@ impl std::fmt::Debug for AudioDevice { .field("host", &self.host) .field("kind", &self.kind) .field("sample_rates", &self.sample_rates) + .field("channels", &self.channels) .finish() } } @@ -488,22 +582,28 @@ fn audio_device_scanner() -> std::thread::JoinHandle<()> { let Ok(name) = device.name() else { continue; }; - tracing::info!("{i}: {name}"); + + fn config_filter(config: &cpal::SupportedStreamConfigRange) -> bool { + // We dont support automatic sample conversions, only look for f32 + config.sample_format() == cpal::SampleFormat::F32 + } let device = Arc::new(device); let inputs: Vec<_> = device .supported_input_configs() .into_iter() - .map(|c| c.collect::>()) + .map(|c| c.filter(config_filter).collect::>()) .flatten() .collect(); if !inputs.is_empty() { + let (sample_rates, channels) = get_sample_rates(&inputs); new_devs.push(AudioDevice { inner: Arc::clone(&device), host: host.id(), kind: AudioDeviceKind::Source, - sample_rates: get_sample_rates(&inputs), + sample_rates, + channels, name: name.clone(), }); } @@ -511,41 +611,68 @@ fn audio_device_scanner() -> std::thread::JoinHandle<()> { let outputs: Vec<_> = device .supported_output_configs() .into_iter() - .map(|c| c.collect::>()) + .map(|c| c.filter(config_filter).collect::>()) .flatten() .collect(); if !outputs.is_empty() { + let (sample_rates, channels) = get_sample_rates(&outputs); new_devs.push(AudioDevice { inner: Arc::clone(&device), host: host.id(), kind: AudioDeviceKind::Sink, - sample_rates: get_sample_rates(&outputs), + sample_rates, + channels, name, }); } - fn get_sample_rates(config: &[cpal::SupportedStreamConfigRange]) -> Vec { - const STANDARD_RATES: [u32; 4] = [24000, 44100, 48000, 96000]; + fn get_sample_rates( + configs: &[cpal::SupportedStreamConfigRange], + ) -> (Vec, u16) { + fn sample_rates_for_channel_count( + configs: &[cpal::SupportedStreamConfigRange], + channels: u16, + ) -> Vec { + const STANDARD_RATES: [u32; 4] = [24000, 44100, 48000, 96000]; + + let mut rates = Vec::new(); + for c in configs { + if c.channels() != channels { + continue; + } - let mut rates = Vec::new(); - for c in config { - let min = c.min_sample_rate().0; - let max = c.max_sample_rate().0; - if min >= 10000 { - rates.push(min); - } - if max >= 10000 { - rates.push(max); + let min = c.min_sample_rate().0; + let max = c.max_sample_rate().0; + if min >= 10000 { + rates.push(min); + } + if max >= 10000 { + rates.push(max); + } + + rates.extend( + STANDARD_RATES.iter().filter(|x| *x >= &min && *x <= &max), + ); + // Hide huge sample rates (they come from buggy drivers sometimes) + rates.retain(|r| *r < 10_000_000); } + rates.sort(); + rates.dedup(); rates - .extend(STANDARD_RATES.iter().filter(|x| *x >= &min && *x <= &max)); } - rates.sort(); - rates.dedup(); - rates + let sample_rates_mono = sample_rates_for_channel_count(configs, 1); + let sample_rates_sterio = sample_rates_for_channel_count(configs, 2); + + // Return whichever has more sample rates (but perfer mono) + // if sample_rates_mono.len() >= sample_rates_sterio.len() { + // (sample_rates_mono, 1) + // } else { + // (sample_rates_sterio, 2) + // } + (sample_rates_sterio, 2) } } } diff --git a/runner/src/flowgraph_builder.rs b/runner/src/flowgraph_builder.rs index 75dc3a77..3deef540 100644 --- a/runner/src/flowgraph_builder.rs +++ b/runner/src/flowgraph_builder.rs @@ -8,18 +8,13 @@ use thiserror::Error; #[derive(Error, Debug)] pub enum BuildFlowgraphError { - #[error("could not construct block: {inner}")] + #[error("could not construct block {block_kind:?}: {inner}")] ConstructBlock { block: NodeId, + block_kind: BlockKind, name: String, inner: anyhow::Error, }, - #[error("could not connect blocks: {inner}")] - ConnectBlocks { - src: NodeId, - dst: NodeId, - inner: anyhow::Error, - }, #[error("missing node: {0:?}")] MissingNode(NodeId), #[error("Unconnected pin {block:?}.{pin_name}")] @@ -111,6 +106,7 @@ fn build_inner( let block = src_block.construct(&mut ctx.inner).map_err(|inner| { BuildFlowgraphError::ConstructBlock { block: node_id, + block_kind: src_block.kind(), name: format!("{:?}", src_block.kind()), inner, } From d2ea58b03dfad8e2bee135dcd334353db83ddce5 Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Sun, 22 Jun 2025 17:49:13 -0700 Subject: [PATCH 10/15] fix bug --- Cargo.lock | 46 ++++++++++--- blocks/src/blocks/audio.rs | 131 +++++++++++++++++++++++++++---------- blocks/src/blocks/math.rs | 4 +- blocks/src/lib.rs | 2 +- dsp-core/Cargo.toml | 3 + dsp-core/src/byte_buf.rs | 52 ++++++++++++++- 6 files changed, 190 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8e80200c..f3472c1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1587,6 +1587,7 @@ dependencies = [ "chrono", "futuresdr", "log", + "rand 0.9.1", "thingbuf", "tracing", "tracing-appender", @@ -2298,7 +2299,7 @@ dependencies = [ "num-integer", "num_cpus", "once_cell", - "rand", + "rand 0.8.5", "rodio", "rustc_version", "rustfft", @@ -4217,7 +4218,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -4447,7 +4448,7 @@ checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" dependencies = [ "env_logger 0.8.4", "log", - "rand", + "rand 0.8.5", ] [[package]] @@ -4472,8 +4473,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -4483,7 +4494,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -4495,6 +4516,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -6013,7 +6043,7 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.8.5", "sha1", "thiserror 1.0.69", "utf-8", @@ -7420,7 +7450,7 @@ dependencies = [ "hex", "nix", "ordered-stream", - "rand", + "rand 0.8.5", "serde", "serde_repr", "sha1", diff --git a/blocks/src/blocks/audio.rs b/blocks/src/blocks/audio.rs index 33c819c7..3c0ab44d 100644 --- a/blocks/src/blocks/audio.rs +++ b/blocks/src/blocks/audio.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use crate::prelude::*; use crate::{BlockMetadata, BlockPin, ParameterValueMut, ParameterValueRef}; @@ -23,7 +24,7 @@ use futuresdr::runtime::StreamIoBuilder; use futuresdr::runtime::TypedBlock; use futuresdr::runtime::WorkIo; use thingbuf::mpsc::errors::TryRecvError; -use tracing::{error, warn}; +use tracing::{error, info, warn}; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, macros::Block)] #[block(new = "construct_audio_source")] @@ -168,7 +169,7 @@ impl AudioSourceFsdr { "Unsupported channel count {channels}" ); - let (tx, rx) = thingbuf::mpsc::channel::>(32); + let (tx, rx) = thingbuf::mpsc::channel::>(8); let config = StreamConfig { channels, @@ -183,23 +184,26 @@ impl AudioSourceFsdr { let mut send_err_count = 0; let stream = device.build_input_stream( &config, - move |data: &[f32], _| match tx.try_send_ref() { - Ok(mut slot) => { - send_err_count = 0; - slot.clear(); - slot.extend_from_slice(data); - } - Err(_) => { - send_err_count += 1; - if send_err_count >= 10 { - err3.store(Some(Box::new(cpal::StreamError::BackendSpecific { - err: cpal::BackendSpecificError { - description: "Buffer overflow sending source data to flowgraph" - .to_string(), - }, - }))); + move |data: &[f32], _| { + tracing::info!("cpal AudioSource callback"); + match tx.try_send_ref() { + Ok(mut slot) => { + send_err_count = 0; + slot.clear(); + slot.extend_from_slice(data); + } + Err(_) => { + send_err_count += 1; + if send_err_count >= 10 { + err3.store(Some(Box::new(cpal::StreamError::BackendSpecific { + err: cpal::BackendSpecificError { + description: "Buffer overflow sending source data to flowgraph" + .to_string(), + }, + }))); + } + tracing::warn!("Failed to send {} samples to flowgraph", data.len()); } - tracing::warn!("Failed to send {} samples to flowgraph", data.len()); } }, move |err| { @@ -277,6 +281,12 @@ impl Kernel for AudioSourceFsdr { let input = if self.channels == 1 { &raw_input } else if self.channels == 2 { + tracing::info!( + "Got {} raw samples. First 32: {:?}", + raw_input.len(), + &raw_input[..32.min(raw_input.len())] + ); + self.de_interleave.resize(raw_input.len() / 2, 0.0); for i in 0..raw_input.len() / 2 { @@ -293,6 +303,8 @@ impl Kernel for AudioSourceFsdr { let (to_write, rest) = input.as_slice().split_at(len); (&mut output[..len]).copy_from_slice(to_write); + tracing::info!("Producing {len} samples from AudioSource to flowgraph"); + sio.output(0).produce(len); if !rest.is_empty() { @@ -317,6 +329,7 @@ pub struct AudioSinkFsdr { stream: Stream, tx: thingbuf::mpsc::Sender>, err: Arc>, + initialized: Arc, channels: u16, } @@ -340,37 +353,70 @@ impl AudioSinkFsdr { "Unsupported channel count {channels}" ); - let (tx, rx) = thingbuf::mpsc::channel::>(32); + let (tx, rx) = thingbuf::mpsc::channel::>(8); let config = StreamConfig { channels, sample_rate: SampleRate(sample_rate), - buffer_size: BufferSize::Default, + buffer_size: BufferSize::Fixed(960), }; let err = Arc::new(AtomicSlot::empty()); let err2 = Arc::clone(&err); let err3 = Arc::clone(&err); - let mut buf = ByteBuf::::new(64 * 1024); + let initialized = Arc::new(AtomicBool::new(false)); + let initialized2 = Arc::clone(&initialized); + let mut first = true; + + let mut buf = ByteBuf::::new(200 * 1024); let stream = device.build_output_stream( &config, // Loop since we only return once we have filled `data` completely move |data: &mut [f32], _| loop { + if first { + initialized2.store(true, Ordering::Release); + first = false; + } + + tracing::info!( + "cpal AudioSink callback (wants {} bytes), have {} bytes buffered", + data.len(), + buf.len() + ); + // Try to fill if !buf.is_empty() { if buf.read_exact(data).is_ok() { + info!( + "Sending {} bytes to cpal (now {} bytes buffered)", + data.len(), + buf.len() + ); break; } } match rx.try_recv_ref() { Ok(input) => { + tracing::info!( + "Cpal AudioSink callback got {} samples, {} buffers in flight", + input.len(), + rx.len() + ); + // Happy path: buffer sizes align, avoid copy into `buf` - if input.len() == data.len() && !buf.is_empty() { + if + /*input.len() == data.len() && !buf.is_empty()*/ + false { data.copy_from_slice(&input); + tracing::info!("Happy path, writing exactly {} bytes", input.len()); break; } else { // Buffer and handle later + info!( + "Appending write of {} bytes into re-size buffer", + input.len() + ); if buf.append(&input).is_err() { warn!("Buffer overflow sending data to cpal output"); } @@ -406,6 +452,7 @@ impl AudioSinkFsdr { tx, err, channels, + initialized, }, )) } @@ -431,6 +478,10 @@ impl Kernel for AudioSinkFsdr { _mio: &mut MessageIo, _meta: &mut BlockMeta, ) -> Result<()> { + if sio.input(0).finished() { + io.finished = true; + } + if self.err.is_some() { if let Some(err) = self.err.take() { if let Err(e) = self.stream.pause() { @@ -441,6 +492,14 @@ impl Kernel for AudioSinkFsdr { } } + // Drop samples until cpal callback starts running + if !self.initialized.load(Ordering::Acquire) { + let input = sio.input(0).slice::(); + sio.input(0).consume(input.len()); + return Ok(()); + } + + tracing::info!("AudioSink acquiring"); match self.tx.send_ref().await { Ok(mut dst) => { let input = sio.input(0).slice::(); @@ -578,7 +637,7 @@ fn audio_device_scanner() -> std::thread::JoinHandle<()> { continue; }; - for (i, device) in devices.into_iter().enumerate() { + for device in devices { let Ok(name) = device.name() else { continue; }; @@ -667,20 +726,23 @@ fn audio_device_scanner() -> std::thread::JoinHandle<()> { let sample_rates_sterio = sample_rates_for_channel_count(configs, 2); // Return whichever has more sample rates (but perfer mono) - // if sample_rates_mono.len() >= sample_rates_sterio.len() { - // (sample_rates_mono, 1) - // } else { - // (sample_rates_sterio, 2) - // } - (sample_rates_sterio, 2) + // TODO: make >= + if sample_rates_mono.len() > sample_rates_sterio.len() { + (sample_rates_mono, 1) + } else { + (sample_rates_sterio, 2) + } } } } { - if let Some(guard) = DEVICES.get() { - tracing::info!("Updating devices: {new_devs:?}"); - *guard.lock().unwrap() = new_devs; + if let Some(devs) = DEVICES.get() { + let mut guard = devs.lock().unwrap(); + if guard.is_empty() && !new_devs.is_empty() { + tracing::info!("Updating devices: {new_devs:?}"); + } + *guard = new_devs; } } @@ -690,8 +752,9 @@ fn audio_device_scanner() -> std::thread::JoinHandle<()> { // Were in this situation because: // 1. cpal doesnt make the distinction from opening a device and browsing its capabilities // 2. The main thread needs to have references to available devices so we can - // create streams - // The combination of these two mean that its easier to not continuously scan on linux. + // create streams at any moment + // The combination of these two mean that devices will randomly come and go, + // so better to disable scanning entierly on linux. tracing::info!("Device scanner exiting"); break; } diff --git a/blocks/src/blocks/math.rs b/blocks/src/blocks/math.rs index b6b94583..08664d34 100644 --- a/blocks/src/blocks/math.rs +++ b/blocks/src/blocks/math.rs @@ -61,7 +61,7 @@ fn construct_sine_source( #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, macros::Block)] #[block(new = "construct_cosine_source")] pub struct CosineSourceBlock { - #[block(range = { initial: 915MHz, range: 1..=20GHz })] + #[block(range = { initial: 50kHz, range: 1..=20GHz })] freq: FloatValue, #[block(range = { initial: 20kHz, range: 10kHz..=60MHz })] @@ -116,7 +116,7 @@ fn construct_cosine_source( #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, macros::Block)] #[block(new = "construct_square_wave_source")] pub struct SquareWaveSourceBlock { - #[block(range = { initial: 915MHz, range: 50MHz..=2GHz })] + #[block(range = { initial: 50kHz, range: 1..=2GHz })] freq: FloatValue, #[block(range = { initial: 20kHz, range: 10kHz..=60MHz })] diff --git a/blocks/src/lib.rs b/blocks/src/lib.rs index 4f3cdd96..bf405a43 100644 --- a/blocks/src/lib.rs +++ b/blocks/src/lib.rs @@ -39,7 +39,7 @@ pub struct ConstructContext { impl ConstructContext { pub fn new() -> Self { futuresdr::runtime::config::set("ctrlport_enable", false); - futuresdr::runtime::config::set("buffer_size", 16384); + futuresdr::runtime::config::set("buffer_size", 32768); Self { widget_tokens: vec![], diff --git a/dsp-core/Cargo.toml b/dsp-core/Cargo.toml index 19c766ff..aa2628ab 100644 --- a/dsp-core/Cargo.toml +++ b/dsp-core/Cargo.toml @@ -24,3 +24,6 @@ chrono = { version = "0.4.41", default-features = false, features = [ [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.60.2", features = ["Win32_Storage_FileSystem"] } + +[dev-dependencies] +rand = "0.9.1" diff --git a/dsp-core/src/byte_buf.rs b/dsp-core/src/byte_buf.rs index 8adbbc6e..700233f9 100644 --- a/dsp-core/src/byte_buf.rs +++ b/dsp-core/src/byte_buf.rs @@ -16,12 +16,12 @@ impl ByteBuf { /// Number of unread items remaining. pub fn len(&self) -> usize { - self.buf.len().saturating_sub(self.head) + self.buf.len() - self.head } /// True if there are no unread items. pub fn is_empty(&self) -> bool { - self.buf.len() == self.head + self.len() == 0 } /// Append a slice of new items. @@ -45,19 +45,26 @@ impl ByteBuf { if self.len() < out.len() { return Err(()); } - debug_assert_eq!(self.read(out), out.len()); + let bytes = self.read(out); + debug_assert_eq!(bytes, out.len()); Ok(()) } /// Reads up to `out.len()` items into `out`. pub fn read(&mut self, out: &mut [T]) -> usize { + tracing::info!("ByteBuf read of {} requested", out.len()); let len = std::cmp::min(self.len(), out.len()); let start = self.head; let end = start + len; out[..len].copy_from_slice(&self.buf[start..end]); self.head += len; + tracing::info!( + "len = {len}, start = {start}, self.head = {}, self.len() = {}", + self.head, + self.len() + ); // If we've consumed more than half the buffer, attempt to shift if self.head > (self.max_capacity / 2) { @@ -69,6 +76,7 @@ impl ByteBuf { /// Drop the consumed prefix by shifting the remaining items back to the start of the Vec. fn compact(&mut self) { + tracing::info!("Running compact"); if self.head == 0 { return; } @@ -161,4 +169,42 @@ mod tests { assert_eq!(out2, [103]); assert!(buf.is_empty()); } + + #[test] + fn test_randomized_append_and_read() { + use rand::{Rng, SeedableRng, rngs::StdRng}; + let mut rng = StdRng::seed_from_u64(0x1234_5678); + + const TOTAL: usize = 1000_000; + let data: Vec = (0..TOTAL).map(|_| rng.random()).collect(); + + // Buffer capacity smaller than TOTAL to force compaction + let mut buf = ByteBuf::new(1024); + let mut reconstructed = Vec::with_capacity(TOTAL); + + let mut cursor = 0; + + while cursor < TOTAL || !buf.is_empty() { + // Read a random‐sized slice out + loop { + let read_size = rng.random_range(1..=128); + let mut out = vec![0_u8; read_size]; + if buf.read_exact(&mut out).is_err() { + break; + } + reconstructed.extend(out); + } + + // Append a random‐sized chunk if there's data left + if cursor < TOTAL { + let remaining = TOTAL - cursor; + let chunk_size = rng.random_range(1..=remaining.min(512)); + assert!(buf.append(&data[cursor..cursor + chunk_size]).is_ok()); + cursor += chunk_size; + } + } + + assert_eq!(reconstructed.len(), TOTAL); + assert_eq!(reconstructed, data); + } } From c5cd3bb3f562d8c080892c176c846135c9845cfe Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Sun, 22 Jun 2025 17:53:41 -0700 Subject: [PATCH 11/15] rm debug code --- blocks/src/blocks/audio.rs | 37 ++++--------------------------------- dsp-core/src/byte_buf.rs | 7 ------- 2 files changed, 4 insertions(+), 40 deletions(-) diff --git a/blocks/src/blocks/audio.rs b/blocks/src/blocks/audio.rs index 3c0ab44d..666551a7 100644 --- a/blocks/src/blocks/audio.rs +++ b/blocks/src/blocks/audio.rs @@ -231,7 +231,7 @@ impl AudioSourceFsdr { impl Drop for AudioSourceFsdr { fn drop(&mut self) { - tracing::info!("Dropping AudioSource"); + info!("Dropping AudioSource"); if let Err(e) = self.stream.pause() { warn!("Failed to pause AudioSource: {e:?}"); } @@ -281,12 +281,6 @@ impl Kernel for AudioSourceFsdr { let input = if self.channels == 1 { &raw_input } else if self.channels == 2 { - tracing::info!( - "Got {} raw samples. First 32: {:?}", - raw_input.len(), - &raw_input[..32.min(raw_input.len())] - ); - self.de_interleave.resize(raw_input.len() / 2, 0.0); for i in 0..raw_input.len() / 2 { @@ -303,7 +297,6 @@ impl Kernel for AudioSourceFsdr { let (to_write, rest) = input.as_slice().split_at(len); (&mut output[..len]).copy_from_slice(to_write); - tracing::info!("Producing {len} samples from AudioSource to flowgraph"); sio.output(0).produce(len); @@ -379,44 +372,23 @@ impl AudioSinkFsdr { first = false; } - tracing::info!( - "cpal AudioSink callback (wants {} bytes), have {} bytes buffered", - data.len(), - buf.len() - ); // Try to fill if !buf.is_empty() { if buf.read_exact(data).is_ok() { - info!( - "Sending {} bytes to cpal (now {} bytes buffered)", - data.len(), - buf.len() - ); break; } } match rx.try_recv_ref() { Ok(input) => { - tracing::info!( - "Cpal AudioSink callback got {} samples, {} buffers in flight", - input.len(), - rx.len() - ); - // Happy path: buffer sizes align, avoid copy into `buf` if /*input.len() == data.len() && !buf.is_empty()*/ false { data.copy_from_slice(&input); - tracing::info!("Happy path, writing exactly {} bytes", input.len()); break; } else { // Buffer and handle later - info!( - "Appending write of {} bytes into re-size buffer", - input.len() - ); if buf.append(&input).is_err() { warn!("Buffer overflow sending data to cpal output"); } @@ -499,7 +471,6 @@ impl Kernel for AudioSinkFsdr { return Ok(()); } - tracing::info!("AudioSink acquiring"); match self.tx.send_ref().await { Ok(mut dst) => { let input = sio.input(0).slice::(); @@ -619,7 +590,7 @@ const AUDIO_DEVICE_SCAN_INTERVAL: std::time::Duration = std::time::Duration::fro fn audio_device_scanner() -> std::thread::JoinHandle<()> { std::thread::spawn(move || { - tracing::info!("Starting audio device scanner"); + info!("Starting audio device scanner"); loop { let mut new_devs = vec![]; //for host in get_hosts() { @@ -740,7 +711,7 @@ fn audio_device_scanner() -> std::thread::JoinHandle<()> { if let Some(devs) = DEVICES.get() { let mut guard = devs.lock().unwrap(); if guard.is_empty() && !new_devs.is_empty() { - tracing::info!("Updating devices: {new_devs:?}"); + info!("Updating devices: {new_devs:?}"); } *guard = new_devs; } @@ -755,7 +726,7 @@ fn audio_device_scanner() -> std::thread::JoinHandle<()> { // create streams at any moment // The combination of these two mean that devices will randomly come and go, // so better to disable scanning entierly on linux. - tracing::info!("Device scanner exiting"); + info!("Device scanner exiting"); break; } diff --git a/dsp-core/src/byte_buf.rs b/dsp-core/src/byte_buf.rs index 700233f9..98d861f0 100644 --- a/dsp-core/src/byte_buf.rs +++ b/dsp-core/src/byte_buf.rs @@ -53,18 +53,12 @@ impl ByteBuf { /// Reads up to `out.len()` items into `out`. pub fn read(&mut self, out: &mut [T]) -> usize { - tracing::info!("ByteBuf read of {} requested", out.len()); let len = std::cmp::min(self.len(), out.len()); let start = self.head; let end = start + len; out[..len].copy_from_slice(&self.buf[start..end]); self.head += len; - tracing::info!( - "len = {len}, start = {start}, self.head = {}, self.len() = {}", - self.head, - self.len() - ); // If we've consumed more than half the buffer, attempt to shift if self.head > (self.max_capacity / 2) { @@ -76,7 +70,6 @@ impl ByteBuf { /// Drop the consumed prefix by shifting the remaining items back to the start of the Vec. fn compact(&mut self) { - tracing::info!("Running compact"); if self.head == 0 { return; } From 2389ea85aaf5c3e645639ca2f6c1604fdb876311 Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Sun, 22 Jun 2025 18:02:39 -0700 Subject: [PATCH 12/15] cleanup --- blocks/src/blocks/audio.rs | 43 ++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/blocks/src/blocks/audio.rs b/blocks/src/blocks/audio.rs index 666551a7..bfa0c30d 100644 --- a/blocks/src/blocks/audio.rs +++ b/blocks/src/blocks/audio.rs @@ -184,30 +184,27 @@ impl AudioSourceFsdr { let mut send_err_count = 0; let stream = device.build_input_stream( &config, - move |data: &[f32], _| { - tracing::info!("cpal AudioSource callback"); - match tx.try_send_ref() { - Ok(mut slot) => { - send_err_count = 0; - slot.clear(); - slot.extend_from_slice(data); - } - Err(_) => { - send_err_count += 1; - if send_err_count >= 10 { - err3.store(Some(Box::new(cpal::StreamError::BackendSpecific { - err: cpal::BackendSpecificError { - description: "Buffer overflow sending source data to flowgraph" - .to_string(), - }, - }))); - } - tracing::warn!("Failed to send {} samples to flowgraph", data.len()); + move |data: &[f32], _| match tx.try_send_ref() { + Ok(mut slot) => { + send_err_count = 0; + slot.clear(); + slot.extend_from_slice(data); + } + Err(_) => { + send_err_count += 1; + if send_err_count >= 10 { + err3.store(Some(Box::new(cpal::StreamError::BackendSpecific { + err: cpal::BackendSpecificError { + description: "Buffer overflow sending source data to flowgraph" + .to_string(), + }, + }))); } + warn!("Failed to send {} samples to flowgraph", data.len()); } }, move |err| { - tracing::error!("cpal stream error {err:?}"); + error!("cpal stream error {err:?}"); err2.store(Some(Box::new(err))); }, None, @@ -395,12 +392,12 @@ impl AudioSinkFsdr { } } Err(TryRecvError::Empty) => { - tracing::warn!("Buffer underflow on AudioSink block"); + warn!("Buffer underflow on AudioSink block"); break; } Err(e) => { let description = format!("Failed to read samples from flowgraph: {e:?}"); - tracing::error!("{}", description); + error!("{}", description); err2.store(Some(Box::new(cpal::StreamError::BackendSpecific { err: cpal::BackendSpecificError { description }, }))); @@ -507,7 +504,7 @@ impl Kernel for AudioSinkFsdr { impl Drop for AudioSinkFsdr { fn drop(&mut self) { - tracing::debug!("Dropping AudioSink"); + info!("Dropping AudioSink"); if let Err(e) = self.stream.pause() { warn!("Failed to pause AudioSink: {e:?}"); } From 550a5a6f764c97a209eed4446fb73acdd6d369bb Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Sun, 22 Jun 2025 18:35:59 -0700 Subject: [PATCH 13/15] clippy --- blocks/src/blocks/audio.rs | 29 +++++++++++++---------------- blocks/src/gui/spectrogram.rs | 6 +++--- dsp-core/src/byte_buf.rs | 17 +++++++++++------ dsp-core/src/lib.rs | 2 +- macros/src/lib.rs | 2 +- studio/src/main.rs | 3 ++- 6 files changed, 31 insertions(+), 28 deletions(-) diff --git a/blocks/src/blocks/audio.rs b/blocks/src/blocks/audio.rs index bfa0c30d..6e238abb 100644 --- a/blocks/src/blocks/audio.rs +++ b/blocks/src/blocks/audio.rs @@ -281,7 +281,7 @@ impl Kernel for AudioSourceFsdr { self.de_interleave.resize(raw_input.len() / 2, 0.0); for i in 0..raw_input.len() / 2 { - let a = raw_input[2 * i + 0]; + let a = raw_input[2 * i]; let b = raw_input[2 * i + 1]; self.de_interleave[i] = f32::sqrt(a * a + b * b); } @@ -293,7 +293,7 @@ impl Kernel for AudioSourceFsdr { let len = std::cmp::min(input.len(), output.len()); let (to_write, rest) = input.as_slice().split_at(len); - (&mut output[..len]).copy_from_slice(to_write); + output[..len].copy_from_slice(to_write); sio.output(0).produce(len); @@ -369,19 +369,15 @@ impl AudioSinkFsdr { first = false; } - // Try to fill - if !buf.is_empty() { - if buf.read_exact(data).is_ok() { - break; - } + // Try to fill using past data, if we have enough + if !buf.is_empty() && buf.read_exact(data).is_ok() { + break; } match rx.try_recv_ref() { Ok(input) => { // Happy path: buffer sizes align, avoid copy into `buf` - if - /*input.len() == data.len() && !buf.is_empty()*/ - false { + if input.len() == data.len() && !buf.is_empty() { data.copy_from_slice(&input); break; } else { @@ -477,14 +473,14 @@ impl Kernel for AudioSinkFsdr { dst.resize(input.len(), 0.0); } - (&mut dst[..input.len()]).copy_from_slice(&input); + dst[..input.len()].copy_from_slice(input); } else if self.channels == 2 { // Fan out to stereo audio if input.len() * 2 > dst.len() { dst.resize(input.len() * 2, 0.0); } for i in 0..input.len() { - dst[2 * i + 0] = input[i]; + dst[2 * i] = input[i]; dst[2 * i + 1] = input[i]; } } else { @@ -588,6 +584,9 @@ const AUDIO_DEVICE_SCAN_INTERVAL: std::time::Duration = std::time::Duration::fro fn audio_device_scanner() -> std::thread::JoinHandle<()> { std::thread::spawn(move || { info!("Starting audio device scanner"); + + // We do actually loop (just not on linux) + #[allow(clippy::never_loop)] loop { let mut new_devs = vec![]; //for host in get_hosts() { @@ -619,8 +618,7 @@ fn audio_device_scanner() -> std::thread::JoinHandle<()> { let inputs: Vec<_> = device .supported_input_configs() .into_iter() - .map(|c| c.filter(config_filter).collect::>()) - .flatten() + .flat_map(|c| c.filter(config_filter).collect::>()) .collect(); if !inputs.is_empty() { @@ -638,8 +636,7 @@ fn audio_device_scanner() -> std::thread::JoinHandle<()> { let outputs: Vec<_> = device .supported_output_configs() .into_iter() - .map(|c| c.filter(config_filter).collect::>()) - .flatten() + .flat_map(|c| c.filter(config_filter).collect::>()) .collect(); if !outputs.is_empty() { diff --git a/blocks/src/gui/spectrogram.rs b/blocks/src/gui/spectrogram.rs index b410447f..4300459e 100644 --- a/blocks/src/gui/spectrogram.rs +++ b/blocks/src/gui/spectrogram.rs @@ -3,7 +3,7 @@ use crate::{ }; use dsp_core::futuresdr_blocks::ThingBufVecReceiver; use std::sync::{Arc, Mutex}; -use tracing::{info, warn}; +use tracing::{debug, warn}; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, macros::Block)] #[block(new = "construct_spectrogram")] @@ -112,7 +112,7 @@ impl crate::EguiWidget for SpectrogramWidget { } }; if let Some(s) = to_write { - info!("Created spectrum successfully"); + debug!("Created spectrum successfully"); *spectrum = Ok(Some(s)); } @@ -181,7 +181,7 @@ impl Spectrum { } else { "#version 330" }; - info!("Shaders using {shader_version}"); + debug!("Shaders using {shader_version}"); unsafe { let program = gl.create_program().expect("Cannot create program"); diff --git a/dsp-core/src/byte_buf.rs b/dsp-core/src/byte_buf.rs index 98d861f0..5f1e48fe 100644 --- a/dsp-core/src/byte_buf.rs +++ b/dsp-core/src/byte_buf.rs @@ -5,6 +5,12 @@ pub struct ByteBuf { max_capacity: usize, } +#[derive(Copy, Clone, Debug)] +pub struct BufferOverflowError; + +#[derive(Copy, Clone, Debug)] +pub struct BufferUnderflowError; + impl ByteBuf { pub fn new(max_capacity: usize) -> Self { ByteBuf { @@ -27,23 +33,22 @@ impl ByteBuf { /// Append a slice of new items. /// /// Returns Err(()) if the max capacity would be exceeded. - pub fn append(&mut self, chunk: &[T]) -> Result<(), ()> { + pub fn append(&mut self, chunk: &[T]) -> Result<(), BufferOverflowError> { // try to reclaim space first if self.buf.len() + chunk.len() > self.max_capacity { self.compact(); } - // if still too big, signal overflow if self.buf.len() + chunk.len() > self.max_capacity { - return Err(()); + return Err(BufferOverflowError); } self.buf.extend_from_slice(chunk); Ok(()) } /// Reads up to `out.len()` items into `out`, advance head, - pub fn read_exact(&mut self, out: &mut [T]) -> Result<(), ()> { + pub fn read_exact(&mut self, out: &mut [T]) -> Result<(), BufferUnderflowError> { if self.len() < out.len() { - return Err(()); + return Err(BufferUnderflowError); } let bytes = self.read(out); debug_assert_eq!(bytes, out.len()); @@ -168,7 +173,7 @@ mod tests { use rand::{Rng, SeedableRng, rngs::StdRng}; let mut rng = StdRng::seed_from_u64(0x1234_5678); - const TOTAL: usize = 1000_000; + const TOTAL: usize = 1_000_000; let data: Vec = (0..TOTAL).map(|_| rng.random()).collect(); // Buffer capacity smaller than TOTAL to force compaction diff --git a/dsp-core/src/lib.rs b/dsp-core/src/lib.rs index 5a38c267..ba934d71 100644 --- a/dsp-core/src/lib.rs +++ b/dsp-core/src/lib.rs @@ -1,3 +1,3 @@ +pub mod byte_buf; pub mod futuresdr_blocks; pub mod logging; -pub mod byte_buf; diff --git a/macros/src/lib.rs b/macros/src/lib.rs index cf1033db..096be9f8 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -512,7 +512,7 @@ impl FieldInfo { )); }; - let parse_result = block_attrs.get(0).map(|b| { + let parse_result = block_attrs.first().map(|b| { b.parse_nested_meta(|meta| { let path = meta.path.clone(); if path.is_ident("default") { diff --git a/studio/src/main.rs b/studio/src/main.rs index 9ffe4540..08bc59e7 100644 --- a/studio/src/main.rs +++ b/studio/src/main.rs @@ -115,7 +115,8 @@ fn main() -> eframe::Result<()> { if let Err(e) = r { error!("Failed to show startup error with native_dialog: {e:?}"); } - return Err(e.into()); + + return Err(e); } Ok(()) From 88511217650303e2962238734806f715cd82d1d6 Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Sun, 22 Jun 2025 18:36:38 -0700 Subject: [PATCH 14/15] buffer size --- blocks/src/blocks/audio.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blocks/src/blocks/audio.rs b/blocks/src/blocks/audio.rs index 6e238abb..c61f98af 100644 --- a/blocks/src/blocks/audio.rs +++ b/blocks/src/blocks/audio.rs @@ -359,7 +359,7 @@ impl AudioSinkFsdr { let initialized2 = Arc::clone(&initialized); let mut first = true; - let mut buf = ByteBuf::::new(200 * 1024); + let mut buf = ByteBuf::::new(64 * 1024); let stream = device.build_output_stream( &config, // Loop since we only return once we have filled `data` completely From 0f91b2d95bbb675e14c03286bd3de1e5bfda5214 Mon Sep 17 00:00:00 2001 From: Troy Neubauer Date: Sun, 22 Jun 2025 19:04:12 -0700 Subject: [PATCH 15/15] rm nix stuff --- flake.nix | 3 --- 1 file changed, 3 deletions(-) diff --git a/flake.nix b/flake.nix index 7cc01738..f4cfb802 100644 --- a/flake.nix +++ b/flake.nix @@ -163,9 +163,6 @@ TARGET_CC = "${pkgs.pkgsCross.mingwW64.stdenv.cc}/bin/${pkgs.pkgsCross.mingwW64.stdenv.cc.targetPrefix}cc"; - separateDebugInfo = true; - dontStrip = true; - depsBuildBuild = with pkgs; [ pkgsCross.mingwW64.stdenv.cc pkgsCross.mingwW64.windows.pthreads