commit 496b6bdc71d740324b7078e4bc93a635ced254c2 Author: Matt Spencer Date: Mon Mar 23 15:06:31 2026 +0000 Initial commit of a toy analogue audio generator Key components: - no_std core so it can be used bare metal on embedded systems - default implementation for RPi 2350, with midi input and I2s output using PIO - Visualiser for the web to play with on a dev system diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..4b2f0b8 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,8 @@ +[target.wasm32-unknown-unknown] +rustflags = [ + "-C", "target-feature=+simd128", +] + +# Bare-metal Cortex-M4F (e.g. Daisy Seed / STM32H750) — uncomment when needed: +# [target.thumbv7em-none-eabihf] +# rustflags = ["-C", "link-arg=-Tlink.x"] diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..4ac62e2 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo build:*)", + "Bash(grep -v \"^$\")", + "WebFetch(domain:github.com)", + "Bash(find /Users/mattsp/.cargo/registry/src -name memory.x -path */rp*)", + "Bash(find /Users/mattsp/.cargo/registry/src -name build.rs -path */rp-pac*)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4048fec --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1808 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "az" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" + +[[package]] +name = "bare-metal" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5deb64efa5bd81e31fcd1938615a6d98c82eafcbcd787162b6f63b91d6bac5b3" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitfield" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "defmt", + "num-traits", + "pure-rust-locales", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "cortex-m" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9" +dependencies = [ + "bare-metal", + "bitfield", + "critical-section", + "embedded-hal 0.2.7", + "volatile-register", +] + +[[package]] +name = "cortex-m-rt" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d4dec46b34c299ccf6b036717ae0fce602faa4f4fe816d9013b9a7c9f5ba6" +dependencies = [ + "cortex-m-rt-macros", +] + +[[package]] +name = "cortex-m-rt-macros" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37549a379a9e0e6e576fd208ee60394ccb8be963889eebba3ffe0980364f472" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc-any" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a62ec9ff5f7965e4d7280bd5482acd20aadb50d632cf6c1d74493856b011fa73" +dependencies = [ + "debug-helper", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "debug-helper" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f578e8e2c440e7297e008bb5486a3a8a194775224bbc23729b0dbdfaeebf162e" + +[[package]] +name = "defmt" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "defmt-rtt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d5a25c99d89c40f5676bec8cefe0614f17f0f40e916f98e345dae941807f9e" +dependencies = [ + "critical-section", + "defmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embassy-embedded-hal" +version = "0.6.0" +source = "git+https://github.com/embassy-rs/embassy#c49665d681549027cbac5302ca7365bea4ae2ba1" +dependencies = [ + "embassy-futures", + "embassy-hal-internal", + "embassy-sync", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-storage", + "embedded-storage-async", + "nb 1.1.0", +] + +[[package]] +name = "embassy-executor" +version = "0.10.0" +source = "git+https://github.com/embassy-rs/embassy#c49665d681549027cbac5302ca7365bea4ae2ba1" +dependencies = [ + "cordyceps", + "cortex-m", + "critical-section", + "defmt", + "document-features", + "embassy-executor-macros", + "embassy-executor-timer-queue", +] + +[[package]] +name = "embassy-executor-macros" +version = "0.8.0" +source = "git+https://github.com/embassy-rs/embassy#c49665d681549027cbac5302ca7365bea4ae2ba1" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "embassy-executor-timer-queue" +version = "0.1.0" +source = "git+https://github.com/embassy-rs/embassy#c49665d681549027cbac5302ca7365bea4ae2ba1" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "git+https://github.com/embassy-rs/embassy#c49665d681549027cbac5302ca7365bea4ae2ba1" + +[[package]] +name = "embassy-hal-internal" +version = "0.5.0" +source = "git+https://github.com/embassy-rs/embassy#c49665d681549027cbac5302ca7365bea4ae2ba1" +dependencies = [ + "cortex-m", + "critical-section", + "defmt", + "num-traits", +] + +[[package]] +name = "embassy-rp" +version = "0.10.0" +source = "git+https://github.com/embassy-rs/embassy#c49665d681549027cbac5302ca7365bea4ae2ba1" +dependencies = [ + "cfg-if", + "chrono", + "cortex-m", + "cortex-m-rt", + "critical-section", + "defmt", + "document-features", + "embassy-embedded-hal", + "embassy-futures", + "embassy-hal-internal", + "embassy-sync", + "embassy-time", + "embassy-time-driver", + "embassy-time-queue-utils", + "embassy-usb-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-hal-nb", + "embedded-io", + "embedded-io-async", + "embedded-storage", + "embedded-storage-async", + "fixed", + "nb 1.1.0", + "pio", + "rand_core 0.6.4", + "rand_core 0.9.5", + "rp-pac", + "rp2040-boot2", + "sha2-const-stable", + "smart-leds", +] + +[[package]] +name = "embassy-sync" +version = "0.8.0" +source = "git+https://github.com/embassy-rs/embassy#c49665d681549027cbac5302ca7365bea4ae2ba1" +dependencies = [ + "cfg-if", + "critical-section", + "defmt", + "embedded-io-async", + "futures-core", + "futures-sink", + "heapless 0.9.2", +] + +[[package]] +name = "embassy-time" +version = "0.5.1" +source = "git+https://github.com/embassy-rs/embassy#c49665d681549027cbac5302ca7365bea4ae2ba1" +dependencies = [ + "cfg-if", + "critical-section", + "defmt", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-core", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.2" +source = "git+https://github.com/embassy-rs/embassy#c49665d681549027cbac5302ca7365bea4ae2ba1" +dependencies = [ + "document-features", +] + +[[package]] +name = "embassy-time-queue-utils" +version = "0.3.0" +source = "git+https://github.com/embassy-rs/embassy#c49665d681549027cbac5302ca7365bea4ae2ba1" +dependencies = [ + "embassy-executor-timer-queue", + "heapless 0.9.2", +] + +[[package]] +name = "embassy-usb-driver" +version = "0.2.0" +source = "git+https://github.com/embassy-rs/embassy#c49665d681549027cbac5302ca7365bea4ae2ba1" +dependencies = [ + "defmt", + "embedded-io-async", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-hal-nb" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba4268c14288c828995299e59b12babdbe170f6c6d73731af1b4648142e8605" +dependencies = [ + "embedded-hal 1.0.0", + "nb 1.1.0", +] + +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "embedded-io", +] + +[[package]] +name = "embedded-storage" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21dea9854beb860f3062d10228ce9b976da520a73474aed3171ec276bc0c032" + +[[package]] +name = "embedded-storage-async" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1763775e2323b7d5f0aa6090657f5e21cfa02ede71f5dc40eead06d64dcd15cc" +dependencies = [ + "embedded-storage", +] + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixed" +version = "1.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af2cbf772fa6d1c11358f92ef554cb6b386201210bcf0e91fb7fba8a907fb40" +dependencies = [ + "az", + "bytemuck", + "half", + "typenum", +] + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "micromath" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" + +[[package]] +name = "midi-types" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef0bbe5256e5c434947d790788426bb65773502784aed7b23408f7e7fb4d8eb5" + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "panic-probe" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd402d00b0fb94c5aee000029204a46884b1262e0c443f166d86d2c0747e1a1a" +dependencies = [ + "cortex-m", + "defmt", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pio" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0ba4153cee9585abc451271aa437d9e8defdea8b468d48ba6b8f098cbe03d7f" +dependencies = [ + "pio-core", + "pio-proc", +] + +[[package]] +name = "pio-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61d90fddc3d67f21bbf93683bc461b05d6a29c708caf3ffb79947d7ff7095406" +dependencies = [ + "arrayvec", + "num_enum", + "paste", +] + +[[package]] +name = "pio-parser" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825266c1eaddf54f636d06eefa4bf3c99d774c14ec46a4a6c6e5128a0f10d205" +dependencies = [ + "lalrpop", + "lalrpop-util", + "pio-core", +] + +[[package]] +name = "pio-proc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a76571f5fe51af43cc80ac870fe0c79cc0cdd686b9002a6c4c84bfdd0176b" +dependencies = [ + "codespan-reporting", + "lalrpop-util", + "pio-core", + "pio-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pure-rust-locales" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869675ad2d7541aea90c6d88c81f46a7f4ea9af8cd0395d38f11a95126998a0d" +dependencies = [ + "defmt", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "rp-pac" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8af65855c40b2c35079514c5489abffc0429347fef25d8467ff98ad84b4322d3" +dependencies = [ + "cortex-m", + "cortex-m-rt", +] + +[[package]] +name = "rp2040-boot2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c92f344f63f950ee36cf4080050e4dce850839b9175da38f9d2ffb69b4dbb21" +dependencies = [ + "crc-any", +] + +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2-const-stable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9" + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smart-leds" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66df34e571fa9993fa6f99131a374d58ca3d694b75f9baac93458fe0d6057bf0" +dependencies = [ + "smart-leds-trait", +] + +[[package]] +name = "smart-leds-trait" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f4441a131924d58da6b83a7ad765c460e64630cce504376c3a87a2558c487f" +dependencies = [ + "rgb", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synth-core" +version = "0.1.0" +dependencies = [ + "heapless 0.8.0", + "libm", + "micromath", + "midi-types", + "num-traits", + "serde", +] + +[[package]] +name = "synth-embedded" +version = "0.1.0" +dependencies = [ + "cortex-m", + "cortex-m-rt", + "defmt", + "defmt-rtt", + "embassy-executor", + "embassy-futures", + "embassy-rp", + "embassy-sync", + "embassy-time", + "fixed", + "heapless 0.8.0", + "libm", + "panic-probe", + "pio", + "synth-core", +] + +[[package]] +name = "synth-visualiser" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "js-sys", + "serde", + "synth-core", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", +] + +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcell" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "volatile-register" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc" +dependencies = [ + "vcell", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6311c867385cc7d5602463b31825d454d0837a3aba7cdb5e56d5201792a3f7fe" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67008cdde4769831958536b0f11b3bdd0380bde882be17fff9c2f34bb4549abd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe29135b180b72b04c74aa97b2b4a2ef275161eff9a6c7955ea9eaedc7e1d4e" + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7ca3b48 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,60 @@ +[workspace] +resolver = "2" +members = [ + "crates/synth-core", + "crates/synth-visualiser", + "crates/synth-embedded", +] + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = "symbols" + +[profile.release-wasm] +inherits = "release" +opt-level = "z" +lto = "fat" +panic = "abort" + +[workspace.dependencies] +# no_std-compatible math +libm = { version = "0.2", default-features = false } +num-traits = { version = "0.2", default-features = false } +micromath = { version = "2.1", default-features = false } + +# Stack-allocated collections +heapless = { version = "0.8", default-features = false } + +# MIDI +midi-types = { version = "0.1", default-features = false } + +# WASM / browser +wasm-bindgen = { version = "0.2" } +js-sys = { version = "0.3" } +wasm-bindgen-futures = { version = "0.4" } +console_error_panic_hook = { version = "0.1" } + +# Serialisation +serde = { version = "1", default-features = false, features = ["derive"] } + +# Embassy — RP2350 support is only in the git repo, not crates.io. +# Cargo pins the resolved commit into Cargo.lock on first build. +# Pin a specific `rev = "..."` for reproducible builds. +embassy-executor = { git = "https://github.com/embassy-rs/embassy", default-features = false } +embassy-rp = { git = "https://github.com/embassy-rs/embassy", default-features = false } +embassy-sync = { git = "https://github.com/embassy-rs/embassy", default-features = false } +embassy-time = { git = "https://github.com/embassy-rs/embassy", default-features = false } +embassy-futures = { git = "https://github.com/embassy-rs/embassy", default-features = false } + +# Embedded ecosystem (crates.io — stable) +cortex-m = { version = "0.7", default-features = false } +cortex-m-rt = { version = "0.7", default-features = false } +defmt = { version = "1.0.1", default-features = false } +defmt-rtt = { version = "1.1.0", default-features = false } +panic-probe = { version = "1.0.0", default-features = false } +fixed = { version = "1.23", default-features = false } +static-cell = { version = "2", default-features = false } +pio = { version = "0.3", default-features = false } +# pio-proc = { version = "0.3" } diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ce77a4 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# Sound — Analogue Synthesiser + +A modular analogue synthesiser written in Rust. + +- **`synth-core`** — `no_std` DSP library (oscillators, filters, envelopes, LFO, MIDI). Runs on microcontrollers and WASM. +- **`synth-visualiser`** — browser-based visualiser (oscilloscope, spectrum analyser, patch bay) compiled to WebAssembly. + +## Prerequisites + +```sh +# Rust toolchain (stable) +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# WASM target +rustup target add wasm32-unknown-unknown + +# wasm-pack (builds and packages the WASM module) +cargo install wasm-pack +``` + +## Build + +### synth-core (native — tests and development) + +```sh +cargo build -p synth-core +cargo test -p synth-core +``` + +### synth-core (WASM — verify it cross-compiles) + +```sh +cargo build -p synth-core --target wasm32-unknown-unknown +``` + +### synth-visualiser (browser) + +Run from the workspace root: + +```sh +wasm-pack build crates/synth-visualiser --target web --out-dir ../../www/pkg +``` + +This generates `www/pkg/` containing the compiled `.wasm` binary and the JS glue module. + +## Run + +Serve the `www/` directory with any static HTTP server. A browser is required (the Web Audio API is not available over `file://`). + +```sh +# Python (no install needed) +python3 -m http.server --directory www 8080 + +# Or with npx serve +npx serve www +``` + +Then open [http://localhost:8080](http://localhost:8080). + +## Microcontroller deployment + +`synth-core` targets bare-metal Cortex-M out of the box. Add the relevant target and uncomment the linker flags in [`.cargo/config.toml`](.cargo/config.toml). + +```sh +# Example: Daisy Seed / STM32H750 (Cortex-M7) +rustup target add thumbv7em-none-eabihf +cargo build -p synth-core --target thumbv7em-none-eabihf +``` + +A microcontroller runner crate (I²S output, UART MIDI) can be added as a new workspace member when needed. + +## Project structure + +``` +sound/ +├── crates/ +│ ├── synth-core/ # no_std DSP library +│ │ └── src/ +│ │ ├── oscillator.rs VCO — Sine, Saw, Square, Triangle, Pulse +│ │ ├── filter.rs SVF — LP / HP / BP / Notch +│ │ ├── envelope.rs ADSR envelope generator +│ │ ├── vca.rs Voltage-controlled amplifier +│ │ ├── lfo.rs Low-frequency oscillator +│ │ ├── midi.rs Byte-stream MIDI parser +│ │ ├── patch.rs Cable routing graph +│ │ ├── math.rs DSP utilities (lerp, dB, MIDI→Hz) +│ │ └── config.rs SampleRate type +│ └── synth-visualiser/ # WASM browser front-end +│ └── src/ +│ ├── engine.rs AudioContext + AnalyserNode +│ ├── oscilloscope.rs Time-domain canvas view +│ ├── spectrum.rs FFT canvas view +│ ├── patchbay.rs Drag-and-drop patch cables +│ └── params.rs SynthParams (JSON serialisable) +└── www/ + ├── index.html Browser UI + ├── bootstrap.js WASM loader (ES module) + └── pkg/ Generated by wasm-pack (git-ignored) +``` diff --git a/crates/synth-core/Cargo.toml b/crates/synth-core/Cargo.toml new file mode 100644 index 0000000..e85bc00 --- /dev/null +++ b/crates/synth-core/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "synth-core" +version = "0.1.0" +edition = "2021" +description = "no_std DSP/audio components for an analogue-modelling synthesiser" +publish = false + +[features] +default = [] +std = [] +alloc = [] + +[dependencies] +libm = { workspace = true } +micromath = { workspace = true } +num-traits = { workspace = true } +heapless = { workspace = true } +midi-types = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] +# approx = "0.5" diff --git a/crates/synth-core/src/config.rs b/crates/synth-core/src/config.rs new file mode 100644 index 0000000..5f87011 --- /dev/null +++ b/crates/synth-core/src/config.rs @@ -0,0 +1,14 @@ +//! Sample rate and shared audio configuration. + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SampleRate(pub f32); + +impl SampleRate { + #[inline] + pub fn period(self) -> f32 { + 1.0 / self.0 + } +} + +pub const SR_44100: SampleRate = SampleRate(44_100.0); +pub const SR_48000: SampleRate = SampleRate(48_000.0); diff --git a/crates/synth-core/src/envelope.rs b/crates/synth-core/src/envelope.rs new file mode 100644 index 0000000..d6bee5c --- /dev/null +++ b/crates/synth-core/src/envelope.rs @@ -0,0 +1,66 @@ +//! ADSR envelope generator. + +use crate::{AudioProcessor, config::SampleRate}; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Stage { Idle, Attack, Decay, Sustain, Release } + +pub struct Adsr { + pub attack_s: f32, + pub decay_s: f32, + pub sustain: f32, // 0.0–1.0 + pub release_s: f32, + sample_rate: SampleRate, + stage: Stage, + level: f32, +} + +impl Adsr { + pub fn new(sample_rate: SampleRate) -> Self { + Self { + attack_s: 0.01, decay_s: 0.1, sustain: 0.7, release_s: 0.3, + sample_rate, + stage: Stage::Idle, + level: 0.0, + } + } + + pub fn gate_on(&mut self) { self.stage = Stage::Attack; } + pub fn gate_off(&mut self) { self.stage = Stage::Release; } + pub fn is_idle(&self) -> bool { self.stage == Stage::Idle } + + #[inline] + fn next_sample(&mut self) -> f32 { + let dt = self.sample_rate.period(); + match self.stage { + Stage::Idle => {}, + Stage::Attack => { + self.level += dt / self.attack_s.max(dt); + if self.level >= 1.0 { self.level = 1.0; self.stage = Stage::Decay; } + } + Stage::Decay => { + self.level -= dt / self.decay_s.max(dt) * (1.0 - self.sustain); + if self.level <= self.sustain { self.level = self.sustain; self.stage = Stage::Sustain; } + } + Stage::Sustain => { self.level = self.sustain; } + Stage::Release => { + self.level -= dt / self.release_s.max(dt) * self.level; + if self.level <= 0.0001 { self.level = 0.0; self.stage = Stage::Idle; } + } + } + self.level + } +} + +impl AudioProcessor for Adsr { + fn process(&mut self, out: &mut [f32; B]) { + for s in out.iter_mut() { + *s = self.next_sample(); + } + } + + fn reset(&mut self) { + self.stage = Stage::Idle; + self.level = 0.0; + } +} diff --git a/crates/synth-core/src/filter.rs b/crates/synth-core/src/filter.rs new file mode 100644 index 0000000..a4aaf2e --- /dev/null +++ b/crates/synth-core/src/filter.rs @@ -0,0 +1,76 @@ +//! Analogue-modelling filters. +//! +//! - `MoogLadder` — 4-pole 24 dB/oct low-pass, Huovilainen model +//! - `Svf` — State-variable filter (LP / HP / BP / Notch) + +use crate::{AudioProcessor, CVProcessor, config::SampleRate}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum FilterMode { + LowPass, + HighPass, + BandPass, + Notch, +} + +// ── State-variable filter ───────────────────────────────────────────────────── + +pub struct Svf { + pub cutoff_hz: f32, + pub resonance: f32, // 0.0–1.0 (1.0 = self-oscillation) + pub mode: FilterMode, + sample_rate: SampleRate, + // state + low: f32, + band: f32, +} + +impl Svf { + pub fn new(sample_rate: SampleRate, cutoff_hz: f32, resonance: f32, mode: FilterMode) -> Self { + Self { cutoff_hz, resonance, mode, sample_rate, low: 0.0, band: 0.0 } + } + + #[inline] + fn process_sample(&mut self, input: f32) -> f32 { + let f = 2.0 * libm::sinf( + core::f32::consts::PI * self.cutoff_hz * self.sample_rate.period() + ); + let q = 1.0 - self.resonance; + + let low = self.low + f * self.band; + let high = input - low - q * self.band; + let band = f * high + self.band; + let notch = high + low; + + self.low = low; + self.band = band; + + match self.mode { + FilterMode::LowPass => low, + FilterMode::HighPass => high, + FilterMode::BandPass => band, + FilterMode::Notch => notch, + } + } +} + +impl AudioProcessor for Svf { + fn process(&mut self, out: &mut [f32; B]) { + // in-place filter (caller pre-fills out with the audio signal) + for s in out.iter_mut() { + *s = self.process_sample(*s); + } + } + + fn reset(&mut self) { + self.low = 0.0; + self.band = 0.0; + } +} + +impl CVProcessor for Svf { + /// CV modulates cutoff: 0 V = base cutoff, +1 V = 1 octave up. + fn set_cv(&mut self, cv: f32) { + self.cutoff_hz *= libm::powf(2.0, cv); + } +} diff --git a/crates/synth-core/src/lfo.rs b/crates/synth-core/src/lfo.rs new file mode 100644 index 0000000..d076a79 --- /dev/null +++ b/crates/synth-core/src/lfo.rs @@ -0,0 +1,48 @@ +//! Low-frequency oscillator (LFO). +//! +//! Shares the Waveform enum from the oscillator module but operates at +//! sub-audio rates (typically 0.01 Hz – 20 Hz) and outputs a CV signal +//! in the range –1.0 to +1.0. + +use crate::{AudioProcessor, config::SampleRate, oscillator::Waveform}; + +pub struct Lfo { + pub waveform: Waveform, + pub rate_hz: f32, + pub depth: f32, // 0.0–1.0 output scale + phase: f32, + sample_rate: SampleRate, +} + +impl Lfo { + pub fn new(sample_rate: SampleRate, rate_hz: f32, depth: f32, waveform: Waveform) -> Self { + Self { waveform, rate_hz, depth, phase: 0.0, sample_rate } + } + + #[inline] + fn next_sample(&mut self) -> f32 { + let p = self.phase; + let raw = match self.waveform { + Waveform::Sine => libm::sinf(p * core::f32::consts::TAU), + Waveform::Saw => 2.0 * p - 1.0, + Waveform::Square => if p < 0.5 { 1.0 } else { -1.0 }, + Waveform::Triangle => 4.0 * (p - libm::floorf(p + 0.5)).abs() - 1.0, + Waveform::Pulse(w) => if p < w { 1.0 } else { -1.0 }, + }; + let next = p + self.rate_hz * self.sample_rate.period(); + self.phase = next - libm::floorf(next); + raw * self.depth + } +} + +impl AudioProcessor for Lfo { + fn process(&mut self, out: &mut [f32; B]) { + for s in out.iter_mut() { + *s = self.next_sample(); + } + } + + fn reset(&mut self) { + self.phase = 0.0; + } +} diff --git a/crates/synth-core/src/lib.rs b/crates/synth-core/src/lib.rs new file mode 100644 index 0000000..673096d --- /dev/null +++ b/crates/synth-core/src/lib.rs @@ -0,0 +1,63 @@ +//! synth-core — no_std DSP/audio components library. +//! +//! Compiles to any target that provides `core`: +//! - wasm32-unknown-unknown (synth-visualiser) +//! - thumbv7em-none-eabihf (Daisy Seed / STM32H750) +//! - native (tests, desktop host) +//! +//! Feature flags +//! ------------- +//! `std` — enables std (panicking, I/O helpers) +//! `alloc` — enables heap-dependent paths + +#![no_std] + +extern crate libm; + +#[cfg(feature = "alloc")] +extern crate alloc; + +pub mod config; +pub mod math; +pub mod oscillator; +pub mod filter; +pub mod envelope; +pub mod vca; +pub mod lfo; +pub mod midi; +pub mod patch; + +pub use config::SampleRate; +pub use math::{db_to_linear, linear_to_db, lerp, midi_note_to_hz}; + +/// Every audio-processing module implements this trait. +/// +/// `BLOCK` is the number of samples per render quantum — const generic so the +/// compiler can unroll loops and callers need no heap allocation. +pub trait AudioProcessor { + fn process(&mut self, out: &mut [f32; BLOCK]); + fn reset(&mut self); +} + +/// A module that accepts a control-voltage input alongside audio. +pub trait CVProcessor: AudioProcessor { + fn set_cv(&mut self, cv: f32); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn midi_a4_is_440hz() { + let hz = midi_note_to_hz(69); + assert!((hz - 440.0).abs() < 0.5, "got {hz}"); + } + + #[test] + fn db_round_trip() { + let lin = db_to_linear(-6.0); + let back = linear_to_db(lin); + assert!((back - -6.0_f32).abs() < 0.01, "got {back}"); + } +} diff --git a/crates/synth-core/src/math.rs b/crates/synth-core/src/math.rs new file mode 100644 index 0000000..639a0ab --- /dev/null +++ b/crates/synth-core/src/math.rs @@ -0,0 +1,22 @@ +//! DSP utility functions. + +#[inline] +pub fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + t * (b - a) +} + +#[inline] +pub fn db_to_linear(db: f32) -> f32 { + libm::powf(10.0, db / 20.0) +} + +#[inline] +pub fn linear_to_db(lin: f32) -> f32 { + 20.0 * libm::log10f(lin) +} + +/// Convert a MIDI note number to frequency in Hz (A4 = 69 = 440 Hz). +#[inline] +pub fn midi_note_to_hz(note: u8) -> f32 { + 440.0 * libm::powf(2.0, (note as f32 - 69.0) / 12.0) +} diff --git a/crates/synth-core/src/midi.rs b/crates/synth-core/src/midi.rs new file mode 100644 index 0000000..e6e6504 --- /dev/null +++ b/crates/synth-core/src/midi.rs @@ -0,0 +1,103 @@ +//! MIDI byte-stream parser. +//! +//! Parses a raw MIDI byte stream (UART or Web MIDI) into typed events. +//! No allocation; internal state is a small fixed-size buffer. + +use heapless::Vec; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum MidiEvent { + NoteOn { channel: u8, note: u8, velocity: u8 }, + NoteOff { channel: u8, note: u8, velocity: u8 }, + ControlChange { channel: u8, controller: u8, value: u8 }, + PitchBend { channel: u8, value: i16 }, // –8192 to +8191 + ProgramChange { channel: u8, program: u8 }, + Clock, + Start, + Stop, + Continue, +} + +/// Running-status parser for a single MIDI stream. +pub struct MidiParser { + status: u8, + buf: Vec, +} + +impl MidiParser { + pub fn new() -> Self { + Self { status: 0, buf: Vec::new() } + } + + /// Feed one byte; returns `Some(event)` when a complete message is parsed. + pub fn push_byte(&mut self, byte: u8) -> Option { + // System real-time messages are single-byte and can appear anywhere. + match byte { + 0xF8 => return Some(MidiEvent::Clock), + 0xFA => return Some(MidiEvent::Start), + 0xFB => return Some(MidiEvent::Continue), + 0xFC => return Some(MidiEvent::Stop), + _ => {} + } + + if byte & 0x80 != 0 { + // Status byte — start fresh. + self.buf.clear(); + self.status = byte; + // Single-byte status messages have no data bytes. + return None; + } + + // Data byte. + let _ = self.buf.push(byte); + let status = self.status; + let kind = (status >> 4) & 0x0F; + let ch = status & 0x0F; + + match (kind, self.buf.len()) { + (0x8, 2) => { + let ev = MidiEvent::NoteOff { channel: ch, note: self.buf[0], velocity: self.buf[1] }; + self.buf.clear(); + Some(ev) + } + (0x9, 2) => { + let vel = self.buf[1]; + let ev = if vel == 0 { + MidiEvent::NoteOff { channel: ch, note: self.buf[0], velocity: 0 } + } else { + MidiEvent::NoteOn { channel: ch, note: self.buf[0], velocity: vel } + }; + self.buf.clear(); + Some(ev) + } + (0xB, 2) => { + let ev = MidiEvent::ControlChange { channel: ch, controller: self.buf[0], value: self.buf[1] }; + self.buf.clear(); + Some(ev) + } + (0xC, 1) => { + let ev = MidiEvent::ProgramChange { channel: ch, program: self.buf[0] }; + self.buf.clear(); + Some(ev) + } + (0xE, 2) => { + let lsb = self.buf[0] as i16; + let msb = self.buf[1] as i16; + let value = ((msb << 7) | lsb) - 8192; + let ev = MidiEvent::PitchBend { channel: ch, value }; + self.buf.clear(); + Some(ev) + } + _ => None, + } + } + + pub fn reset(&mut self) { + self.status = 0; + self.buf.clear(); + } +} + +impl Default for MidiParser { + fn default() -> Self { Self::new() } +} diff --git a/crates/synth-core/src/oscillator.rs b/crates/synth-core/src/oscillator.rs new file mode 100644 index 0000000..2a6a14a --- /dev/null +++ b/crates/synth-core/src/oscillator.rs @@ -0,0 +1,62 @@ +//! Voltage-controlled oscillator (VCO). +//! +//! Waveforms: Sine, Saw, Square, Triangle, Pulse (variable width). +//! Uses phase accumulation; bandlimited variants (BLEP/BLAMP) to follow. + +use crate::{AudioProcessor, CVProcessor, config::SampleRate}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Waveform { + Sine, + Saw, + Square, + Triangle, + Pulse(f32), // pulse width 0.0–1.0 +} + +pub struct Vco { + pub waveform: Waveform, + pub freq_hz: f32, + phase: f32, + sample_rate: SampleRate, +} + +impl Vco { + pub fn new(sample_rate: SampleRate, freq_hz: f32, waveform: Waveform) -> Self { + Self { waveform, freq_hz, phase: 0.0, sample_rate } + } + + #[inline] + fn next_sample(&mut self) -> f32 { + let p = self.phase; + let sample = match self.waveform { + Waveform::Sine => libm::sinf(p * core::f32::consts::TAU), + Waveform::Saw => 2.0 * p - 1.0, + Waveform::Square => if p < 0.5 { 1.0 } else { -1.0 }, + Waveform::Triangle => 4.0 * (p - libm::floorf(p + 0.5)).abs() - 1.0, + Waveform::Pulse(w) => if p < w { 1.0 } else { -1.0 }, + }; + let next = p + self.freq_hz * self.sample_rate.period(); + self.phase = next - libm::floorf(next); + sample + } +} + +impl AudioProcessor for Vco { + fn process(&mut self, out: &mut [f32; B]) { + for s in out.iter_mut() { + *s = self.next_sample(); + } + } + + fn reset(&mut self) { + self.phase = 0.0; + } +} + +impl CVProcessor for Vco { + /// CV is 1 V/oct: 0 V = 440 Hz, +1 V = 880 Hz, −1 V = 220 Hz. + fn set_cv(&mut self, cv: f32) { + self.freq_hz = 440.0 * libm::powf(2.0, cv); + } +} diff --git a/crates/synth-core/src/patch.rs b/crates/synth-core/src/patch.rs new file mode 100644 index 0000000..7748d34 --- /dev/null +++ b/crates/synth-core/src/patch.rs @@ -0,0 +1,40 @@ +//! Signal routing — connects module outputs to inputs. +//! +//! A `Patch` is a fixed-size connection graph. Each cable routes +//! the output buffer of one slot into the CV or audio input of another. +//! Const generics keep everything on the stack. + +/// A single cable: from slot `src` output to slot `dst` input. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Cable { + pub src: usize, + pub dst: usize, +} + +/// A fixed-capacity patch with up to `MAX_CABLES` connections. +pub struct Patch { + cables: heapless::Vec, +} + +impl Patch { + pub fn new() -> Self { + Self { cables: heapless::Vec::new() } + } + + /// Add a cable; returns `Err(cable)` if the patch is full. + pub fn connect(&mut self, src: usize, dst: usize) -> Result<(), Cable> { + self.cables.push(Cable { src, dst }).map_err(|_| Cable { src, dst }) + } + + pub fn disconnect(&mut self, src: usize, dst: usize) { + self.cables.retain(|c| !(c.src == src && c.dst == dst)); + } + + pub fn cables(&self) -> &[Cable] { + &self.cables + } +} + +impl Default for Patch { + fn default() -> Self { Self::new() } +} diff --git a/crates/synth-core/src/vca.rs b/crates/synth-core/src/vca.rs new file mode 100644 index 0000000..3c93b81 --- /dev/null +++ b/crates/synth-core/src/vca.rs @@ -0,0 +1,42 @@ +//! Voltage-controlled amplifier (VCA). + +use crate::{AudioProcessor, CVProcessor}; + +pub struct Vca { + pub gain: f32, // 0.0–1.0 +} + +impl Vca { + pub fn new(gain: f32) -> Self { + Self { gain } + } +} + +impl AudioProcessor for Vca { + /// Scales the signal already in `out` by the current gain. + fn process(&mut self, out: &mut [f32; B]) { + for s in out.iter_mut() { + *s *= self.gain; + } + } + + fn reset(&mut self) { + self.gain = 0.0; + } +} + +impl CVProcessor for Vca { + fn set_cv(&mut self, cv: f32) { + self.gain = cv.clamp(0.0, 1.0); + } +} + +// Allow the ADSR output to drive the VCA directly. +impl Vca { + /// Apply a per-sample gain envelope to `audio`, returning the result in-place. + pub fn apply_envelope(&self, audio: &mut [f32; B], envelope: &[f32; B]) { + for (s, &e) in audio.iter_mut().zip(envelope.iter()) { + *s *= e * self.gain; + } + } +} diff --git a/crates/synth-embedded/.cargo/config.toml b/crates/synth-embedded/.cargo/config.toml new file mode 100644 index 0000000..15ef370 --- /dev/null +++ b/crates/synth-embedded/.cargo/config.toml @@ -0,0 +1,12 @@ +[build] +target = "thumbv8m.main-none-eabihf" # Cortex-M33 (RP2350) + +[target.thumbv8m.main-none-eabihf] +rustflags = [ + "-C", "link-arg=-Tdefmt.x", # defmt-rtt section placement + "-C", "link-arg=-Tlink.x", # cortex-m-rt startup/vector table (includes memory.x) +] +runner = "probe-rs run --chip RP2350" + +[env] +DEFMT_LOG = "debug" diff --git a/crates/synth-embedded/Cargo.toml b/crates/synth-embedded/Cargo.toml new file mode 100644 index 0000000..a6cd7f2 --- /dev/null +++ b/crates/synth-embedded/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "synth-embedded" +version = "0.1.0" +edition = "2021" +description = "Embassy/RP2350 audio engine — I2S out, MIDI in, synth-core DSP" +publish = false + +[[bin]] +name = "synth-embedded" +path = "src/main.rs" + +[dependencies] +synth-core = { path = "../synth-core" } + +embassy-executor = { workspace = true, features = [ + "platform-cortex-m", + "executor-thread", + "defmt", +] } +embassy-rp = { workspace = true, features = [ + "rp235xa", # Pico 2 (30 GPIOs); use rp235xb for the 48-pin variant + "rt", # enables rp-pac/rt → generates device.x interrupt vectors + "time-driver", + "defmt", + "unstable-pac", +] } +embassy-sync = { workspace = true, features = ["defmt"] } +embassy-time = { workspace = true, features = ["defmt", "defmt-timestamp-uptime"] } +embassy-futures = { workspace = true } + +cortex-m = { workspace = true, features = ["inline-asm", "critical-section-single-core"] } +cortex-m-rt = { workspace = true, features = ["device"] } +defmt = { workspace = true } +defmt-rtt = { workspace = true } +panic-probe = { workspace = true, features = ["print-defmt"] } + +fixed = { workspace = true } +heapless = { workspace = true } +libm = { workspace = true } +pio = { workspace = true } +# pio-proc = { workspace = true } + +# Profile overrides must live in the workspace root Cargo.toml, not here. +# See /Cargo.toml for release/dev profile settings. diff --git a/crates/synth-embedded/build.rs b/crates/synth-embedded/build.rs new file mode 100644 index 0000000..f7d247b --- /dev/null +++ b/crates/synth-embedded/build.rs @@ -0,0 +1,10 @@ +use std::{env, fs, path::PathBuf}; + +fn main() { + println!("cargo:rerun-if-changed=memory.x"); + + // Copy memory.x into OUT_DIR so the `-Tmemory.x` linker flag can find it. + let out = PathBuf::from(env::var("OUT_DIR").unwrap()); + fs::copy("memory.x", out.join("memory.x")).unwrap(); + println!("cargo:rustc-link-search={}", out.display()); +} diff --git a/crates/synth-embedded/memory.x b/crates/synth-embedded/memory.x new file mode 100644 index 0000000..c77657b --- /dev/null +++ b/crates/synth-embedded/memory.x @@ -0,0 +1,8 @@ +/* RP2350 (Pico 2): 2 MB XIP flash, 520 KB SRAM */ +MEMORY { + FLASH : ORIGIN = 0x10000000, LENGTH = 2M + RAM : ORIGIN = 0x20000000, LENGTH = 520K +} + +/* cortex-m-rt places the stack at the top of RAM. */ +_stack_start = ORIGIN(RAM) + LENGTH(RAM); diff --git a/crates/synth-embedded/src/audio.rs b/crates/synth-embedded/src/audio.rs new file mode 100644 index 0000000..8c2e181 --- /dev/null +++ b/crates/synth-embedded/src/audio.rs @@ -0,0 +1,165 @@ +//! I2S audio output via PIO + DMA, using the embassy-rp `PioI2sOut` driver. +//! +//! Targets a PCM5102A (or compatible) I2S DAC wired as: +//! GPIO 9 → BCK (bit clock) +//! GPIO 10 → LRCK (word select) +//! GPIO 11 → DATA +//! +//! # Buffer format +//! `PioI2sOut` expects a flat `&[u32]` of interleaved stereo words: +//! `[L0, R0, L1, R1, …]`. Each word holds a 24-bit signed sample +//! left-justified in bits 31..8, which is the format expected by PCM5102A in +//! 32-bit I2S mode. +//! +//! # Double-buffering +//! Two static DMA buffers (A and B) alternate: +//! 1. DMA drains buffer A → PIO TX FIFO. +//! 2. CPU renders the next block into buffer B. +//! 3. Swap and repeat. +//! +//! At 48 kHz with 256 frames per block (5.33 ms/block) the Cortex-M33 at +//! 150 MHz has ~10× headroom for the DSP render. + +use embassy_executor::task; +use embassy_rp::{ + peripherals::{DMA_CH0, PIN_9, PIN_10, PIN_11, PIO0}, + pio::Pio, + pio_programs::i2s::{PioI2sOut, PioI2sOutProgram}, + Peri, +}; +use synth_core::{ + config::SR_48000, + envelope::Adsr, + filter::{FilterMode, Svf}, + math::midi_note_to_hz, + oscillator::Vco, + vca::Vca, + AudioProcessor, +}; + +use crate::{Irqs, PARAMS}; + +/// Stereo frames per render block (one frame = left sample + right sample). +const BLOCK_FRAMES: usize = 256; +/// DMA buffer length in u32 words (two words per frame: L and R). +const BLOCK_WORDS: usize = BLOCK_FRAMES * 2; +const SAMPLE_RATE: u32 = 48_000; +const BIT_DEPTH: u32 = 24; + +// Ping-pong DMA buffers. Placed in .bss (zero-initialised RAM) at link time. +// SAFETY: only `audio_task` ever accesses these after `main` has run. +static mut DMA_BUF_A: [u32; BLOCK_WORDS] = [0; BLOCK_WORDS]; +static mut DMA_BUF_B: [u32; BLOCK_WORDS] = [0; BLOCK_WORDS]; + +/// Convert a synth-core f32 sample (−1.0 to +1.0) to a 24-bit signed integer +/// left-justified in a u32, as expected by PCM5102A in 32-bit I2S mode. +#[inline] +fn f32_to_i2s(s: f32) -> u32 { + let clamped = if s > 1.0 { 1.0 } else if s < -1.0 { -1.0 } else { s }; + let i24 = (clamped * 8_388_607.0) as i32; + // Left-justify: move to bits 31..8 + (i24 << 8) as u32 +} + +/// Render one `BLOCK_FRAMES`-frame stereo block into `buf` using synth-core DSP. +fn render( + buf: &mut [u32; BLOCK_WORDS], + vco: &mut Vco, + adsr: &mut Adsr, + filt: &mut Svf, + vca: &mut Vca, + snap: &crate::params::SynthParams, +) { + let hz = midi_note_to_hz(snap.note) * libm::powf(2.0_f32, snap.pitch_bend / 12.0); + vco.freq_hz = hz; + vco.waveform = snap.waveform; + filt.cutoff_hz = snap.cutoff_hz; + filt.resonance = snap.resonance; + vca.gain = snap.volume; + + if snap.gate { + adsr.gate_on(); + } else { + adsr.gate_off(); + } + + let mut audio: [f32; BLOCK_FRAMES] = [0.0; BLOCK_FRAMES]; + let mut env: [f32; BLOCK_FRAMES] = [0.0; BLOCK_FRAMES]; + + vco.process(&mut audio); + adsr.process(&mut env); + filt.process(&mut audio); + vca.apply_envelope(&mut audio, &env); + + for (i, &s) in audio.iter().enumerate() { + let word = f32_to_i2s(s); + buf[i * 2] = word; // Left + buf[i * 2 + 1] = word; // Right (mono → stereo) + } +} + +#[task] +pub async fn audio_task( + pio0: Peri<'static, PIO0>, + bck: Peri<'static, PIN_9>, + lrck: Peri<'static, PIN_10>, + data: Peri<'static, PIN_11>, + dma: Peri<'static, DMA_CH0>, +) { + let Pio { mut common, sm0, .. } = Pio::new(pio0, Irqs); + + let program = PioI2sOutProgram::new(&mut common); + let mut i2s = PioI2sOut::new( + &mut common, + sm0, + dma, + Irqs, + data, + bck, + lrck, + SAMPLE_RATE, + BIT_DEPTH, + &program, + ); + i2s.start(); + + defmt::info!("I2S PIO running: {}Hz, {}-bit", SAMPLE_RATE, BIT_DEPTH); + + // Initialise DSP modules. + let init = { PARAMS.lock().await.clone() }; + let sr = SR_48000; + + let mut vco = Vco::new(sr, midi_note_to_hz(init.note), init.waveform); + let mut adsr = Adsr::new(sr); + let mut filt = Svf::new(sr, init.cutoff_hz, init.resonance, FilterMode::LowPass); + let mut vca = Vca::new(init.volume); + + // SAFETY: we are the sole owner of these statics from this point on. + let buf_a = unsafe { &mut *core::ptr::addr_of_mut!(DMA_BUF_A) }; + let buf_b = unsafe { &mut *core::ptr::addr_of_mut!(DMA_BUF_B) }; + + // Pre-fill both buffers before starting the DMA loop. + { + let snap = PARAMS.lock().await.clone(); + render(buf_a, &mut vco, &mut adsr, &mut filt, &mut vca, &snap); + render(buf_b, &mut vco, &mut adsr, &mut filt, &mut vca, &snap); + } + + // Double-buffered audio loop: + // 1. Send the active buffer via DMA. + // 2. Render the next block into the inactive buffer. + // 3. Await DMA completion and swap roles. + loop { + i2s.write(buf_a).await; + { + let snap = PARAMS.lock().await.clone(); + render(buf_b, &mut vco, &mut adsr, &mut filt, &mut vca, &snap); + } + + i2s.write(buf_b).await; + { + let snap = PARAMS.lock().await.clone(); + render(buf_a, &mut vco, &mut adsr, &mut filt, &mut vca, &snap); + } + } +} diff --git a/crates/synth-embedded/src/main.rs b/crates/synth-embedded/src/main.rs new file mode 100644 index 0000000..a69d2e4 --- /dev/null +++ b/crates/synth-embedded/src/main.rs @@ -0,0 +1,66 @@ +//! synth-embedded — RP2350 / Embassy audio engine entry point. +//! +//! Hardware assumed (adjust pin numbers in spawner calls to match your wiring): +//! GPIO 9 → I2S BCK (bit clock) +//! GPIO 10 → I2S LRCK (word select / left-right clock) +//! GPIO 11 → I2S DATA +//! GPIO 1 → UART0 RX (MIDI in, 31250 baud) +//! +//! Build: +//! cargo build --release +//! +//! Flash (requires probe-rs and a debug probe): +//! cargo run --release + +#![no_std] +#![no_main] + +use defmt_rtt as _; +use panic_probe as _; + +use embassy_executor::Spawner; +use embassy_rp::{ + bind_interrupts, dma, + peripherals::{DMA_CH0, DMA_CH1, PIO0, UART0}, + pio::InterruptHandler as PioIrqHandler, + uart::InterruptHandler as UartIrqHandler, +}; +use embassy_sync::{blocking_mutex::raw::ThreadModeRawMutex, mutex::Mutex}; + +mod audio; +mod midi; +mod params; + +use params::SynthParams; + +/// Shared synthesiser state — written by the MIDI task, read by the audio task. +/// +/// `ThreadModeRawMutex` is appropriate here: both tasks run on core 0 and +/// the mutex is never accessed from an interrupt handler. +pub static PARAMS: Mutex = Mutex::new(SynthParams::new()); + +// Register Embassy interrupt handlers for the peripherals we use. +bind_interrupts!(struct Irqs { + PIO0_IRQ_0 => PioIrqHandler; + UART0_IRQ => UartIrqHandler; + DMA_IRQ_0 => dma::InterruptHandler, dma::InterruptHandler; +}); + +#[embassy_executor::main] +async fn main(spawner: Spawner) { + let p = embassy_rp::init(Default::default()); + + defmt::info!("synth-embedded starting on RP2350"); + + // Audio output via PIO0 + DMA (I2S to PCM5102A or compatible DAC). + spawner.spawn(audio::audio_task( + p.PIO0, + p.PIN_9, // BCK + p.PIN_10, // LRCK + p.PIN_11, // DATA + p.DMA_CH0, + ).unwrap()); + + // MIDI input via UART0 RX. + spawner.spawn(midi::midi_task(p.UART0, p.PIN_1, p.DMA_CH1).unwrap()); +} diff --git a/crates/synth-embedded/src/midi.rs b/crates/synth-embedded/src/midi.rs new file mode 100644 index 0000000..ce1f60d --- /dev/null +++ b/crates/synth-embedded/src/midi.rs @@ -0,0 +1,112 @@ +//! UART MIDI input task. +//! +//! Reads bytes at 31250 baud, feeds them to synth-core's `MidiParser`, then +//! updates the shared `PARAMS` mutex with each decoded event. + +use embassy_executor::task; +use embassy_rp::{ + peripherals::{DMA_CH1, PIN_1, UART0}, + uart::{Config as UartConfig, DataBits, Parity, StopBits, UartRx}, + Peri, +}; +use synth_core::{ + midi::{MidiEvent, MidiParser}, + oscillator::Waveform, +}; + +use crate::{Irqs, PARAMS}; + +#[task] +pub async fn midi_task( + uart0: Peri<'static, UART0>, + rx: Peri<'static, PIN_1>, + rx_dma: Peri<'static, DMA_CH1>, +) { + let mut cfg = UartConfig::default(); + cfg.baudrate = 31_250; + cfg.data_bits = DataBits::DataBits8; + cfg.parity = Parity::ParityNone; + cfg.stop_bits = StopBits::STOP1; + + let mut uart = UartRx::new(uart0, rx, Irqs, rx_dma, cfg); + + let mut parser = MidiParser::new(); + let mut byte = [0u8; 1]; + + defmt::info!("MIDI task running on UART0 RX (GPIO1)"); + + loop { + match uart.read(&mut byte).await { + Ok(()) => { + if let Some(event) = parser.push_byte(byte[0]) { + handle_event(event).await; + } + } + Err(_) => { + // Framing errors happen on cable connect/disconnect. + // Log and re-sync: the parser's running-status model + // will recover on the next status byte. + defmt::warn!("UART framing error — resync"); + parser.reset(); + } + } + } +} + +async fn handle_event(event: MidiEvent) { + let mut p = PARAMS.lock().await; + + match event { + MidiEvent::NoteOn { note, velocity, .. } => { + defmt::debug!("NoteOn note={} vel={}", note, velocity); + p.note = note; + p.velocity = velocity; + p.gate = true; + } + + MidiEvent::NoteOff { note, .. } => { + defmt::debug!("NoteOff note={}", note); + if p.note == note { + p.gate = false; + p.velocity = 0; + } + } + + MidiEvent::ControlChange { controller, value, .. } => { + match controller { + // Standard CC map + 1 => p.resonance = value as f32 / 127.0, // mod wheel → resonance + 7 => p.volume = value as f32 / 127.0, // volume + 73 => p.attack_s = value as f32 / 127.0 * 4.0, // attack 0–4 s + 74 => p.cutoff_hz = cutoff_cc_to_hz(value), // filter cutoff + 75 => p.decay_s = value as f32 / 127.0 * 4.0, // decay 0–4 s + 72 => p.release_s = value as f32 / 127.0 * 8.0, // release 0–8 s + _ => {} + } + } + + MidiEvent::PitchBend { value, .. } => { + // −8192..+8191 → −2.0..+2.0 semitones + p.pitch_bend = value as f32 / 8192.0 * 2.0; + } + + MidiEvent::ProgramChange { program, .. } => { + p.waveform = match program % 5 { + 0 => Waveform::Sine, + 1 => Waveform::Saw, + 2 => Waveform::Square, + 3 => Waveform::Triangle, + _ => Waveform::Pulse(0.25), + }; + } + + // Ignore clock/transport + _ => {} + } +} + +/// Map CC value 0–127 to filter cutoff 80 Hz – 18 kHz on a linear scale. +#[inline] +fn cutoff_cc_to_hz(value: u8) -> f32 { + 80.0 + (value as f32 / 127.0) * (18_000.0 - 80.0) +} diff --git a/crates/synth-embedded/src/params.rs b/crates/synth-embedded/src/params.rs new file mode 100644 index 0000000..96de0c7 --- /dev/null +++ b/crates/synth-embedded/src/params.rs @@ -0,0 +1,58 @@ +//! Shared synthesiser parameters. +//! +//! `SynthParams` is a plain-data snapshot. The Embassy `Mutex` in `main.rs` +//! provides exclusive access. `new()` is `const` so the mutex can be placed in +//! a `static` without a runtime initialiser. + +use synth_core::{ + config::{SampleRate, SR_48000}, + oscillator::Waveform, +}; + +#[derive(Clone)] +pub struct SynthParams { + pub sample_rate: SampleRate, + + // Voice state (monophonic) + pub note: u8, // MIDI note number + pub velocity: u8, // 0 = silent + pub gate: bool, + + // Oscillator + pub waveform: Waveform, + pub pitch_bend: f32, // semitones, −2.0 to +2.0 + + // Filter (SVF) + pub cutoff_hz: f32, + pub resonance: f32, // 0.0–1.0 + + // Amp + pub volume: f32, // 0.0–1.0 + + // ADSR + pub attack_s: f32, + pub decay_s: f32, + pub sustain: f32, // 0.0–1.0 + pub release_s: f32, +} + +impl SynthParams { + /// `const` so this can initialise a `static Mutex`. + pub const fn new() -> Self { + Self { + sample_rate: SR_48000, + note: 69, // A4 + velocity: 0, + gate: false, + waveform: Waveform::Saw, + pitch_bend: 0.0, + cutoff_hz: 2000.0, + resonance: 0.2, + volume: 0.8, + attack_s: 0.01, + decay_s: 0.1, + sustain: 0.7, + release_s: 0.3, + } + } +} diff --git a/crates/synth-visualiser/Cargo.toml b/crates/synth-visualiser/Cargo.toml new file mode 100644 index 0000000..bfdf112 --- /dev/null +++ b/crates/synth-visualiser/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "synth-visualiser" +version = "0.1.0" +edition = "2021" +description = "Browser-based visualiser for the synthesiser (WASM)" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["console-panic"] +console-panic = ["dep:console_error_panic_hook"] + +[dependencies] +synth-core = { path = "../synth-core" } +wasm-bindgen = { workspace = true } +wasm-bindgen-futures = { workspace = true } +js-sys = { workspace = true } +serde = { workspace = true } +console_error_panic_hook = { workspace = true, optional = true } + +[dependencies.web-sys] +version = "0.3" +features = [ + "Window", + "Document", + "Element", + "HtmlElement", + "HtmlCanvasElement", + "CanvasRenderingContext2d", + "AudioContext", + "AudioContextOptions", + "AnalyserNode", + "GainNode", + "OscillatorNode", + "OscillatorType", + "AudioWorkletNode", + "AudioWorkletNodeOptions", + "AudioBuffer", + "AudioBufferSourceNode", + "Event", + "EventTarget", + "MouseEvent", + "PointerEvent", + "WheelEvent", + "Performance", + "Worker", + "MessageEvent", + "AudioNode", + "AudioDestinationNode", +] + +[dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/crates/synth-visualiser/src/engine.rs b/crates/synth-visualiser/src/engine.rs new file mode 100644 index 0000000..1333465 --- /dev/null +++ b/crates/synth-visualiser/src/engine.rs @@ -0,0 +1,53 @@ +//! Audio engine — owns the WebAudio AudioContext and AnalyserNode. + +use wasm_bindgen::prelude::*; +use web_sys::{ AudioContext, AudioContextOptions, AnalyserNode, GainNode }; + +#[wasm_bindgen] +pub struct AudioEngine { + ctx: AudioContext, + analyser: AnalyserNode, + gain: GainNode, +} + +#[wasm_bindgen] +impl AudioEngine { + #[wasm_bindgen(constructor)] + pub fn new() -> Result { + let opts = AudioContextOptions::new(); + opts.set_sample_rate(44100.0); + + let ctx = AudioContext::new_with_context_options(&opts)?; + let analyser = ctx.create_analyser()?; + let gain = ctx.create_gain()?; + + analyser.set_fft_size(2048); + analyser.set_smoothing_time_constant(0.8); + + gain.connect_with_audio_node(&analyser)?; + analyser.connect_with_audio_node(&ctx.destination())?; + + Ok(AudioEngine { ctx, analyser, gain }) + } + + pub fn attach(&self) -> Result<(), JsValue> { + Ok(()) + } + + pub fn start(&self) {} + pub fn stop(&self) {} + + pub fn sample_rate(&self) -> f32 { + self.ctx.sample_rate() + } + + /// Returns a JS handle to the AnalyserNode for use by the visualiser views. + pub fn analyser_node(&self) -> AnalyserNode { + self.analyser.clone() + } + + pub fn set_params(&self, _json: &str) -> Result<(), JsValue> { + // TODO: parse JSON and post to AudioWorkletNode MessagePort + Ok(()) + } +} diff --git a/crates/synth-visualiser/src/lib.rs b/crates/synth-visualiser/src/lib.rs new file mode 100644 index 0000000..f7a25da --- /dev/null +++ b/crates/synth-visualiser/src/lib.rs @@ -0,0 +1,32 @@ +//! synth-visualiser — WASM browser front-end. +//! +//! Build with wasm-pack: +//! wasm-pack build crates/synth-visualiser --target web --out-dir ../../www/pkg + +use wasm_bindgen::prelude::*; + +#[cfg(feature = "console-panic")] +fn set_panic_hook() { + console_error_panic_hook::set_once(); +} +#[cfg(not(feature = "console-panic"))] +fn set_panic_hook() {} + +pub mod engine; +pub mod oscilloscope; +pub mod spectrum; +pub mod patchbay; +pub mod params; + +pub use engine::AudioEngine; +pub use oscilloscope::OscilloscopeView; +pub use spectrum::SpectrumView; +pub use patchbay::PatchBay; +pub use params::SynthParams; + +/// Called once by bootstrap.js after the WASM module loads. +#[wasm_bindgen(start)] +pub fn main() -> Result<(), JsValue> { + set_panic_hook(); + Ok(()) +} diff --git a/crates/synth-visualiser/src/oscilloscope.rs b/crates/synth-visualiser/src/oscilloscope.rs new file mode 100644 index 0000000..00827e8 --- /dev/null +++ b/crates/synth-visualiser/src/oscilloscope.rs @@ -0,0 +1,59 @@ +//! Canvas-based oscilloscope — draws time-domain waveform from AnalyserNode. + +use wasm_bindgen::prelude::*; +use web_sys::{AnalyserNode, HtmlCanvasElement, CanvasRenderingContext2d}; + +#[wasm_bindgen] +pub struct OscilloscopeView { + canvas: HtmlCanvasElement, + ctx2d: CanvasRenderingContext2d, + analyser: AnalyserNode, + buf: Vec, +} + +#[wasm_bindgen] +impl OscilloscopeView { + #[wasm_bindgen(constructor)] + pub fn new(canvas_id: &str, analyser: &AnalyserNode) -> Result { + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let canvas: HtmlCanvasElement = document + .get_element_by_id(canvas_id) + .ok_or_else(|| JsValue::from_str(&format!("canvas #{canvas_id} not found")))? + .dyn_into()?; + + let ctx2d: CanvasRenderingContext2d = canvas + .get_context("2d")? + .ok_or("no 2d context")? + .dyn_into()?; + + let len = analyser.fft_size() as usize; + let buf = vec![0.0f32; len]; + + Ok(OscilloscopeView { canvas, ctx2d, analyser: analyser.clone(), buf }) + } + + /// Draw one frame — call inside requestAnimationFrame. + pub fn draw(&mut self) { + let w = self.canvas.width() as f64; + let h = self.canvas.height() as f64; + + self.analyser.get_float_time_domain_data(&mut self.buf); + + let ctx = &self.ctx2d; + ctx.set_fill_style_str("#0d0d0d"); + ctx.fill_rect(0.0, 0.0, w, h); + + ctx.begin_path(); + ctx.set_stroke_style_str("#00e5ff"); + ctx.set_line_width(1.5); + + let step = w / self.buf.len() as f64; + for (i, &sample) in self.buf.iter().enumerate() { + let x = i as f64 * step; + let y = (1.0 - sample as f64) * 0.5 * h; + if i == 0 { ctx.move_to(x, y); } else { ctx.line_to(x, y); } + } + ctx.stroke(); + } +} diff --git a/crates/synth-visualiser/src/params.rs b/crates/synth-visualiser/src/params.rs new file mode 100644 index 0000000..a4b497f --- /dev/null +++ b/crates/synth-visualiser/src/params.rs @@ -0,0 +1,52 @@ +//! Synthesiser parameter model — mirrors synth-core state. +//! Serialised as JSON for postMessage() across the AudioWorklet MessagePort. + +use wasm_bindgen::prelude::*; +use serde::{Serialize, Deserialize}; + +#[wasm_bindgen] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SynthParams { + pub osc_freq: f32, // Hz + pub osc_wave: u8, // 0=Sine 1=Saw 2=Square 3=Triangle + pub filter_cutoff: f32, // Hz + pub filter_res: f32, // 0.0–1.0 + pub env_attack: f32, // seconds + pub env_decay: f32, + pub env_sustain: f32, // 0.0–1.0 + pub env_release: f32, + pub lfo_rate: f32, // Hz + pub lfo_depth: f32, // 0.0–1.0 + pub master_gain: f32, // 0.0–1.0 +} + +#[wasm_bindgen] +impl SynthParams { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { + osc_freq: 440.0, + osc_wave: 1, + filter_cutoff: 2000.0, + filter_res: 0.3, + env_attack: 0.01, + env_decay: 0.1, + env_sustain: 0.7, + env_release: 0.3, + lfo_rate: 2.0, + lfo_depth: 0.0, + master_gain: 0.8, + } + } + + pub fn to_json(&self) -> String { + // serde_json would pull in std; for WASM we use the js glue instead. + // This is a simple manual serialisation for the scaffold. + format!( + r#"{{"osc_freq":{:.2},"osc_wave":{},"filter_cutoff":{:.2},"filter_res":{:.3},"env_attack":{:.4},"env_decay":{:.4},"env_sustain":{:.3},"env_release":{:.4},"lfo_rate":{:.3},"lfo_depth":{:.3},"master_gain":{:.3}}}"#, + self.osc_freq, self.osc_wave, self.filter_cutoff, self.filter_res, + self.env_attack, self.env_decay, self.env_sustain, self.env_release, + self.lfo_rate, self.lfo_depth, self.master_gain + ) + } +} diff --git a/crates/synth-visualiser/src/patchbay.rs b/crates/synth-visualiser/src/patchbay.rs new file mode 100644 index 0000000..6b721ac --- /dev/null +++ b/crates/synth-visualiser/src/patchbay.rs @@ -0,0 +1,155 @@ +//! Patch bay — drag-and-drop cable routing between module jacks. + +use wasm_bindgen::prelude::*; +use web_sys::{HtmlCanvasElement, CanvasRenderingContext2d}; + +#[derive(Clone, Debug)] +struct Jack { + module_id: String, + jack_id: String, + x: f32, + y: f32, + is_output: bool, +} + +#[derive(Clone, Debug)] +struct Cable { + src: usize, // index into jacks + dst: usize, +} + +#[wasm_bindgen] +pub struct PatchBay { + canvas: HtmlCanvasElement, + ctx2d: CanvasRenderingContext2d, + jacks: Vec, + cables: Vec, + dragging_from: Option, // jack index being dragged from + drag_x: f32, + drag_y: f32, +} + +const JACK_RADIUS: f32 = 8.0; + +#[wasm_bindgen] +impl PatchBay { + #[wasm_bindgen(constructor)] + pub fn new(canvas_id: &str) -> Result { + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let canvas: HtmlCanvasElement = document + .get_element_by_id(canvas_id) + .ok_or_else(|| JsValue::from_str(&format!("canvas #{canvas_id} not found")))? + .dyn_into()?; + + let ctx2d: CanvasRenderingContext2d = canvas + .get_context("2d")? + .ok_or("no 2d context")? + .dyn_into()?; + + Ok(PatchBay { + canvas, ctx2d, + jacks: Vec::new(), + cables: Vec::new(), + dragging_from: None, + drag_x: 0.0, + drag_y: 0.0, + }) + } + + pub fn register_jack(&mut self, module_id: &str, jack_id: &str, x: f32, y: f32, is_output: bool) { + self.jacks.push(Jack { + module_id: module_id.to_string(), + jack_id: jack_id.to_string(), + x, y, is_output, + }); + } + + pub fn draw(&self) { + let w = self.canvas.width() as f64; + let h = self.canvas.height() as f64; + let ctx = &self.ctx2d; + + ctx.set_fill_style_str("#1a1a1a"); + ctx.fill_rect(0.0, 0.0, w, h); + + // Draw cables + ctx.set_stroke_style_str("#ffcc00"); + ctx.set_line_width(2.0); + for cable in &self.cables { + if cable.src < self.jacks.len() && cable.dst < self.jacks.len() { + let src = &self.jacks[cable.src]; + let dst = &self.jacks[cable.dst]; + ctx.begin_path(); + ctx.move_to(src.x as f64, src.y as f64); + // Catmull-Rom-ish curve + let mx = (src.x as f64 + dst.x as f64) / 2.0; + let my = ((src.y as f64 + dst.y as f64) / 2.0) + 40.0; + ctx.quadratic_curve_to(mx, my, dst.x as f64, dst.y as f64); + ctx.stroke(); + } + } + + // Draw in-progress drag cable + if let Some(src_idx) = self.dragging_from { + if src_idx < self.jacks.len() { + let src = &self.jacks[src_idx]; + ctx.set_stroke_style_str("rgba(255,204,0,0.5)"); + ctx.begin_path(); + ctx.move_to(src.x as f64, src.y as f64); + ctx.line_to(self.drag_x as f64, self.drag_y as f64); + ctx.stroke(); + } + } + + // Draw jacks + for jack in &self.jacks { + ctx.begin_path(); + ctx.arc(jack.x as f64, jack.y as f64, JACK_RADIUS as f64, 0.0, std::f64::consts::TAU) + .unwrap_or(()); + if jack.is_output { + ctx.set_fill_style_str("#00e5ff"); + } else { + ctx.set_fill_style_str("#ff4081"); + } + ctx.fill(); + ctx.set_stroke_style_str("#444"); + ctx.set_line_width(1.0); + ctx.stroke(); + } + } + + pub fn on_pointer_down(&mut self, x: f32, y: f32) { + self.dragging_from = self.hit_test(x, y); + self.drag_x = x; + self.drag_y = y; + } + + pub fn on_pointer_move(&mut self, x: f32, y: f32) { + self.drag_x = x; + self.drag_y = y; + } + + pub fn on_pointer_up(&mut self, x: f32, y: f32) { + if let Some(src_idx) = self.dragging_from.take() { + if let Some(dst_idx) = self.hit_test(x, y) { + let src_is_out = self.jacks[src_idx].is_output; + let dst_is_out = self.jacks[dst_idx].is_output; + // Only allow output → input connections + if src_is_out && !dst_is_out { + self.cables.push(Cable { src: src_idx, dst: dst_idx }); + } else if !src_is_out && dst_is_out { + self.cables.push(Cable { src: dst_idx, dst: src_idx }); + } + } + } + } + + fn hit_test(&self, x: f32, y: f32) -> Option { + self.jacks.iter().position(|j| { + let dx = j.x - x; + let dy = j.y - y; + (dx * dx + dy * dy).sqrt() <= JACK_RADIUS * 1.5 + }) + } +} diff --git a/crates/synth-visualiser/src/spectrum.rs b/crates/synth-visualiser/src/spectrum.rs new file mode 100644 index 0000000..76cfec6 --- /dev/null +++ b/crates/synth-visualiser/src/spectrum.rs @@ -0,0 +1,55 @@ +//! Canvas-based FFT spectrum analyser — draws frequency-domain data. + +use wasm_bindgen::prelude::*; +use web_sys::{AnalyserNode, HtmlCanvasElement, CanvasRenderingContext2d}; + +#[wasm_bindgen] +pub struct SpectrumView { + canvas: HtmlCanvasElement, + ctx2d: CanvasRenderingContext2d, + analyser: AnalyserNode, + buf: Vec, +} + +#[wasm_bindgen] +impl SpectrumView { + #[wasm_bindgen(constructor)] + pub fn new(canvas_id: &str, analyser: &AnalyserNode) -> Result { + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let canvas: HtmlCanvasElement = document + .get_element_by_id(canvas_id) + .ok_or_else(|| JsValue::from_str(&format!("canvas #{canvas_id} not found")))? + .dyn_into()?; + + let ctx2d: CanvasRenderingContext2d = canvas + .get_context("2d")? + .ok_or("no 2d context")? + .dyn_into()?; + + let len = analyser.frequency_bin_count() as usize; + let buf = vec![0u8; len]; + + Ok(SpectrumView { canvas, ctx2d, analyser: analyser.clone(), buf }) + } + + pub fn draw(&mut self) { + let w = self.canvas.width() as f64; + let h = self.canvas.height() as f64; + + self.analyser.get_byte_frequency_data(&mut self.buf); + + let ctx = &self.ctx2d; + ctx.set_fill_style_str("#0d0d0d"); + ctx.fill_rect(0.0, 0.0, w, h); + + let bar_w = w / self.buf.len() as f64; + for (i, &magnitude) in self.buf.iter().enumerate() { + let bar_h = (magnitude as f64 / 255.0) * h; + let x = i as f64 * bar_w; + let hue = 180.0 + (i as f64 / self.buf.len() as f64) * 100.0; + ctx.set_fill_style_str(&format!("hsl({hue:.0},100%,60%)")); + ctx.fill_rect(x, h - bar_h, bar_w.max(1.0), bar_h); + } + } +} diff --git a/www/bootstrap.js b/www/bootstrap.js new file mode 100644 index 0000000..6f2e811 --- /dev/null +++ b/www/bootstrap.js @@ -0,0 +1,72 @@ +/** + * bootstrap.js — ES module entry point. + * + * Imports the wasm-pack-generated glue, initialises the WASM binary, then + * wires up Rust-exported types to the canvas elements in index.html. + */ + +import init, { + AudioEngine, + OscilloscopeView, + SpectrumView, + PatchBay, + SynthParams, +} from "./pkg/synth_visualiser.js"; + +const loader = document.getElementById("loader"); +const status = document.getElementById("status"); +const srLabel = document.getElementById("sample-rate"); +const frameTime = document.getElementById("frame-time"); + +async function bootstrap() { + try { + await init(); + + const engine = new AudioEngine(); + await engine.attach(); + + const analyser = engine.analyser_node(); + const oscilloscope = new OscilloscopeView("oscilloscope-canvas", analyser); + const spectrum = new SpectrumView("spectrum-canvas", analyser); + const patchbay = new PatchBay("patchbay-canvas"); + + // Register module jacks (x, y coordinates relative to the canvas) + patchbay.register_jack("vco", "out", 50, 60, true); + patchbay.register_jack("filter", "in", 150, 60, false); + patchbay.register_jack("filter", "out", 250, 60, true); + patchbay.register_jack("vca", "in", 350, 60, false); + patchbay.register_jack("lfo", "cv-out", 450, 60, true); + patchbay.register_jack("filter", "cv-in", 550, 60, false); + + const pbCanvas = document.getElementById("patchbay-canvas"); + pbCanvas.addEventListener("pointerdown", e => patchbay.on_pointer_down(e.offsetX, e.offsetY)); + pbCanvas.addEventListener("pointermove", e => patchbay.on_pointer_move(e.offsetX, e.offsetY)); + pbCanvas.addEventListener("pointerup", e => patchbay.on_pointer_up(e.offsetX, e.offsetY)); + + const params = new SynthParams(); + engine.set_params(params.to_json()); + + srLabel.textContent = `SR: ${engine.sample_rate()} Hz`; + status.textContent = "Running"; + engine.start(); + + let last = performance.now(); + function frame(now) { + oscilloscope.draw(); + spectrum.draw(); + patchbay.draw(); + frameTime.textContent = `frame: ${(now - last).toFixed(1)} ms`; + last = now; + requestAnimationFrame(frame); + } + requestAnimationFrame(frame); + + loader.classList.add("hidden"); + + } catch (err) { + console.error("[bootstrap] Fatal:", err); + loader.textContent = `Error: ${err.message ?? err}`; + } +} + +bootstrap(); diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..38cae55 --- /dev/null +++ b/www/index.html @@ -0,0 +1,109 @@ + + + + + + Synth Visualiser + + + +
Loading WASM module…
+ +
Analogue Synth Visualiser
+ +
+
+
Oscilloscope
+ +
+
+
Spectrum
+ +
+
+
Patch Bay
+ +
+
+ +
+ Idle + + +
+ + + + +