diff --git a/.gitignore b/.gitignore index 6e03358..326c33d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,5 @@ hostedx*.rbf **/dist/ result + +tmp/ diff --git a/Cargo.lock b/Cargo.lock index eeefe27..f3472c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,6 +339,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" @@ -571,6 +577,15 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "atomic-slot" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "018de9a99c5df16156fbcf72db09a4a449cc14b4c91e1d0f48b922986d6ea7fd" +dependencies = [ + "loom", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -725,7 +740,7 @@ dependencies = [ "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -788,6 +803,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" @@ -806,6 +830,9 @@ name = "blocks" version = "0.0.1" dependencies = [ "anyhow", + "async-trait", + "atomic-slot", + "cpal 0.16.0", "derivative", "derive_more", "dsp-core", @@ -823,6 +850,7 @@ dependencies = [ "semver", "serde", "strum 0.27.1", + "thingbuf", "toml", "tracing", "tracy-client", @@ -921,7 +949,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -1089,7 +1117,7 @@ dependencies = [ "async-trait", "convert_case", "json5", - "nom", + "nom 7.1.3", "pathdiff", "ron", "rust-ini", @@ -1200,6 +1228,20 @@ dependencies = [ "coreaudio-sys", ] +[[package]] +name = "coreaudio-rs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +dependencies = [ + "bitflags 1.3.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + [[package]] name = "coreaudio-sys" version = "0.2.17" @@ -1217,7 +1259,7 @@ checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" dependencies = [ "alsa", "core-foundation-sys", - "coreaudio-rs", + "coreaudio-rs 0.11.3", "dasp_sample", "jni", "js-sys", @@ -1232,6 +1274,32 @@ dependencies = [ "windows 0.54.0", ] +[[package]] +name = "cpal" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" +dependencies = [ + "alsa", + "coreaudio-rs 0.13.0", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.9.0", + "ndk-context", + "num-derive", + "num-traits", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1250,6 +1318,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" @@ -1400,6 +1477,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" @@ -1437,6 +1523,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.9.1", + "block2 0.6.1", + "libc", "objc2 0.6.1", ] @@ -1496,12 +1584,16 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "chrono", "futuresdr", "log", + "rand 0.9.1", "thingbuf", "tracing", + "tracing-appender", "tracing-log", "tracing-subscriber", + "windows-sys 0.60.2", ] [[package]] @@ -1828,6 +1920,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" @@ -2010,6 +2108,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" @@ -2177,8 +2281,8 @@ dependencies = [ "concurrent-queue", "config", "core_affinity", - "cpal", - "dirs", + "cpal 0.15.3", + "dirs 5.0.1", "dyn-clone", "futuredsp 0.0.6 (git+https://github.com/MerchGuardian/FutureSDR.git?branch=main)", "futures", @@ -2195,7 +2299,7 @@ dependencies = [ "num-integer", "num_cpus", "once_cell", - "rand", + "rand 0.8.5", "rodio", "rustc_version", "rustfft", @@ -2911,6 +3015,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" @@ -3265,6 +3378,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" @@ -3347,6 +3484,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" @@ -3493,13 +3639,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.9.1", - "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]] @@ -3509,9 +3655,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ "bitflags 2.9.1", + "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]] +name = "objc2-audio-toolbox" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cbe18d879e20a4aea544f8befe38bcf52255eb63d3f23eca2842f3319e4c07" +dependencies = [ + "bitflags 2.9.1", + "libc", + "objc2 0.6.1", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", "objc2-foundation 0.3.1", ] @@ -3522,23 +3689,56 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.9.1", - "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.1", + "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", ] +[[package]] +name = "objc2-core-audio" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82" +dependencies = [ + "dispatch2", + "objc2 0.6.1", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f1cc99bb07ad2ddb6527ddf83db6a15271bb036b3eb94b801cd44fdc666ee1" +dependencies = [ + "bitflags 2.9.1", + "objc2 0.6.1", +] + [[package]] name = "objc2-core-data" version = "0.2.2" @@ -3546,11 +3746,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.9.1", - "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.1", + "objc2 0.6.1", + "objc2-foundation 0.3.1", +] + [[package]] name = "objc2-core-foundation" version = "0.3.1" @@ -3558,7 +3769,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ "bitflags 2.9.1", + "block2 0.6.1", "dispatch2", + "libc", "objc2 0.6.1", ] @@ -3569,10 +3782,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" dependencies = [ "bitflags 2.9.1", + "block2 0.6.1", "dispatch2", + "libc", "objc2 0.6.1", "objc2-core-foundation", "objc2-io-surface", + "objc2-metal 0.3.1", ] [[package]] @@ -3581,10 +3797,20 @@ 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]] @@ -3593,7 +3819,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", @@ -3612,7 +3838,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.9.1", - "block2", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -3625,6 +3851,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ "bitflags 2.9.1", + "block2 0.6.1", + "libc", "objc2 0.6.1", "objc2-core-foundation", ] @@ -3646,7 +3874,7 @@ 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 0.2.2", "objc2-foundation 0.2.2", @@ -3659,11 +3887,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.9.1", - "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.1", + "objc2 0.6.1", + "objc2-foundation 0.3.1", +] + [[package]] name = "objc2-quartz-core" version = "0.2.2" @@ -3671,10 +3910,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.9.1", - "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.1", + "objc2 0.6.1", + "objc2-foundation 0.3.1", ] [[package]] @@ -3694,15 +3944,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.9.1", - "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", @@ -3714,7 +3964,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", ] @@ -3726,7 +3976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.9.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -3968,7 +4218,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -4198,7 +4448,7 @@ checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" dependencies = [ "env_logger 0.8.4", "log", - "rand", + "rand 0.8.5", ] [[package]] @@ -4223,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]] @@ -4234,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]] @@ -4246,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" @@ -4411,7 +4690,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb" dependencies = [ - "cpal", + "cpal 0.15.3", "symphonia", "thiserror 1.0.69", ] @@ -4638,7 +4917,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", @@ -5083,6 +5362,7 @@ dependencies = [ "futuresdr", "image", "macros", + "native-dialog", "num-complex", "open", "parse-changelog", @@ -5629,6 +5909,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.30" @@ -5661,6 +5953,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" @@ -5671,12 +5973,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -5738,7 +6043,7 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.8.5", "sha1", "thiserror 1.0.69", "utf-8", @@ -5930,6 +6235,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" @@ -6209,6 +6524,16 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +[[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.5" @@ -6312,6 +6637,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" @@ -6903,7 +7240,7 @@ dependencies = [ "android-activity", "atomic-waker", "bitflags 2.9.1", - "block2", + "block2 0.5.1", "bytemuck", "calloop", "cfg_aliases", @@ -6954,6 +7291,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" @@ -7107,7 +7450,7 @@ dependencies = [ "hex", "nix", "ordered-stream", - "rand", + "rand 0.8.5", "serde", "serde_repr", "sha1", diff --git a/blocks/Cargo.toml b/blocks/Cargo.toml index bd13314..5b1f2d0 100644 --- a/blocks/Cargo.toml +++ b/blocks/Cargo.toml @@ -17,6 +17,7 @@ serde.workspace = true semver.workspace = true strum.workspace = true +atomic-slot = "0.1.0" derivative = "2.2.0" derive_more = { version = "1", features = ["from"] } futuredsp = "0.0.6" @@ -25,6 +26,9 @@ 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" } +thingbuf = { version = "0.1.6", features = ["alloc"] } +async-trait = "0.1.88" [target.'cfg(profile)'.dependencies] tracy-client.workspace = true diff --git a/blocks/src/block.rs b/blocks/src/block.rs index e44f6b8..0a54572 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)] @@ -453,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 8e24d0f..c61f98a 100644 --- a/blocks/src/blocks/audio.rs +++ b/blocks/src/blocks/audio.rs @@ -1,29 +1,40 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::prelude::*; use crate::{BlockMetadata, BlockPin, ParameterValueMut, ParameterValueRef}; -use crate::{F64Picker, prelude::*}; -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) -} +use async_trait::async_trait; +use atomic_slot::AtomicSlot; +use cpal::BufferSize; +use cpal::SampleRate; +use cpal::Stream; +use cpal::StreamConfig; +use cpal::traits::StreamTrait; +use cpal::traits::{DeviceTrait, HostTrait}; +use dsp_core::byte_buf::ByteBuf; +use futuresdr::runtime::BlockMeta; +use futuresdr::runtime::BlockMetaBuilder; +use futuresdr::runtime::Kernel; +use futuresdr::runtime::MessageIo; +use futuresdr::runtime::MessageIoBuilder; +use futuresdr::runtime::Result; +use futuresdr::runtime::StreamIo; +use futuresdr::runtime::StreamIoBuilder; +use futuresdr::runtime::TypedBlock; +use futuresdr::runtime::WorkIo; +use thingbuf::mpsc::errors::TryRecvError; +use tracing::{error, info, warn}; #[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(default = "CpalDevicePicker::source()")] + #[block(skip)] + #[serde(default = "CpalDevicePicker::source")] + #[serde(skip)] + audio_device: CpalDevicePicker, #[block(output_kinds = [PinKind::RealSlice32])] output: BlockPin, @@ -37,8 +48,27 @@ fn construct_audio_source( block: &AudioSourceBlock, _ctx: &mut ConstructContext, ) -> anyhow::Result { - use futuresdr::blocks::audio::AudioSource; - Ok(FutureSdrBlock::from_typed(AudioSource::new(*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.inner, + sample_rate, + // TODO: make channels user-configurable + device.channels, + )?) + .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)] @@ -66,9 +96,13 @@ 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(default = "CpalDevicePicker::sink()")] + #[block(skip)] + #[serde(default = "CpalDevicePicker::sink")] + #[serde(skip)] + audio_device: CpalDevicePicker, #[block(input_kinds = [PinKind::RealSlice32])] input: BlockPin, @@ -82,6 +116,733 @@ fn construct_audio_sink( block: &AudioSinkBlock, _ctx: &mut ConstructContext, ) -> anyhow::Result { - use futuresdr::blocks::audio::AudioSink; - Ok(FutureSdrBlock::from_typed(AudioSink::new(*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.inner, + sample_rate, + device.channels, + )?) + .into()) +} + +fn render_audio_sink(block: &mut AudioSinkBlock, ui: &mut egui::Ui) { + block.audio_device.render(ui); +} + +/// Audio Source. +#[allow(clippy::type_complexity)] +pub struct AudioSourceFsdr { + stream: Stream, + rx: thingbuf::mpsc::Receiver>, + err: Arc>, + buf: ByteBuf, + de_interleave: Vec, + channels: u16, +} + +// cpal::Stream is !Send for API reasons not for safety reasons +// See: +// - https://github.com/RustAudio/cpal/pull/840 +// - https://github.com/RustAudio/cpal/pull/961 +#[allow(clippy::non_send_fields_in_send_ty)] +unsafe impl Send for AudioSourceFsdr {} + +impl AudioSourceFsdr { + /// Create AudioSource block + #[allow(clippy::new_ret_no_self)] + pub fn new( + device: &cpal::platform::Device, + sample_rate: u32, + channels: u16, + ) -> anyhow::Result> { + ensure!( + channels == 1 || channels == 2, + "Unsupported channel count {channels}" + ); + + let (tx, rx) = thingbuf::mpsc::channel::>(8); + + let config = StreamConfig { + channels, + sample_rate: SampleRate(sample_rate), + buffer_size: BufferSize::Default, + }; + + 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(), + }, + }))); + } + warn!("Failed to send {} samples to flowgraph", data.len()); + } + }, + move |err| { + error!("cpal stream error {err:?}"); + err2.store(Some(Box::new(err))); + }, + None, + )?; + + Ok(TypedBlock::new( + BlockMetaBuilder::new("AudioSource").build(), + StreamIoBuilder::new().add_output::("out").build(), + MessageIoBuilder::new().build(), + AudioSourceFsdr { + stream, + rx, + err, + buf: ByteBuf::new(64 * 1024), + de_interleave: vec![], + channels, + }, + )) + } +} + +impl Drop for AudioSourceFsdr { + fn drop(&mut self) { + info!("Dropping AudioSource"); + if let Err(e) = self.stream.pause() { + warn!("Failed to pause AudioSource: {e:?}"); + } + } +} + +#[doc(hidden)] +#[async_trait] +impl Kernel for AudioSourceFsdr { + async fn init( + &mut self, + _s: &mut StreamIo, + _m: &mut MessageIo, + _b: &mut BlockMeta, + ) -> Result<()> { + self.stream.play()?; + Ok(()) + } + + async fn work( + &mut self, + io: &mut WorkIo, + sio: &mut StreamIo, + _mio: &mut MessageIo, + _meta: &mut BlockMeta, + ) -> 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()); + } + } + + if !self.buf.is_empty() { + let output = sio.output(0).slice::(); + let len = self.buf.read(output); + + sio.output(0).produce(len); + return Ok(()); + } + + match self.rx.recv_ref().await { + 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]; + 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); + output[..len].copy_from_slice(to_write); + + sio.output(0).produce(len); + + if !rest.is_empty() { + if self.buf.append(rest).is_err() { + warn!("Buffer overflow in AudioSource"); + } + io.call_again = true; + } + } + None => { + io.finished = true; + } + } + + Ok(()) + } +} + +/// Audio Sink. +#[allow(clippy::type_complexity)] +pub struct AudioSinkFsdr { + stream: Stream, + tx: thingbuf::mpsc::Sender>, + err: Arc>, + initialized: Arc, + channels: u16, +} + +// cpal::Stream is !Send for API reasons not for safety reasons +// See: +// - https://github.com/RustAudio/cpal/pull/840 +// - https://github.com/RustAudio/cpal/pull/961 +#[allow(clippy::non_send_fields_in_send_ty)] +unsafe impl Send for AudioSinkFsdr {} + +impl AudioSinkFsdr { + /// Create AudioSource block + #[allow(clippy::new_ret_no_self)] + pub fn new( + device: &cpal::platform::Device, + sample_rate: u32, + channels: u16, + ) -> anyhow::Result> { + ensure!( + channels == 1 || channels == 2, + "Unsupported channel count {channels}" + ); + + let (tx, rx) = thingbuf::mpsc::channel::>(8); + + let config = StreamConfig { + channels, + sample_rate: SampleRate(sample_rate), + buffer_size: BufferSize::Fixed(960), + }; + + let err = Arc::new(AtomicSlot::empty()); + let err2 = Arc::clone(&err); + let err3 = Arc::clone(&err); + + let initialized = Arc::new(AtomicBool::new(false)); + let initialized2 = Arc::clone(&initialized); + let mut first = true; + + 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 + move |data: &mut [f32], _| loop { + if first { + initialized2.store(true, Ordering::Release); + first = false; + } + + // 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() { + data.copy_from_slice(&input); + break; + } else { + // Buffer and handle later + if buf.append(&input).is_err() { + warn!("Buffer overflow sending data to cpal output"); + } + } + } + Err(TryRecvError::Empty) => { + warn!("Buffer underflow on AudioSink block"); + break; + } + Err(e) => { + let description = format!("Failed to read samples from flowgraph: {e:?}"); + error!("{}", description); + err2.store(Some(Box::new(cpal::StreamError::BackendSpecific { + err: cpal::BackendSpecificError { description }, + }))); + break; + } + } + }, + move |err| { + error!("cpal stream error {err:?}"); + err3.store(Some(Box::new(err))); + }, + None, + )?; + + Ok(TypedBlock::new( + BlockMetaBuilder::new("AudioSink").build(), + StreamIoBuilder::new().add_input::("in").build(), + MessageIoBuilder::new().build(), + AudioSinkFsdr { + stream, + tx, + err, + channels, + initialized, + }, + )) + } +} + +#[doc(hidden)] +#[async_trait] +impl Kernel for AudioSinkFsdr { + async fn init( + &mut self, + _s: &mut StreamIo, + _m: &mut MessageIo, + _b: &mut BlockMeta, + ) -> Result<()> { + self.stream.play()?; + Ok(()) + } + + async fn work( + &mut self, + io: &mut WorkIo, + sio: &mut StreamIo, + _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() { + warn!("Failed to pause cpal stream after error: {e:?}"); + } + io.finished = true; + return Err(err.into()); + } + } + + // 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(()); + } + + match self.tx.send_ref().await { + Ok(mut dst) => { + let input = sio.input(0).slice::(); + + if self.channels == 1 { + if input.len() > dst.len() { + dst.resize(input.len(), 0.0); + } + + 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] = input[i]; + dst[2 * i + 1] = input[i]; + } + } else { + unreachable!("Checked before that channels was 1 or 2"); + } + + sio.input(0).consume(input.len()); + } + Err(_) => { + io.finished = true; + } + } + + Ok(()) + } +} + +impl Drop for AudioSinkFsdr { + fn drop(&mut self) { + info!("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, + Sink, +} + +#[derive(Clone, Debug)] +pub struct CpalDevicePicker { + host_index: usize, + devices: Vec, + device_index: usize, + sample_rate_index: usize, + kind: AudioDeviceKind, +} + +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() + }) +} + +/// 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, + channels: u16, +} + +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) + .field("channels", &self.channels) + .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 || { + 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() { + 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 device in devices { + let Ok(name) = device.name() else { + continue; + }; + + 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() + .flat_map(|c| c.filter(config_filter).collect::>()) + .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, + channels, + name: name.clone(), + }); + } + + let outputs: Vec<_> = device + .supported_output_configs() + .into_iter() + .flat_map(|c| c.filter(config_filter).collect::>()) + .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, + channels, + name, + }); + } + + 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 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 + } + + 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) + // TODO: make >= + if sample_rates_mono.len() > sample_rates_sterio.len() { + (sample_rates_mono, 1) + } else { + (sample_rates_sterio, 2) + } + } + } + } + + { + if let Some(devs) = DEVICES.get() { + let mut guard = devs.lock().unwrap(); + if guard.is_empty() && !new_devs.is_empty() { + info!("Updating devices: {new_devs:?}"); + } + *guard = 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 at any moment + // The combination of these two mean that devices will randomly come and go, + // so better to disable scanning entierly on linux. + 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(AudioDeviceKind::Source) + } + + pub fn sink() -> Self { + Self::new(AudioDeviceKind::Sink) + } + + pub fn new(kind: AudioDeviceKind) -> Self { + Self { + host_index: 0, + devices: vec![], + device_index: 0, + sample_rate_index: 0, + kind, + } + } + + fn render(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.label("Audio backend: "); + let hosts = get_hosts(); + if !hosts.is_empty() { + let mut selected = hosts[self.host_index].id(); + egui::ComboBox::from_id_salt("AudioSource host") + .selected_text(selected.name()) + .show_ui(ui, |ui| { + 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; + } + } + }); + } + }); + + 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.clone(); + + egui::ComboBox::from_id_salt("AudioSource device") + .selected_text(&selected) + .show_ui(ui, |ui| { + for (i, dev) in self.devices.iter().enumerate() { + ui.selectable_value(&mut selected, dev.name.clone(), dev.name.clone()); + if selected == dev.name { + self.device_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<&AudioDevice> { + 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.get_device().map_or(&[], |d| &d.sample_rates) + } + + pub fn get_sample_rate(&self) -> Option { + let rates = self.supported_samples_rates(); + if rates.is_empty() { + assert_eq!(self.sample_rate_index, 0); + None + } else { + Some(rates[self.sample_rate_index]) + } + } +} + +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 77cbd56..40c44e9 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/blocks/math.rs b/blocks/src/blocks/math.rs index a68418e..08664d3 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 })] @@ -61,10 +61,10 @@ 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: 2MHz, range: 50kHz..=60MHz })] + #[block(range = { initial: 20kHz, range: 10kHz..=60MHz })] sample_rate: FloatValue, #[block(range = { initial: 0, range: 0..=1e12 })] @@ -116,10 +116,10 @@ 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: 2MHz, range: 50kHz..=60MHz })] + #[block(range = { initial: 20kHz, range: 10kHz..=60MHz })] sample_rate: FloatValue, #[block(range = { initial: 0, range: 0..=1e12 })] diff --git a/blocks/src/gui/spectrogram.rs b/blocks/src/gui/spectrogram.rs index b410447..4300459 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/blocks/src/lib.rs b/blocks/src/lib.rs index 25cba88..bf405a4 100644 --- a/blocks/src/lib.rs +++ b/blocks/src/lib.rs @@ -31,12 +31,29 @@ mod types; pub use types::*; pub mod version; -#[derive(Default)] pub struct ConstructContext { pub widget_tokens: Vec, pub flowgraph: prelude::FutureSdrFlowgraph, } +impl ConstructContext { + pub fn new() -> Self { + futuresdr::runtime::config::set("ctrlport_enable", false); + futuresdr::runtime::config::set("buffer_size", 32768); + + Self { + widget_tokens: vec![], + flowgraph: prelude::FutureSdrFlowgraph::new(), + } + } +} + +impl Default for ConstructContext { + fn default() -> Self { + Self::new() + } +} + impl ConstructContext { pub fn add_widget_token(&mut self, token: WidgetToken) { self.widget_tokens.push(token); diff --git a/blocks/src/types.rs b/blocks/src/types.rs index 08facdc..638b6da 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/dsp-core/Cargo.toml b/dsp-core/Cargo.toml index b337777..aa2628a 100644 --- a/dsp-core/Cargo.toml +++ b/dsp-core/Cargo.toml @@ -10,9 +10,20 @@ 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", +] } + +[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 new file mode 100644 index 0000000..5f1e48f --- /dev/null +++ b/dsp-core/src/byte_buf.rs @@ -0,0 +1,208 @@ +/// A simple, fixed-capacity FIFO with one internal Vec and a head offset. +pub struct ByteBuf { + buf: Vec, + head: usize, + 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 { + buf: Vec::new(), + head: 0, + max_capacity, + } + } + + /// Number of unread items remaining. + pub fn len(&self) -> usize { + self.buf.len() - self.head + } + + /// True if there are no unread items. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Append a slice of new items. + /// + /// Returns Err(()) if the max capacity would be exceeded. + 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 self.buf.len() + chunk.len() > self.max_capacity { + 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<(), BufferUnderflowError> { + if self.len() < out.len() { + return Err(BufferUnderflowError); + } + 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 { + 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; + + // If we've consumed more than half the buffer, attempt to shift + if self.head > (self.max_capacity / 2) { + self.compact(); + } + + len + } + + /// Drop the consumed prefix by shifting the remaining items back to the start of the Vec. + fn compact(&mut self) { + if self.head == 0 { + return; + } + let remaining = self.len(); + self.buf.copy_within(self.head.., 0); + self.buf.truncate(remaining); + self.head = 0; + } +} + +#[cfg(test)] +mod tests { + use super::ByteBuf; + + #[test] + fn test_empty_buffer() { + let buf = ByteBuf::::new(8); + assert_eq!(buf.len(), 0); + assert!(buf.is_empty()); + } + + #[test] + fn test_append_and_read() { + let mut buf = ByteBuf::new(8); + buf.append(&[10, 20, 30]).unwrap(); + assert_eq!(buf.len(), 3); + assert!(!buf.is_empty()); + + let mut out = [0; 5]; + let n = buf.read(&mut out); + assert_eq!(n, 3); + assert_eq!(&out[..n], &[10, 20, 30]); + assert_eq!(buf.len(), 0); + assert!(buf.is_empty()); + } + + #[test] + fn test_read_exact_success() { + let mut buf = ByteBuf::new(4); + buf.append(&[1, 2, 3, 4]).unwrap(); + + let mut out = [0; 4]; + assert!(buf.read_exact(&mut out).is_ok()); + assert_eq!(out, [1, 2, 3, 4]); + assert!(buf.is_empty()); + } + + #[test] + fn test_read_exact_failure() { + let mut buf = ByteBuf::::new(4); + buf.append(&[5, 6]).unwrap(); + + let mut out = [0; 4]; + assert!(buf.read_exact(&mut out).is_err()); + // ensure nothing was consumed on failure + assert_eq!(buf.len(), 2); + } + + #[test] + fn test_overflow_on_append() { + let mut buf = ByteBuf::new(3); + assert!(buf.append(&[1, 2, 3]).is_ok()); + assert_eq!(buf.len(), 3); + + // this append would exceed capacity + assert!(buf.append(&[4]).is_err()); + // buffer unchanged + assert_eq!(buf.len(), 3); + } + + #[test] + fn test_compact_after_partial_read() { + // capacity=4, so half = 2; reading >2 should trigger compact + let mut buf = ByteBuf::new(4); + buf.append(&[100, 101, 102, 103]).unwrap(); + + // read 3 items: head goes to 3 (> cap/2), compact should run + let mut out1 = [0; 3]; + let n1 = buf.read(&mut out1); + assert_eq!(n1, 3); + assert_eq!(out1, [100, 101, 102]); + + // after compact, internal head=0, remaining data = [103] + assert_eq!(buf.len(), 1); + + // next read should yield the last element + let mut out2 = [0; 1]; + let n2 = buf.read(&mut out2); + assert_eq!(n2, 1); + 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 = 1_000_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); + } +} diff --git a/dsp-core/src/lib.rs b/dsp-core/src/lib.rs index b0337af..ba934d7 100644 --- a/dsp-core/src/lib.rs +++ b/dsp-core/src/lib.rs @@ -1,2 +1,3 @@ +pub mod byte_buf; pub mod futuresdr_blocks; pub mod logging; diff --git a/dsp-core/src/logging.rs b/dsp-core/src/logging.rs index 14001a2..dd3ee72 100644 --- a/dsp-core/src/logging.rs +++ b/dsp-core/src/logging.rs @@ -1,31 +1,140 @@ +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) { - 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, +/// 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![]; + + // ========== FILE LOG ========== + + fn log_file_name() -> String { + chrono::Local::now() + .naive_local() + .format("foxhunter-studio-log_%Y-%m-%d_%H-%M-%S.json") + .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 => { + #[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); + + // 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 + } + #[cfg(not(target_os = "windows"))] + { + 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!("ERROR: 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 ========== + tracing_log::LogTracer::builder() - .with_max_level(log_crate_level) + .with_max_level(log::LevelFilter::Debug) .init() .expect("Failed to build tracing_log tracer"); let format = fmt::layer() .with_level(true) .with_target(true) + .with_line_number(true) .with_thread_ids(false) .with_thread_names(true) .compact(); @@ -35,36 +144,40 @@ 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); - #[cfg(target_os = "android")] - let subscriber = { - use tracing_subscriber::layer::SubscriberExt; - subscriber.with(tracing_android::layer("foxhunter_log_jni").unwrap()) - }; + let subscriber = subscriber.with(format.with_filter(filter)); - let _ = tracing::subscriber::set_global_default(subscriber); + // ========== ANDROID LOG ========== #[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}"); - })); - } + let subscriber = { subscriber.with(tracing_android::layer("foxhunter_log_jni").unwrap()) }; + + // ========== SETUP ========== + + tracing::subscriber::set_global_default(subscriber).expect("Failed to set global logger"); + + tracing::info!("Writing verbose logs to {log_file_path:?}"); + + 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/audio-source.fx b/examples/audio-source.fx new file mode 100644 index 0000000..c3bbdcc --- /dev/null +++ b/examples/audio-source.fx @@ -0,0 +1,102 @@ +[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 = 299.3641052246094 +y = 194.6672821044922 + +[blocks.0.AudioSource.audio_rate] +index = 0 +supported = [24000.0, 44100.0, 48000.0, 96000.0, 384000.0] + +[blocks.0.AudioSource.output] +name = "out" +supported = ["RealSlice32"] +current = 0 + +[blocks.0.AudioSource.metadata] +pin_types_match = false + +[blocks.1.position] +x = 905.6788940429688 +y = 263.2333679199219 + +[blocks.1.Spectrogram] +name = "spectrogram" + +[blocks.1.Spectrogram.bins] +value = 2048 + +[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 = 594.9246215820313 +y = 199.8106689453125 + +[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 = 301.0697937011719 +y = 313.4004211425781 + +[blocks.3.ConstSource] +value = "0" + +[blocks.3.ConstSource.output] +name = "out" +supported = ["RealSlice32"] +current = 0 + +[blocks.3.ConstSource.metadata] +pin_types_match = false + +[[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 = "3" +src_port_index = 0 +dst_index = "2" +dst_port_index = 1 diff --git a/examples/audio-source2.fx b/examples/audio-source2.fx new file mode 100644 index 0000000..a262240 --- /dev/null +++ b/examples/audio-source2.fx @@ -0,0 +1,124 @@ +[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 = 299.3641052246094 +y = 194.6672821044922 + +[blocks.0.AudioSource.audio_rate] +index = 0 +supported = [24000.0, 44100.0, 48000.0, 96000.0, 384000.0] + +[blocks.0.AudioSource.output] +name = "out" +supported = ["RealSlice32"] +current = 0 + +[blocks.0.AudioSource.metadata] +pin_types_match = false + +[blocks.1.position] +x = 905.6788940429688 +y = 263.2333679199219 + +[blocks.1.Spectrogram] +name = "spectrogram" + +[blocks.1.Spectrogram.bins] +value = 2048 + +[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 = 594.9246215820313 +y = 199.8106689453125 + +[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 = 301.0697937011719 +y = 313.4004211425781 + +[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 = 608.0611572265625 +y = 26.03942108154297 + +[blocks.4.AudioSink.audio_rate] +index = 0 +supported = [24000.0, 44100.0, 48000.0, 96000.0, 384000.0] + +[blocks.4.AudioSink.input] +name = "in" +supported = ["RealSlice32"] +current = 0 + +[blocks.4.AudioSink.metadata] +pin_types_match = false + +[[connections]] +src_index = "2" +src_port_index = 0 +dst_index = "1" +dst_port_index = 0 + +[[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 = "2" +dst_port_index = 0 + +[[connections]] +src_index = "0" +src_port_index = 0 +dst_index = "4" +dst_port_index = 0 diff --git a/examples/audio-source3.fx b/examples/audio-source3.fx new file mode 100644 index 0000000..2a15965 --- /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/examples/windows-crash.fds b/examples/windows-crash.fds new file mode 100644 index 0000000..c4609db --- /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 9a0169e..f4cfb80 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 @@ -100,6 +100,7 @@ wayland fontconfig stdenv.cc.cc.lib + jack2 ]) ++ lib.optionals pkgs.stdenv.isDarwin [ pkgs.libiconv ]; @@ -117,6 +118,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 de9ecea..096be9f 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")); } }; + + 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 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,21 +488,6 @@ 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 { - 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" // - input_kinds = "..., ..., ..." @@ -461,52 +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 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.first().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 { @@ -555,16 +614,21 @@ impl FieldInfo { }; (kind, expr) } + FieldKind::Skip => ( + kind, + default_expr_opt + .unwrap_or_else(|| syn::parse_quote!(::std::default::Default::default())), + ), _ => unreachable!(), }, }; - Ok(FieldInfo { + Ok(Some(FieldInfo { ident, span, default_expr, kind, - }) + })) } } @@ -573,49 +637,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/runner/src/flowgraph_builder.rs b/runner/src/flowgraph_builder.rs index 75dc3a7..3deef54 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, } diff --git a/studio/Cargo.toml b/studio/Cargo.toml index 97eef8d..f982661 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 0000000..7b65bcf --- /dev/null +++ b/studio/README.md @@ -0,0 +1,29 @@ + +# foxhunter-studio + +## Usage + +``` +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/docs/changelog.md b/studio/docs/changelog.md index 2068e09..53ca1a2 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 diff --git a/studio/src/flowgraph_viewer.rs b/studio/src/flowgraph_viewer.rs index d56093d..4971da5 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( @@ -197,6 +200,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(); diff --git a/studio/src/main.rs b/studio/src/main.rs index 17260c7..08bc59e 100644 --- a/studio/src/main.rs +++ b/studio/src/main.rs @@ -1,4 +1,4 @@ -#![windows_subsystem = "windows"] +// #![windows_subsystem = "windows"] use studio::DspStudioApp; use tracing::info; @@ -6,7 +6,8 @@ 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 tracing::error; use clap::Parser; @@ -24,6 +25,14 @@ fn main() -> eframe::Result<()> { /// .fx flowgraph save to open #[arg(value_parser)] file: Option, + + /// 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 + #[clap(long)] + log_path: Option, } let args = Cli::parse(); @@ -45,7 +54,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() @@ -76,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| { @@ -94,7 +103,23 @@ 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); + } + + Ok(()) } #[cfg(target_arch = "wasm32")]