diff --git a/daw-backend/C2.mp3 b/daw-backend/C2.mp3 new file mode 100644 index 0000000..3fca8d0 Binary files /dev/null and b/daw-backend/C2.mp3 differ diff --git a/daw-backend/Cargo.lock b/daw-backend/Cargo.lock new file mode 100644 index 0000000..5097d43 --- /dev/null +++ b/daw-backend/Cargo.lock @@ -0,0 +1,1171 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.9.4", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.9.4", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "daw-backend" +version = "0.1.0" +dependencies = [ + "cpal", + "rtrb", + "symphonia", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[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.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.9.4", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "thiserror", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rtrb" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8388ea1a9e0ea807e442e8263a699e7edcb320ecbcd21b4fa8ff859acce3ba" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[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 = "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 = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-caf", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-caf" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[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 = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +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 0.61.2", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[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.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[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 = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/daw-backend/Cargo.toml b/daw-backend/Cargo.toml new file mode 100644 index 0000000..dc83d9f --- /dev/null +++ b/daw-backend/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "daw-backend" +version = "0.1.0" +edition = "2021" + +[dependencies] +cpal = "0.15" +symphonia = { version = "0.5", features = ["all"] } +rtrb = "0.3" + +[dev-dependencies] + +[profile.release] +opt-level = 3 +lto = true + +[profile.dev] +opt-level = 1 # Faster compile times while still reasonable performance diff --git a/daw-backend/Fade.wav b/daw-backend/Fade.wav new file mode 100644 index 0000000..23475eb Binary files /dev/null and b/daw-backend/Fade.wav differ diff --git a/daw-backend/daw_architecture_doc.md b/daw-backend/daw_architecture_doc.md new file mode 100644 index 0000000..f921f12 --- /dev/null +++ b/daw-backend/daw_architecture_doc.md @@ -0,0 +1,1807 @@ +# DAW Backend Architecture & Implementation Roadmap + +**Version:** 1.0 +**Date:** October 2025 +**Language:** Rust +**Audio I/O:** cpal + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Core Components](#core-components) +3. [Metatracks Architecture](#metatracks-architecture) +4. [Implementation Roadmap](#implementation-roadmap) +5. [Technical Specifications](#technical-specifications) +6. [Testing Strategy](#testing-strategy) + +--- + +## Architecture Overview + +### High-Level Design + +The DAW follows a **multi-threaded, message-passing architecture** that separates real-time audio processing from UI and control logic: + +``` +┌─────────────┐ Commands ┌─────────────┐ Commands ┌─────────────┐ +│ UI Thread │ ←──────────────→ │Control Thread│ ←──────────────→ │ Audio Thread│ +└─────────────┘ Events └─────────────┘ Lock-free └─────────────┘ + │ Queues │ + ↓ │ + ┌─────────────┐ │ + │Project State│←────────────────────────┘ + │(Triple-buf) │ Atomic reads + └─────────────┘ +``` + +### Design Principles + +1. **Real-time Safety**: Audio thread is lock-free and allocation-free +2. **Hierarchical Composition**: Tracks can contain other tracks (metatracks) +3. **Message-Based Communication**: All cross-thread communication via lock-free queues +4. **Incremental Complexity**: Architecture supports simple flat tracks initially, scales to nested metatracks +5. **Separation of Concerns**: Audio processing, state management, and UI are decoupled + +--- + +## Core Components + +### 1. Audio Engine (Real-time Thread) + +**Responsibilities:** +- Process audio graph in response to cpal callbacks +- Execute commands from control thread +- Maintain playback position and transport state +- Send events back to control thread + +**Constraints:** +- No memory allocations +- No blocking operations (mutex, I/O) +- No unbounded loops +- Pre-allocated buffers only + +**Key Structures:** + +```rust +struct AudioEngine { + tracks: Vec, + playhead: u64, // Sample position + playing: bool, + sample_rate: u32, + + // Communication + command_rx: rtrb::Consumer, + event_tx: rtrb::Producer, + + // Pre-allocated resources + mix_buffer: Vec, + buffer_pool: BufferPool, +} + +enum Command { + Play, + Stop, + Seek(f64), + SetTempo(f32), + UpdateTrackVolume(TrackId, f32), + UpdateTrackMute(TrackId, bool), + AddEffect(TrackId, EffectType), + // ... more commands +} + +enum AudioEvent { + PlaybackPosition(f64), + PeakLevel(TrackId, f32), + BufferUnderrun, + // ... more events +} +``` + +### 2. Track Hierarchy + +**Track Node Types:** + +```rust +enum TrackNode { + Audio(AudioTrack), + Midi(MidiTrack), + Metatrack(Metatrack), + Bus(BusTrack), +} + +struct AudioTrack { + id: TrackId, + name: String, + clips: Vec, + effects: Vec>, + volume: f32, + pan: f32, + muted: bool, + solo: bool, + parent: Option, +} + +struct MidiTrack { + id: TrackId, + name: String, + clips: Vec, + instrument: Box, // Virtual instrument + effects: Vec>, + volume: f32, + pan: f32, + muted: bool, + solo: bool, + parent: Option, +} + +struct Metatrack { + id: TrackId, + name: String, + children: Vec, + effects: Vec>, + + // Metatrack-specific features + time_stretch: f32, // Speed multiplier (0.5 = half speed) + pitch_shift: f32, // Semitones + offset: f64, // Time offset in seconds + + volume: f32, + pan: f32, + muted: bool, + solo: bool, + parent: Option, + + // UI hints + collapsed: bool, + color: Color, +} + +struct BusTrack { + id: TrackId, + name: String, + inputs: Vec, // Which tracks send to this bus + effects: Vec>, + volume: f32, + pan: f32, +} +``` + +### 3. Clips and Regions + +```rust +struct Clip { + id: ClipId, + content: ClipContent, + start_time: f64, // Position in parent track (seconds) + duration: f64, // Clip duration (seconds) + offset: f64, // Offset into content (seconds) + + // Clip-level processing + gain: f32, + fade_in: f64, + fade_out: f64, + reversed: bool, +} + +enum ClipContent { + AudioFile { + pool_index: usize, // Index into AudioPool + }, + MidiData { + events: Vec, + }, + MetatrackReference { + track_id: TrackId, + }, +} + +struct MidiEvent { + timestamp: u64, // Sample offset within clip + status: u8, + data1: u8, + data2: u8, +} +``` + +### 4. Audio Pool + +Shared audio file storage: + +```rust +struct AudioPool { + files: Vec, + cache: LruCache>, +} + +struct AudioFile { + id: FileId, + path: PathBuf, + data: Vec, // Interleaved samples + channels: u32, + sample_rate: u32, + frames: u64, +} +``` + +### 5. Effect System + +```rust +trait Effect: Send { + fn process(&mut self, buffer: &mut [f32], channels: usize, sample_rate: u32); + fn set_parameter(&mut self, id: u32, value: f32); + fn get_parameter(&self, id: u32) -> f32; + fn reset(&mut self); +} + +// Example implementations +struct GainEffect { + gain_db: f32, +} + +struct SimpleEQ { + low_gain: f32, + mid_gain: f32, + high_gain: f32, + filters: [BiquadFilter; 3], +} + +struct SimpleSynth { + oscillators: Vec, + adsr: AdsrEnvelope, +} +``` + +### 6. Render Context + +Carries time and tempo information through the track hierarchy: + +```rust +struct RenderContext { + global_position: u64, // Absolute sample position + local_position: u64, // Position within current scope + sample_rate: u32, + tempo: f32, + time_signature: (u32, u32), + time_stretch: f32, // Accumulated stretch factor +} + +impl Metatrack { + fn transform_context(&self, ctx: RenderContext) -> RenderContext { + let offset_samples = (self.offset * ctx.sample_rate as f64) as u64; + let local_pos = ((ctx.local_position.saturating_sub(offset_samples)) as f64 + / self.time_stretch as f64) as u64; + + RenderContext { + global_position: ctx.global_position, + local_position: local_pos, + sample_rate: ctx.sample_rate, + tempo: ctx.tempo * self.time_stretch, + time_signature: ctx.time_signature, + time_stretch: ctx.time_stretch * self.time_stretch, + } + } +} +``` + +### 7. Project State + +```rust +struct Project { + tracks: HashMap, + root_tracks: Vec, + audio_pool: AudioPool, + + // Global settings + sample_rate: u32, + tempo: f32, + time_signature: (u32, u32), + + // Metadata + name: String, + created: SystemTime, + modified: SystemTime, +} + +impl Project { + fn get_processing_order(&self) -> Vec { + // Depth-first traversal for correct rendering order + let mut order = Vec::new(); + for root_id in &self.root_tracks { + self.collect_depth_first(*root_id, &mut order); + } + order + } + + fn collect_depth_first(&self, id: TrackId, order: &mut Vec) { + if let Some(TrackNode::Metatrack(meta)) = self.tracks.get(&id) { + for child_id in &meta.children { + self.collect_depth_first(*child_id, order); + } + } + order.push(id); + } +} +``` + +### 8. Buffer Management + +```rust +struct BufferPool { + buffers: Vec>, + available: Vec, + buffer_size: usize, +} + +impl BufferPool { + fn acquire(&mut self) -> Vec { + if let Some(idx) = self.available.pop() { + let mut buf = std::mem::take(&mut self.buffers[idx]); + buf.fill(0.0); + buf + } else { + vec![0.0; self.buffer_size] + } + } + + fn release(&mut self, buffer: Vec) { + let idx = self.buffers.len(); + self.buffers.push(buffer); + self.available.push(idx); + } +} +``` + +--- + +## Metatracks Architecture + +### Processing Model + +Metatracks use a **pre-mix** model: + +1. Mix all children into a temporary buffer +2. Apply metatrack's effects to the mixed buffer +3. Mix result into parent's output + +```rust +fn process_metatrack( + meta: &Metatrack, + project: &Project, + output: &mut [f32], + context: RenderContext, + buffer_pool: &mut BufferPool, +) { + // Transform context for children + let child_context = meta.transform_context(context); + + // Acquire scratch buffer + let mut submix = buffer_pool.acquire(); + submix.resize(output.len(), 0.0); + + // Process all children into submix + for child_id in &meta.children { + if let Some(child) = project.tracks.get(child_id) { + process_track_node( + child, + project, + &mut submix, + child_context, + buffer_pool + ); + } + } + + // Apply metatrack's effects + for effect in &mut meta.effects { + effect.process(&mut submix, 2, context.sample_rate); + } + + // Mix into output with volume + for (out, sub) in output.iter_mut().zip(submix.iter()) { + *out += sub * meta.volume; + } + + // Return buffer to pool + buffer_pool.release(submix); +} +``` + +### Time Transformation + +Metatracks can manipulate time for all children: + +- **Time Stretch**: Speed up or slow down playback +- **Offset**: Shift content in time +- **Pitch Shift**: Transpose content (future feature, requires pitch-preserving time stretch) + +### Metatrack Operations + +```rust +enum MetatrackOperation { + // Creation + CreateFromSelection(Vec), + CreateEmpty, + + // Hierarchy manipulation + AddToMetatrack(TrackId, Vec), + RemoveFromMetatrack(TrackId, Vec), + MoveToMetatrack { track: TrackId, new_parent: TrackId }, + Ungroup(TrackId), + Flatten(TrackId), + + // Transformation + SetTimeStretch(TrackId, f32), + SetOffset(TrackId, f64), + + // Rendering + BounceToAudio(TrackId), + Freeze(TrackId), + Unfreeze(TrackId), +} +``` + +### Nesting Limits + +- **Recommended maximum depth**: 10 levels +- **Reason**: Performance and UI complexity +- **Implementation**: Check depth during metatrack creation + +--- + +## Implementation Roadmap + +### Phase 1: Single Audio File Playback (Week 1-2) + +**Goal**: Play one audio file through speakers + +**Deliverables:** +- Basic cpal integration +- Load audio file with symphonia +- Simple playback loop +- Press spacebar to play/pause + +**Core Implementation:** + +```rust +struct SimpleEngine { + audio_data: Vec, + playhead: usize, + sample_rate: u32, + playing: Arc, +} + +// Main audio callback +fn audio_callback(data: &mut [f32], engine: &mut SimpleEngine) { + if engine.playing.load(Ordering::Relaxed) { + let end = (engine.playhead + data.len()).min(engine.audio_data.len()); + let available = end - engine.playhead; + + data[..available].copy_from_slice( + &engine.audio_data[engine.playhead..end] + ); + + engine.playhead = end; + } else { + data.fill(0.0); + } +} +``` + +**Dependencies:** +- `cpal = "0.15"` +- `symphonia = "0.5"` + +**Success Criteria:** +- Audio plays without clicks or pops +- Can start/stop playback +- No audio thread panics + +--- + +### Phase 2: Transport Control + UI Communication (Week 2-3) + +**Goal**: Start/stop/seek from a basic UI + +**Deliverables:** +- Lock-free command queue +- Atomic playhead position +- Basic UI (terminal or simple window) +- Play/pause/seek controls + +**Core Implementation:** + +```rust +enum Command { + Play, + Stop, + Seek(f64), +} + +struct Engine { + audio_data: Vec, + playhead: Arc, + command_rx: rtrb::Consumer, + playing: bool, + sample_rate: u32, +} + +fn audio_callback(data: &mut [f32], engine: &mut Engine) { + // Process all pending commands + while let Ok(cmd) = engine.command_rx.pop() { + match cmd { + Command::Play => engine.playing = true, + Command::Stop => { + engine.playing = false; + engine.playhead.store(0, Ordering::Relaxed); + } + Command::Seek(seconds) => { + let samples = (seconds * engine.sample_rate as f64) as u64; + engine.playhead.store(samples, Ordering::Relaxed); + } + } + } + + // Render audio... +} +``` + +**New Dependencies:** +- `rtrb = "0.3"` (lock-free ringbuffer) + +**Success Criteria:** +- Commands execute within 1 buffer period +- No audio glitches during seek +- Playhead position updates smoothly + +--- + +### Phase 3: Multiple Audio Tracks (Week 3-4) + +**Goal**: Play multiple audio files simultaneously + +**Deliverables:** +- Track data structure +- Per-track volume control +- Mute/solo functionality +- Mix multiple tracks + +**Core Implementation:** + +```rust +struct Track { + id: u32, + audio_data: Vec, + volume: f32, + muted: bool, + solo: bool, +} + +struct Engine { + tracks: Vec, + playhead: Arc, + command_rx: rtrb::Consumer, + sample_rate: u32, + mix_buffer: Vec, +} + +enum Command { + Play, + Stop, + Seek(f64), + SetTrackVolume(u32, f32), + SetTrackMute(u32, bool), + SetTrackSolo(u32, bool), +} + +fn audio_callback(data: &mut [f32], engine: &mut Engine) { + // Process commands... + + if engine.playing { + // Clear mix buffer + engine.mix_buffer.fill(0.0); + + // Check if any track is soloed + let any_solo = engine.tracks.iter().any(|t| t.solo); + + // Mix all active tracks + for track in &engine.tracks { + let active = !track.muted && (!any_solo || track.solo); + if active { + mix_track(track, &mut engine.mix_buffer, engine.playhead, data.len()); + } + } + + // Copy mix to output + data.copy_from_slice(&engine.mix_buffer[..data.len()]); + } +} + +fn mix_track(track: &Track, output: &mut [f32], playhead: u64, frames: usize) { + let start = playhead as usize; + let end = (start + frames).min(track.audio_data.len()); + + for (i, sample) in track.audio_data[start..end].iter().enumerate() { + output[i] += sample * track.volume; + } +} +``` + +**Success Criteria:** +- 4+ tracks play simultaneously without distortion +- Volume changes are smooth (no clicks) +- Solo/mute work correctly +- CPU usage remains reasonable + +--- + +### Phase 4: Clips & Timeline (Week 4-5) + +**Goal**: Place audio regions at different positions on timeline + +**Deliverables:** +- Clip data structure +- Timeline-based playback +- Audio pool for shared audio data +- Multiple clips per track + +**Core Implementation:** + +```rust +struct Clip { + id: u32, + audio_pool_index: usize, + start_time: f64, // Seconds + duration: f64, + offset: f64, // Offset into audio file + gain: f32, +} + +struct Track { + id: u32, + clips: Vec, + volume: f32, + muted: bool, + solo: bool, +} + +struct AudioPool { + files: Vec>, +} + +fn render_track( + track: &Track, + output: &mut [f32], + pool: &AudioPool, + playhead_seconds: f64, + sample_rate: u32, + frames: usize, +) { + for clip in &track.clips { + let clip_start = clip.start_time; + let clip_end = clip.start_time + clip.duration; + + // Check if clip is active in this time range + if playhead_seconds < clip_end && + playhead_seconds + (frames as f64 / sample_rate as f64) > clip_start { + + render_clip(clip, output, pool, playhead_seconds, sample_rate, frames); + } + } +} + +fn render_clip( + clip: &Clip, + output: &mut [f32], + pool: &AudioPool, + playhead_seconds: f64, + sample_rate: u32, + frames: usize, +) { + let audio = &pool.files[clip.audio_pool_index]; + + // Calculate position within clip + let clip_position = playhead_seconds - clip.start_time + clip.offset; + let start_sample = (clip_position * sample_rate as f64) as usize; + + // Calculate how many samples to copy + let samples_available = audio.len().saturating_sub(start_sample); + let samples_to_copy = samples_available.min(output.len()); + + // Mix into output + for i in 0..samples_to_copy { + output[i] += audio[start_sample + i] * clip.gain * clip.volume; + } +} +``` + +**Success Criteria:** +- Clips play at correct timeline positions +- Multiple clips per track work correctly +- Clips can overlap +- Audio pool prevents duplication + +**UI Requirements:** +- Basic timeline view +- Drag clips to position them +- Visual representation of waveforms + +--- + +### Phase 5: Effect Processing (Week 5-6) + +**Goal**: Add gain/pan/simple effects to tracks + +**Deliverables:** +- Effect trait +- Basic effects (gain, pan, simple EQ) +- Per-track effect chain +- Effect parameter control + +**Core Implementation:** + +```rust +trait Effect: Send { + fn process(&mut self, buffer: &mut [f32], channels: usize, sample_rate: u32); + fn set_parameter(&mut self, id: u32, value: f32); + fn get_parameter(&self, id: u32) -> f32; + fn reset(&mut self); +} + +struct GainEffect { + gain_linear: f32, +} + +impl Effect for GainEffect { + fn process(&mut self, buffer: &mut [f32], _channels: usize, _sample_rate: u32) { + for sample in buffer.iter_mut() { + *sample *= self.gain_linear; + } + } + + fn set_parameter(&mut self, id: u32, value: f32) { + if id == 0 { // Gain in dB + self.gain_linear = 10.0_f32.powf(value / 20.0); + } + } + + fn get_parameter(&self, id: u32) -> f32 { + if id == 0 { + 20.0 * self.gain_linear.log10() + } else { + 0.0 + } + } + + fn reset(&mut self) {} +} + +struct Track { + id: u32, + clips: Vec, + effects: Vec>, + volume: f32, + muted: bool, +} + +fn render_track( + track: &mut Track, + output: &mut [f32], + // ... other params +) { + // Render all clips... + + // Apply effect chain + for effect in &mut track.effects { + effect.process(output, 2, sample_rate); + } + + // Apply track volume + for sample in output.iter_mut() { + *sample *= track.volume; + } +} +``` + +**Additional Effects to Implement:** + +```rust +struct PanEffect { + pan: f32, // -1.0 (left) to 1.0 (right) +} + +struct SimpleEQ { + low_gain: f32, + mid_gain: f32, + high_gain: f32, + low_filter: BiquadFilter, + high_filter: BiquadFilter, +} + +struct BiquadFilter { + b0: f32, b1: f32, b2: f32, + a1: f32, a2: f32, + x1: f32, x2: f32, + y1: f32, y2: f32, +} +``` + +**Success Criteria:** +- Effects process without distortion +- Multiple effects can chain +- Parameter changes are smooth +- No performance degradation + +**Begin DSP Library:** +- Basic filters (lowpass, highpass, bandpass) +- Utilities (db to linear, frequency to coefficients) + +--- + +### Phase 6: Hierarchical Tracks - Foundation (Week 6-7) + +**Goal**: Introduce track hierarchy (groups) without full metatracks + +**Deliverables:** +- TrackNode enum +- Group tracks +- Recursive rendering +- Parent-child relationships + +**Core Implementation:** + +```rust +enum TrackNode { + Audio(AudioTrack), + Group(GroupTrack), +} + +struct AudioTrack { + id: u32, + clips: Vec, + effects: Vec>, + volume: f32, + parent: Option, +} + +struct GroupTrack { + id: u32, + children: Vec, + effects: Vec>, + volume: f32, +} + +struct Project { + tracks: HashMap, + root_tracks: Vec, + audio_pool: AudioPool, +} + +fn render_track_node( + node_id: u32, + project: &Project, + output: &mut [f32], + context: &RenderContext, + buffer_pool: &mut BufferPool, +) { + match &project.tracks[&node_id] { + TrackNode::Audio(track) => { + render_audio_track(track, output, &project.audio_pool, context); + } + TrackNode::Group(group) => { + // Get temp buffer from pool + let mut group_buffer = buffer_pool.acquire(); + group_buffer.resize(output.len(), 0.0); + + // Render all children + for child_id in &group.children { + render_track_node( + *child_id, + project, + &mut group_buffer, + context, + buffer_pool + ); + } + + // Apply group effects + for effect in &mut group.effects { + effect.process(&mut group_buffer, 2, context.sample_rate); + } + + // Mix into output + for (out, group) in output.iter_mut().zip(group_buffer.iter()) { + *out += group * group.volume; + } + + buffer_pool.release(group_buffer); + } + } +} + +struct RenderContext { + playhead_seconds: f64, + sample_rate: u32, + tempo: f32, +} +``` + +**Success Criteria:** +- Can create groups of tracks +- Groups can nest (test 3-4 levels) +- Effects on groups affect all children +- No audio glitches from recursion + +**Refactoring Required:** +- Migrate from `Vec` to `HashMap` +- Update all track access code +- Add parent tracking + +--- + +### Phase 7: MIDI Support (Week 7-8) + +**Goal**: Play MIDI through virtual instruments + +**Deliverables:** +- MIDI data structures +- MIDI clip rendering +- Simple virtual instrument +- MIDI track type + +**Core Implementation:** + +```rust +struct MidiEvent { + timestamp: u64, // Sample position + status: u8, + data1: u8, // Note/CC number + data2: u8, // Velocity/value +} + +struct MidiClip { + id: u32, + events: Vec, + start_time: f64, + duration: f64, +} + +struct MidiTrack { + id: u32, + clips: Vec, + instrument: Box, // Synth as effect + effects: Vec>, + volume: f32, + parent: Option, +} + +enum TrackNode { + Audio(AudioTrack), + Midi(MidiTrack), + Group(GroupTrack), +} + +// Simple sine wave synth for testing +struct SimpleSynth { + voices: Vec, + sample_rate: f32, +} + +struct SynthVoice { + active: bool, + note: u8, + velocity: u8, + phase: f32, + frequency: f32, +} + +impl Effect for SimpleSynth { + fn process(&mut self, buffer: &mut [f32], channels: usize, sample_rate: u32) { + // Process active voices + for voice in &mut self.voices { + if voice.active { + for frame in buffer.chunks_mut(channels) { + let sample = (voice.phase * 2.0 * PI).sin() + * (voice.velocity as f32 / 127.0) * 0.3; + + for channel in frame.iter_mut() { + *channel += sample; + } + + voice.phase += voice.frequency / sample_rate as f32; + if voice.phase >= 1.0 { + voice.phase -= 1.0; + } + } + } + } + } + + // Handle MIDI events via parameters + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + 0 => self.note_on(value as u8, 100), // Note on + 1 => self.note_off(value as u8), // Note off + _ => {} + } + } +} + +fn render_midi_track( + track: &mut MidiTrack, + output: &mut [f32], + context: &RenderContext, + frames: usize, +) { + // Collect MIDI events for this render period + let mut events_to_process = Vec::new(); + + for clip in &track.clips { + collect_events_in_range( + clip, + context.playhead_seconds, + frames, + context.sample_rate, + &mut events_to_process + ); + } + + // Sort by timestamp + events_to_process.sort_by_key(|e| e.timestamp); + + // Process events through instrument + for event in events_to_process { + handle_midi_event(&mut track.instrument, event); + } + + // Generate audio + track.instrument.process(output, 2, context.sample_rate); + + // Apply effect chain + for effect in &mut track.effects { + effect.process(output, 2, context.sample_rate); + } +} +``` + +**Success Criteria:** +- Can load and play MIDI files +- Notes trigger at correct times +- Polyphony works (4+ notes) +- Timing is sample-accurate + +**Dependencies:** +- `midly = "0.5"` (for MIDI file parsing) + +--- + +### Phase 8: Full Metatracks (Week 8-9) + +**Goal**: Add time transformation and metatrack-specific features + +**Deliverables:** +- Metatrack type with transformations +- Time stretch functionality +- Offset capability +- Transform context propagation + +**Core Implementation:** + +```rust +struct Metatrack { + id: u32, + children: Vec, + effects: Vec>, + + // Transformation parameters + time_stretch: f32, // 0.5 = half speed, 2.0 = double speed + pitch_shift: f32, // Semitones (future feature) + offset: f64, // Time offset in seconds + + volume: f32, + parent: Option, +} + +enum TrackNode { + Audio(AudioTrack), + Midi(MidiTrack), + Metatrack(Metatrack), + Group(GroupTrack), +} + +struct RenderContext { + global_position: u64, // Absolute sample position + local_position: u64, // Position within current scope + sample_rate: u32, + tempo: f32, + time_signature: (u32, u32), + time_stretch: f32, // Accumulated stretch +} + +impl Metatrack { + fn transform_context(&self, ctx: RenderContext) -> RenderContext { + let offset_samples = (self.offset * ctx.sample_rate as f64) as u64; + + let adjusted_position = ctx.local_position.saturating_sub(offset_samples); + let stretched_position = (adjusted_position as f64 / self.time_stretch as f64) as u64; + + RenderContext { + global_position: ctx.global_position, + local_position: stretched_position, + sample_rate: ctx.sample_rate, + tempo: ctx.tempo * self.time_stretch, + time_signature: ctx.time_signature, + time_stretch: ctx.time_stretch * self.time_stretch, + } + } +} + +fn render_metatrack( + meta: &Metatrack, + project: &Project, + output: &mut [f32], + context: RenderContext, + buffer_pool: &mut BufferPool, +) { + // Transform context for children + let child_context = meta.transform_context(context); + + // Acquire buffer for submix + let mut submix = buffer_pool.acquire(); + submix.resize(output.len(), 0.0); + + // Render all children with transformed context + for child_id in &meta.children { + if let Some(child) = project.tracks.get(child_id) { + render_track_node( + *child_id, + project, + &mut submix, + child_context, + buffer_pool + ); + } + } + + // Apply metatrack effects + for effect in &mut meta.effects { + effect.process(&mut submix, 2, context.sample_rate); + } + + // Mix into output + for (out, sub) in output.iter_mut().zip(submix.iter()) { + *out += sub * meta.volume; + } + + buffer_pool.release(submix); +} +``` + +**Metatrack Operations:** + +```rust +impl Project { + fn create_metatrack_from_selection(&mut self, track_ids: Vec) -> u32 { + let metatrack_id = self.next_id(); + + let metatrack = Metatrack { + id: metatrack_id, + children: track_ids.clone(), + effects: Vec::new(), + time_stretch: 1.0, + pitch_shift: 0.0, + offset: 0.0, + volume: 1.0, + parent: None, + }; + + // Update parent references + for track_id in track_ids { + if let Some(track) = self.tracks.get_mut(&track_id) { + set_parent(track, Some(metatrack_id)); + } + } + + self.tracks.insert(metatrack_id, TrackNode::Metatrack(metatrack)); + self.root_tracks.push(metatrack_id); + + metatrack_id + } + + fn ungroup_metatrack(&mut self, metatrack_id: u32) { + if let Some(TrackNode::Metatrack(meta)) = self.tracks.get(&metatrack_id) { + let children = meta.children.clone(); + + // Remove parent from children + for child_id in children { + if let Some(track) = self.tracks.get_mut(&child_id) { + set_parent(track, None); + } + self.root_tracks.push(child_id); + } + + // Remove metatrack + self.tracks.remove(&metatrack_id); + self.root_tracks.retain(|&id| id != metatrack_id); + } + } +} +``` + +**Success Criteria:** +- Can create metatracks from track selection +- Time stretch affects all children +- Offset shifts children in time +- Can nest metatracks 5+ levels deep +- Performance remains acceptable + +--- + +### Phase 9: Polish & Optimization (Week 9-11) + +#### 9a. Buffer Pool Optimization (Week 9) + +**Goal**: Eliminate allocations in audio thread + +```rust +struct BufferPool { + buffers: Vec>, + available: Vec, + buffer_size: usize, + total_allocations: AtomicUsize, +} + +impl BufferPool { + fn new(count: usize, size: usize) -> Self { + let mut buffers = Vec::with_capacity(count); + let mut available = Vec::with_capacity(count); + + for i in 0..count { + buffers.push(vec![0.0; size]); + available.push(i); + } + + BufferPool { + buffers, + available, + buffer_size: size, + total_allocations: AtomicUsize::new(0), + } + } + + fn acquire(&mut self) -> Vec { + if let Some(idx) = self.available.pop() { + let mut buf = std::mem::take(&mut self.buffers[idx]); + buf.fill(0.0); + buf + } else { + self.total_allocations.fetch_add(1, Ordering::Relaxed); + vec![0.0; self.buffer_size] + } + } + + fn release(&mut self, buffer: Vec) { + if buffer.len() == self.buffer_size { + let idx = self.buffers.len(); + self.buffers.push(buffer); + self.available.push(idx); + } + } +} +``` + +**Success Criteria:** +- Zero allocations during steady-state playback +- Pool size auto-adjusts to actual usage +- Metrics show allocation count + +#### 9b. Lock-Free State Updates (Week 10) + +**Goal**: Replace Mutex with triple-buffering for project state + +```rust +struct TripleBuffer { + buffers: [T; 3], + write_idx: AtomicUsize, + read_idx: AtomicUsize, +} + +impl TripleBuffer { + fn new(initial: T) -> Self { + TripleBuffer { + buffers: [initial.clone(), initial.clone(), initial], + write_idx: AtomicUsize::new(0), + read_idx: AtomicUsize::new(0), + } + } + + // Called from control thread + fn write(&mut self, value: T) { + let write_idx = self.write_idx.load(Ordering::Acquire); + let next_idx = (write_idx + 1) % 3; + + self.buffers[next_idx] = value; + self.write_idx.store(next_idx, Ordering::Release); + } + + // Called from audio thread + fn read(&self) -> &T { + let read_idx = self.read_idx.load(Ordering::Acquire); + let write_idx = self.write_idx.load(Ordering::Acquire); + + if read_idx != write_idx { + self.read_idx.store(write_idx, Ordering::Release); + } + + &self.buffers[read_idx] + } +} +``` + +**Success Criteria:** +- No locks in audio thread +- State updates propagate within 1-2 buffers +- No audio glitches during updates + +#### 9c. Disk Streaming (Week 11) + +**Goal**: Stream large audio files that don't fit in RAM + +```rust +struct StreamingFile { + id: FileId, + path: PathBuf, + channels: u32, + sample_rate: u32, + total_frames: u64, + + // Streaming state + buffer_rx: rtrb::Consumer, + current_chunk: Option, + chunk_offset: usize, +} + +struct AudioChunk { + start_frame: u64, + data: Vec, +} + +// Background streaming thread +fn streaming_thread( + file_path: PathBuf, + request_rx: Receiver, + chunk_tx: rtrb::Producer, +) { + let mut file = File::open(file_path).unwrap(); + let mut decoder = /* create decoder */; + + loop { + if let Ok(request) = request_rx.try_recv() { + // Seek to requested position + decoder.seek(request.frame); + } + + // Read chunk + let chunk = decoder.read_frames(CHUNK_SIZE); + + // Send to audio thread + let _ = chunk_tx.push(AudioChunk { + start_frame: current_frame, + data: chunk, + }); + + current_frame += CHUNK_SIZE; + } +} +``` + +**Success Criteria:** +- Can play files larger than RAM +- No dropouts during streaming +- Seek works smoothly +- Multiple streaming files simultaneously + +--- + +### Phase 10: Advanced Features (Week 11+) + +#### Automation + +```rust +struct AutomationLane { + parameter_id: u32, + points: Vec, +} + +struct AutomationPoint { + time: f64, + value: f32, + curve: CurveType, +} + +enum CurveType { + Linear, + Exponential, + SCurve, +} +``` + +#### Plugin Hosting (VST/CLAP) + +```rust +struct PluginHost { + scanner: PluginScanner, + instances: HashMap, +} + +struct PluginInstance { + id: PluginId, + plugin_type: PluginType, + handle: *mut c_void, + parameters: Vec, +} + +enum PluginType { + VST3, + CLAP, +} +``` + +#### Project Save/Load + +```rust +#[derive(Serialize, Deserialize)] +struct ProjectFile { + version: String, + metadata: ProjectMetadata, + tracks: Vec, + audio_files: Vec, + tempo_map: TempoMap, +} +``` + +#### Undo/Redo System + +```rust +trait Command { + fn execute(&mut self, project: &mut Project); + fn undo(&mut self, project: &mut Project); +} + +struct CommandHistory { + commands: Vec>, + position: usize, +} +``` + +--- + +## Technical Specifications + +### Performance Targets + +- **Latency**: < 10ms (varies by buffer size and sample rate) +- **CPU Usage**: < 50% for 32 tracks with effects at 44.1kHz +- **Track Count**: Support 64+ tracks without performance degradation +- **Nesting Depth**: 10 levels of metatrack nesting +- **Plugin Count**: 8+ plugins per track + +### Buffer Sizes + +- **Audio Callback**: 128-512 frames (adjustable) +- **Streaming Chunk**: 8192 frames +- **Ring Buffer**: 8192 samples (UI → Audio commands) + +### Sample Rates + +- **Supported**: 44.1kHz, 48kHz, 88.2kHz, 96kHz +- **Default**: 48kHz +- **Internal Processing**: Always at project sample rate + +### Memory Budget + +- **Audio Pool Cache**: 1GB default, configurable +- **Buffer Pool**: 50 buffers × 4096 samples × 4 bytes = 800KB +- **Per-Track Overhead**: < 1KB + +### Thread Model + +1. **UI Thread**: User interaction, visualization +2. **Control Thread**: Project state management, file I/O +3. **Audio Thread**: Real-time processing (cpal callback) +4. **Streaming Thread(s)**: Disk I/O for large files + +### Data Format + +- **Internal Audio**: 32-bit float, interleaved +- **File Support**: WAV, FLAC, MP3, OGG via symphonia +- **MIDI**: Standard MIDI File Format +- **Project Files**: JSON or MessagePack + +--- + +## Testing Strategy + +### Unit Tests + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clip_rendering_at_position() { + let clip = Clip { + id: 0, + audio_pool_index: 0, + start_time: 1.0, // Starts at 1 second + duration: 2.0, + offset: 0.0, + gain: 1.0, + }; + + let pool = AudioPool { + files: vec![vec![1.0; 96000]], // 2 seconds at 48kHz + }; + + let mut output = vec![0.0; 4800]; // 0.1 seconds + + render_clip(&clip, &mut output, &pool, 1.5, 48000, 4800); + + assert!(output.iter().any(|&s| s != 0.0)); + } + + #[test] + fn test_metatrack_time_stretch() { + let mut context = RenderContext { + global_position: 48000, + local_position: 48000, + sample_rate: 48000, + tempo: 120.0, + time_signature: (4, 4), + time_stretch: 1.0, + }; + + let metatrack = Metatrack { + id: 0, + children: vec![], + effects: vec![], + time_stretch: 0.5, // Half speed + pitch_shift: 0.0, + offset: 0.0, + volume: 1.0, + parent: None, + }; + + let child_context = metatrack.transform_context(context); + + assert_eq!(child_context.time_stretch, 0.5); + assert_eq!(child_context.tempo, 60.0); + } + + #[test] + fn test_buffer_pool_no_allocations() { + let mut pool = BufferPool::new(10, 1024); + + let buf1 = pool.acquire(); + let buf2 = pool.acquire(); + + pool.release(buf1); + pool.release(buf2); + + let buf3 = pool.acquire(); + let buf4 = pool.acquire(); + + // Should reuse buffers, no new allocations + assert_eq!(pool.total_allocations.load(Ordering::Relaxed), 0); + } +} +``` + +### Integration Tests + +```rust +#[test] +fn test_full_playback_pipeline() { + // Setup + let (cmd_tx, cmd_rx) = rtrb::RingBuffer::new(256); + let (evt_tx, evt_rx) = rtrb::RingBuffer::new(256); + + let mut engine = AudioEngine::new(48000, cmd_rx, evt_tx); + engine.load_audio_file("test.wav"); + + // Start playback + cmd_tx.push(Command::Play).unwrap(); + + // Render some audio + let mut output = vec![0.0; 4800]; + engine.process(&mut output); + + // Verify audio was rendered + assert!(output.iter().any(|&s| s.abs() > 0.001)); + + // Check events + if let Ok(AudioEvent::PlaybackPosition(pos)) = evt_rx.pop() { + assert!(pos > 0.0); + } +} + +#[test] +fn test_nested_metatrack_rendering() { + let mut project = Project::new(48000); + + // Create structure: Metatrack1 -> Metatrack2 -> AudioTrack + let audio_id = project.add_audio_track(); + let meta2_id = project.create_metatrack_from_selection(vec![audio_id]); + let meta1_id = project.create_metatrack_from_selection(vec![meta2_id]); + + // Apply transformations + project.set_metatrack_time_stretch(meta1_id, 0.5); + project.set_metatrack_time_stretch(meta2_id, 2.0); + + // Render + let mut output = vec![0.0; 4800]; + project.render(&mut output, 0.0); + + // Effective stretch should be 0.5 * 2.0 = 1.0 (normal speed) + assert!(output.iter().any(|&s| s != 0.0)); +} +``` + +### Performance Tests + +```rust +#[bench] +fn bench_render_32_tracks(b: &mut Bencher) { + let mut engine = create_engine_with_tracks(32); + let mut output = vec![0.0; 512 * 2]; + + b.iter(|| { + engine.process(&mut output); + }); +} + +#[bench] +fn bench_metatrack_nesting_10_levels(b: &mut Bencher) { + let mut project = create_nested_metatracks(10); + let mut output = vec![0.0; 512 * 2]; + + b.iter(|| { + project.render(&mut output, 0.0); + }); +} +``` + +### Audio Quality Tests + +- **THD+N**: Total Harmonic Distortion + Noise < 0.01% +- **Frequency Response**: Flat ±0.1dB 20Hz-20kHz +- **Click Detection**: No clicks during parameter changes +- **Timing Accuracy**: MIDI events within ±1 sample + +### Stress Tests + +- **Long Sessions**: 8+ hours continuous playback +- **Many Tracks**: 128 tracks, 8 effects each +- **Deep Nesting**: 20 levels of metatrack nesting +- **Rapid Commands**: 1000 commands/second +- **Large Files**: 1GB+ audio files streaming + +--- + +## Recommended Crates + +### Core Audio +- `cpal = "0.15"` - Audio I/O +- `symphonia = "0.5"` - Audio decoding +- `rubato = "0.14"` - Sample rate conversion + +### Concurrency +- `rtrb = "0.3"` - Lock-free ring buffers +- `crossbeam = "0.8"` - Additional concurrency tools +- `parking_lot = "0.12"` - Better mutexes (non-realtime) + +### DSP +- `realfft = "3.3"` - FFT for spectral processing +- `biquad = "0.4"` - IIR filters + +### Serialization +- `serde = { version = "1.0", features = ["derive"] }` +- `serde_json = "1.0"` or `rmp-serde = "1.1"` (MessagePack) + +### File I/O +- `midly = "0.5"` - MIDI file parsing +- `hound = "3.5"` - WAV file writing + +### Future +- `vst3-sys` or `clack` - Plugin hosting +- `egui = "0.24"` - Immediate mode GUI (if building UI in Rust) + +--- + +## Project Structure + +``` +daw-backend/ +├── Cargo.toml +├── src/ +│ ├── main.rs +│ ├── lib.rs +│ │ +│ ├── audio/ +│ │ ├── mod.rs +│ │ ├── engine.rs # Audio engine, main processing loop +│ │ ├── track.rs # Track types (Audio, MIDI, Metatrack) +│ │ ├── clip.rs # Clip management +│ │ ├── pool.rs # Audio pool +│ │ ├── buffer_pool.rs # Buffer allocation pool +│ │ └── render.rs # Rendering functions +│ │ +│ ├── project/ +│ │ ├── mod.rs +│ │ ├── project.rs # Project state +│ │ ├── hierarchy.rs # Track hierarchy management +│ │ ├── operations.rs # Project operations (add track, etc.) +│ │ └── serialization.rs # Save/load +│ │ +│ ├── effects/ +│ │ ├── mod.rs +│ │ ├── trait.rs # Effect trait +│ │ ├── gain.rs +│ │ ├── pan.rs +│ │ ├── eq.rs +│ │ └── synth.rs # Simple synth for MIDI +│ │ +│ ├── dsp/ +│ │ ├── mod.rs +│ │ ├── filters.rs # Biquad, etc. +│ │ ├── envelope.rs # ADSR +│ │ ├── oscillator.rs +│ │ └── utils.rs # DB conversion, etc. +│ │ +│ ├── io/ +│ │ ├── mod.rs +│ │ ├── audio_file.rs # Audio file loading +│ │ ├── midi_file.rs # MIDI file loading +│ │ └── streaming.rs # Disk streaming +│ │ +│ ├── command/ +│ │ ├── mod.rs +│ │ ├── types.rs # Command/Event enums +│ │ └── queue.rs # Command queue management +│ │ +│ └── ui/ +│ ├── mod.rs +│ └── bridge.rs # UI-Audio communication +│ +├── tests/ +│ ├── integration_tests.rs +│ └── audio_quality_tests.rs +│ +└── benches/ + └── performance.rs +``` + +--- + +## Conclusion + +This architecture provides: + +1. **Incremental Development**: Each phase builds on the last without requiring rewrites +2. **Real-time Safety**: Lock-free, allocation-free audio thread from the start +3. **Flexibility**: Hierarchical tracks support simple projects and complex arrangements +4. **Scalability**: Architecture handles 64+ tracks with deep nesting +5. **Extensibility**: Effect trait makes plugin hosting straightforward + +The roadmap gets you from "hello audio" to a full-featured DAW in 11 weeks, with each phase delivering working, testable functionality. + +**Next Steps:** +1. Set up Rust project with cpal +2. Implement Phase 1 (single file playback) +3. Add comprehensive tests +4. Profile and optimize as needed +5. Continue through phases sequentially + +Good luck building your DAW! \ No newline at end of file diff --git a/daw-backend/src/audio/clip.rs b/daw-backend/src/audio/clip.rs new file mode 100644 index 0000000..b5749a8 --- /dev/null +++ b/daw-backend/src/audio/clip.rs @@ -0,0 +1,49 @@ +/// Clip ID type +pub type ClipId = u32; + +/// Audio clip that references data in the AudioPool +#[derive(Debug, Clone)] +pub struct Clip { + pub id: ClipId, + pub audio_pool_index: usize, + pub start_time: f64, // Position on timeline in seconds + pub duration: f64, // Clip duration in seconds + pub offset: f64, // Offset into audio file in seconds + pub gain: f32, // Clip-level gain +} + +impl Clip { + /// Create a new clip + pub fn new( + id: ClipId, + audio_pool_index: usize, + start_time: f64, + duration: f64, + offset: f64, + ) -> Self { + Self { + id, + audio_pool_index, + start_time, + duration, + offset, + gain: 1.0, + } + } + + /// Check if this clip is active at a given timeline position + pub fn is_active_at(&self, time_seconds: f64) -> bool { + let clip_end = self.start_time + self.duration; + time_seconds >= self.start_time && time_seconds < clip_end + } + + /// Get the end time of this clip on the timeline + pub fn end_time(&self) -> f64 { + self.start_time + self.duration + } + + /// Set clip gain + pub fn set_gain(&mut self, gain: f32) { + self.gain = gain.max(0.0); + } +} diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs new file mode 100644 index 0000000..ca5fc75 --- /dev/null +++ b/daw-backend/src/audio/engine.rs @@ -0,0 +1,269 @@ +use crate::audio::clip::ClipId; +use crate::audio::pool::AudioPool; +use crate::audio::track::{Track, TrackId}; +use crate::command::{AudioEvent, Command}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +/// Audio engine for Phase 4: timeline with clips and audio pool +pub struct Engine { + tracks: Vec, + audio_pool: AudioPool, + playhead: u64, // Playhead position in samples + sample_rate: u32, + playing: bool, + channels: u32, + + // Lock-free communication + command_rx: rtrb::Consumer, + event_tx: rtrb::Producer, + + // Shared playhead for UI reads + playhead_atomic: Arc, + + // Event counter for periodic position updates + frames_since_last_event: usize, + event_interval_frames: usize, + + // Mix buffer for combining tracks + mix_buffer: Vec, +} + +impl Engine { + /// Create a new Engine with communication channels + pub fn new( + sample_rate: u32, + channels: u32, + command_rx: rtrb::Consumer, + event_tx: rtrb::Producer, + ) -> Self { + let event_interval_frames = (sample_rate as usize * channels as usize) / 10; // Update 10 times per second + + Self { + tracks: Vec::new(), + audio_pool: AudioPool::new(), + playhead: 0, + sample_rate, + playing: false, + channels, + command_rx, + event_tx, + playhead_atomic: Arc::new(AtomicU64::new(0)), + frames_since_last_event: 0, + event_interval_frames, + mix_buffer: Vec::new(), + } + } + + /// Add a track to the engine + pub fn add_track(&mut self, track: Track) -> TrackId { + let id = track.id; + self.tracks.push(track); + id + } + + /// Get mutable reference to audio pool + pub fn audio_pool_mut(&mut self) -> &mut AudioPool { + &mut self.audio_pool + } + + /// Get reference to audio pool + pub fn audio_pool(&self) -> &AudioPool { + &self.audio_pool + } + + /// Get a handle for controlling playback from the UI thread + pub fn get_controller(&self, command_tx: rtrb::Producer) -> EngineController { + EngineController { + command_tx, + playhead: Arc::clone(&self.playhead_atomic), + sample_rate: self.sample_rate, + channels: self.channels, + } + } + + /// Process audio callback - called from the audio thread + pub fn process(&mut self, output: &mut [f32]) { + // Process all pending commands + while let Ok(cmd) = self.command_rx.pop() { + self.handle_command(cmd); + } + + if self.playing { + // Ensure mix buffer is sized correctly + if self.mix_buffer.len() != output.len() { + self.mix_buffer.resize(output.len(), 0.0); + } + + // Clear mix buffer + self.mix_buffer.fill(0.0); + + // Convert playhead from samples to seconds for timeline-based rendering + let playhead_seconds = self.playhead as f64 / (self.sample_rate as f64 * self.channels as f64); + + // Check if any track is soloed + let any_solo = self.tracks.iter().any(|t| t.solo); + + // Mix all active tracks using timeline-based rendering + for track in &self.tracks { + if track.is_active(any_solo) { + track.render( + &mut self.mix_buffer, + &self.audio_pool, + playhead_seconds, + self.sample_rate, + self.channels, + ); + } + } + + // Copy mix to output + output.copy_from_slice(&self.mix_buffer); + + // Update playhead + self.playhead += output.len() as u64; + + // Update atomic playhead for UI reads + self.playhead_atomic + .store(self.playhead, Ordering::Relaxed); + + // Send periodic position updates + self.frames_since_last_event += output.len() / self.channels as usize; + if self.frames_since_last_event >= self.event_interval_frames / self.channels as usize + { + let position_seconds = + self.playhead as f64 / (self.sample_rate as f64 * self.channels as f64); + let _ = self + .event_tx + .push(AudioEvent::PlaybackPosition(position_seconds)); + self.frames_since_last_event = 0; + } + } else { + // Not playing, output silence + output.fill(0.0); + } + } + + /// Handle a command from the UI thread + fn handle_command(&mut self, cmd: Command) { + match cmd { + Command::Play => { + self.playing = true; + } + Command::Stop => { + self.playing = false; + self.playhead = 0; + self.playhead_atomic.store(0, Ordering::Relaxed); + } + Command::Pause => { + self.playing = false; + } + Command::Seek(seconds) => { + let samples = (seconds * self.sample_rate as f64 * self.channels as f64) as u64; + self.playhead = samples; + self.playhead_atomic + .store(self.playhead, Ordering::Relaxed); + } + Command::SetTrackVolume(track_id, volume) => { + if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) { + track.set_volume(volume); + } + } + Command::SetTrackMute(track_id, muted) => { + if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) { + track.set_muted(muted); + } + } + Command::SetTrackSolo(track_id, solo) => { + if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) { + track.set_solo(solo); + } + } + Command::MoveClip(track_id, clip_id, new_start_time) => { + if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) { + if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { + clip.start_time = new_start_time; + } + } + } + } + } + + /// Get current sample rate + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + /// Get number of channels + pub fn channels(&self) -> u32 { + self.channels + } + + /// Get number of tracks + pub fn track_count(&self) -> usize { + self.tracks.len() + } +} + +/// Controller for the engine that can be used from the UI thread +pub struct EngineController { + command_tx: rtrb::Producer, + playhead: Arc, + sample_rate: u32, + channels: u32, +} + +impl EngineController { + /// Start or resume playback + pub fn play(&mut self) { + let _ = self.command_tx.push(Command::Play); + } + + /// Pause playback + pub fn pause(&mut self) { + let _ = self.command_tx.push(Command::Pause); + } + + /// Stop playback and reset to beginning + pub fn stop(&mut self) { + let _ = self.command_tx.push(Command::Stop); + } + + /// Seek to a specific position in seconds + pub fn seek(&mut self, seconds: f64) { + let _ = self.command_tx.push(Command::Seek(seconds)); + } + + /// Set track volume (0.0 = silence, 1.0 = unity gain) + pub fn set_track_volume(&mut self, track_id: TrackId, volume: f32) { + let _ = self + .command_tx + .push(Command::SetTrackVolume(track_id, volume)); + } + + /// Set track mute state + pub fn set_track_mute(&mut self, track_id: TrackId, muted: bool) { + let _ = self.command_tx.push(Command::SetTrackMute(track_id, muted)); + } + + /// Set track solo state + pub fn set_track_solo(&mut self, track_id: TrackId, solo: bool) { + let _ = self.command_tx.push(Command::SetTrackSolo(track_id, solo)); + } + + /// Move a clip to a new timeline position + pub fn move_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: f64) { + let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time)); + } + + /// Get current playhead position in samples + pub fn get_playhead_samples(&self) -> u64 { + self.playhead.load(Ordering::Relaxed) + } + + /// Get current playhead position in seconds + pub fn get_playhead_seconds(&self) -> f64 { + let samples = self.playhead.load(Ordering::Relaxed); + samples as f64 / (self.sample_rate as f64 * self.channels as f64) + } +} diff --git a/daw-backend/src/audio/mod.rs b/daw-backend/src/audio/mod.rs new file mode 100644 index 0000000..08e4f06 --- /dev/null +++ b/daw-backend/src/audio/mod.rs @@ -0,0 +1,9 @@ +pub mod clip; +pub mod engine; +pub mod pool; +pub mod track; + +pub use clip::{Clip, ClipId}; +pub use engine::{Engine, EngineController}; +pub use pool::{AudioFile as PoolAudioFile, AudioPool}; +pub use track::{Track, TrackId}; diff --git a/daw-backend/src/audio/pool.rs b/daw-backend/src/audio/pool.rs new file mode 100644 index 0000000..fdecd2c --- /dev/null +++ b/daw-backend/src/audio/pool.rs @@ -0,0 +1,178 @@ +use std::path::PathBuf; + +/// Audio file stored in the pool +#[derive(Debug, Clone)] +pub struct AudioFile { + pub path: PathBuf, + pub data: Vec, // Interleaved samples + pub channels: u32, + pub sample_rate: u32, + pub frames: u64, +} + +impl AudioFile { + /// Create a new AudioFile + pub fn new(path: PathBuf, data: Vec, channels: u32, sample_rate: u32) -> Self { + let frames = (data.len() / channels as usize) as u64; + Self { + path, + data, + channels, + sample_rate, + frames, + } + } + + /// Get duration in seconds + pub fn duration_seconds(&self) -> f64 { + self.frames as f64 / self.sample_rate as f64 + } +} + +/// Pool of shared audio files +pub struct AudioPool { + files: Vec, +} + +impl AudioPool { + /// Create a new empty audio pool + pub fn new() -> Self { + Self { + files: Vec::new(), + } + } + + /// Add an audio file to the pool and return its index + pub fn add_file(&mut self, file: AudioFile) -> usize { + let index = self.files.len(); + self.files.push(file); + index + } + + /// Get an audio file by index + pub fn get_file(&self, index: usize) -> Option<&AudioFile> { + self.files.get(index) + } + + /// Get number of files in the pool + pub fn file_count(&self) -> usize { + self.files.len() + } + + /// Render audio from a file in the pool with sample rate and channel conversion + /// start_time_seconds: position in the audio file to start reading from (in seconds) + /// Returns the number of samples actually rendered + pub fn render_from_file( + &self, + pool_index: usize, + output: &mut [f32], + start_time_seconds: f64, + gain: f32, + engine_sample_rate: u32, + engine_channels: u32, + ) -> usize { + if let Some(audio_file) = self.files.get(pool_index) { + // Calculate starting frame position in the source file (frame = one sample per channel) + let src_start_frame = start_time_seconds * audio_file.sample_rate as f64; + + // Calculate sample rate conversion ratio (frames) + let rate_ratio = audio_file.sample_rate as f64 / engine_sample_rate as f64; + + let src_channels = audio_file.channels; + let dst_channels = engine_channels; + + // Render frame by frame + let output_frames = output.len() / dst_channels as usize; + let mut rendered_frames = 0; + + for frame_idx in 0..output_frames { + // Calculate the corresponding frame in the source file + let src_frame_pos = src_start_frame + (frame_idx as f64 * rate_ratio); + let src_frame_idx = src_frame_pos as usize; + + // Check bounds + if src_frame_idx >= audio_file.frames as usize { + break; + } + + // Calculate source sample index (interleaved) + let src_sample_idx = src_frame_idx * src_channels as usize; + + // Check bounds for interpolation + if src_sample_idx + src_channels as usize > audio_file.data.len() { + break; + } + + // Linear interpolation for better quality + let frac = src_frame_pos - src_frame_idx as f64; + let next_frame_idx = src_frame_idx + 1; + let next_sample_idx = next_frame_idx * src_channels as usize; + let can_interpolate = next_sample_idx + src_channels as usize <= audio_file.data.len() && frac > 0.0; + + // Read and convert channels + for dst_ch in 0..dst_channels { + let sample = if src_channels == dst_channels { + // Same number of channels - direct mapping + let ch = dst_ch as usize; + let s0 = audio_file.data[src_sample_idx + ch]; + if can_interpolate { + let s1 = audio_file.data[next_sample_idx + ch]; + s0 + (s1 - s0) * frac as f32 + } else { + s0 + } + } else if src_channels == 1 && dst_channels > 1 { + // Mono to multi-channel - duplicate to all channels + let s0 = audio_file.data[src_sample_idx]; + if can_interpolate { + let s1 = audio_file.data[next_sample_idx]; + s0 + (s1 - s0) * frac as f32 + } else { + s0 + } + } else if src_channels > 1 && dst_channels == 1 { + // Multi-channel to mono - average all source channels + let mut sum = 0.0f32; + for src_ch in 0..src_channels { + let s0 = audio_file.data[src_sample_idx + src_ch as usize]; + let s = if can_interpolate { + let s1 = audio_file.data[next_sample_idx + src_ch as usize]; + s0 + (s1 - s0) * frac as f32 + } else { + s0 + }; + sum += s; + } + sum / src_channels as f32 + } else { + // Mismatched channels - use modulo for simple mapping + let src_ch = (dst_ch % src_channels) as usize; + let s0 = audio_file.data[src_sample_idx + src_ch]; + if can_interpolate { + let s1 = audio_file.data[next_sample_idx + src_ch]; + s0 + (s1 - s0) * frac as f32 + } else { + s0 + } + }; + + // Mix into output with gain + let output_idx = frame_idx * dst_channels as usize + dst_ch as usize; + output[output_idx] += sample * gain; + } + + rendered_frames += 1; + } + + rendered_frames * dst_channels as usize + } else { + 0 + } + } +} + +impl Default for AudioPool { + fn default() -> Self { + Self::new() + } +} diff --git a/daw-backend/src/audio/track.rs b/daw-backend/src/audio/track.rs new file mode 100644 index 0000000..f0384de --- /dev/null +++ b/daw-backend/src/audio/track.rs @@ -0,0 +1,143 @@ +use super::clip::Clip; +use super::pool::AudioPool; + +/// Track ID type +pub type TrackId = u32; + +/// Audio track for Phase 4 with clips +pub struct Track { + pub id: TrackId, + pub name: String, + pub clips: Vec, + pub volume: f32, + pub muted: bool, + pub solo: bool, +} + +impl Track { + /// Create a new track with default settings + pub fn new(id: TrackId, name: String) -> Self { + Self { + id, + name, + clips: Vec::new(), + volume: 1.0, + muted: false, + solo: false, + } + } + + /// Add a clip to this track + pub fn add_clip(&mut self, clip: Clip) { + self.clips.push(clip); + } + + /// Set track volume (0.0 = silence, 1.0 = unity gain, >1.0 = amplification) + pub fn set_volume(&mut self, volume: f32) { + self.volume = volume.max(0.0); + } + + /// Set mute state + pub fn set_muted(&mut self, muted: bool) { + self.muted = muted; + } + + /// Set solo state + pub fn set_solo(&mut self, solo: bool) { + self.solo = solo; + } + + /// Check if this track should be audible given the solo state of all tracks + pub fn is_active(&self, any_solo: bool) -> bool { + !self.muted && (!any_solo || self.solo) + } + + /// Render this track into the output buffer at a given timeline position + /// Returns the number of samples actually rendered + pub fn render( + &self, + output: &mut [f32], + pool: &AudioPool, + playhead_seconds: f64, + sample_rate: u32, + channels: u32, + ) -> usize { + let buffer_duration_seconds = output.len() as f64 / (sample_rate as f64 * channels as f64); + let buffer_end_seconds = playhead_seconds + buffer_duration_seconds; + + let mut rendered = 0; + + // Render all active clips + for clip in &self.clips { + // Check if clip overlaps with current buffer time range + if clip.start_time < buffer_end_seconds && clip.end_time() > playhead_seconds { + rendered += self.render_clip( + clip, + output, + pool, + playhead_seconds, + sample_rate, + channels, + ); + } + } + + rendered + } + + /// Render a single clip into the output buffer + fn render_clip( + &self, + clip: &Clip, + output: &mut [f32], + pool: &AudioPool, + playhead_seconds: f64, + sample_rate: u32, + channels: u32, + ) -> usize { + let buffer_duration_seconds = output.len() as f64 / (sample_rate as f64 * channels as f64); + let buffer_end_seconds = playhead_seconds + buffer_duration_seconds; + + // Determine the time range we need to render (intersection of buffer and clip) + let render_start_seconds = playhead_seconds.max(clip.start_time); + let render_end_seconds = buffer_end_seconds.min(clip.end_time()); + + // If no overlap, return early + if render_start_seconds >= render_end_seconds { + return 0; + } + + // Calculate offset into the output buffer (in interleaved samples) + let output_offset_seconds = render_start_seconds - playhead_seconds; + let output_offset_samples = (output_offset_seconds * sample_rate as f64 * channels as f64) as usize; + + // Calculate position within the clip's audio file (in seconds) + let clip_position_seconds = render_start_seconds - clip.start_time + clip.offset; + + // Calculate how many samples to render in the output + let render_duration_seconds = render_end_seconds - render_start_seconds; + let samples_to_render = (render_duration_seconds * sample_rate as f64 * channels as f64) as usize; + let samples_to_render = samples_to_render.min(output.len() - output_offset_samples); + + // Get the slice of output buffer to write to + if output_offset_samples + samples_to_render > output.len() { + return 0; + } + + let output_slice = &mut output[output_offset_samples..output_offset_samples + samples_to_render]; + + // Calculate combined gain + let combined_gain = clip.gain * self.volume; + + // Render from pool with sample rate conversion + // Pass the time position in seconds, let the pool handle sample rate conversion + pool.render_from_file( + clip.audio_pool_index, + output_slice, + clip_position_seconds, + combined_gain, + sample_rate, + channels, + ) + } +} diff --git a/daw-backend/src/command/mod.rs b/daw-backend/src/command/mod.rs new file mode 100644 index 0000000..64cfe4c --- /dev/null +++ b/daw-backend/src/command/mod.rs @@ -0,0 +1,3 @@ +pub mod types; + +pub use types::{AudioEvent, Command}; diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs new file mode 100644 index 0000000..43ac7ef --- /dev/null +++ b/daw-backend/src/command/types.rs @@ -0,0 +1,38 @@ +use crate::audio::{ClipId, TrackId}; + +/// Commands sent from UI/control thread to audio thread +#[derive(Debug, Clone)] +pub enum Command { + // Transport commands + /// Start playback + Play, + /// Stop playback and reset to beginning + Stop, + /// Pause playback (maintains position) + Pause, + /// Seek to a specific position in seconds + Seek(f64), + + // Track management commands + /// Set track volume (0.0 = silence, 1.0 = unity gain) + SetTrackVolume(TrackId, f32), + /// Set track mute state + SetTrackMute(TrackId, bool), + /// Set track solo state + SetTrackSolo(TrackId, bool), + + // Clip management commands + /// Move a clip to a new timeline position + MoveClip(TrackId, ClipId, f64), +} + +/// Events sent from audio thread back to UI/control thread +#[derive(Debug, Clone)] +pub enum AudioEvent { + /// Current playback position in seconds + PlaybackPosition(f64), + /// Playback has stopped (reached end of audio) + PlaybackStopped, + /// Audio buffer underrun detected + BufferUnderrun, +} diff --git a/daw-backend/src/io/audio_file.rs b/daw-backend/src/io/audio_file.rs new file mode 100644 index 0000000..c473fb4 --- /dev/null +++ b/daw-backend/src/io/audio_file.rs @@ -0,0 +1,124 @@ +use std::path::Path; +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::DecoderOptions; +use symphonia::core::errors::Error; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +pub struct AudioFile { + pub data: Vec, + pub channels: u32, + pub sample_rate: u32, + pub frames: u64, +} + +impl AudioFile { + /// Load an audio file from disk and decode it to interleaved f32 samples + pub fn load>(path: P) -> Result { + let path = path.as_ref(); + + // Open the media source + let file = std::fs::File::open(path) + .map_err(|e| format!("Failed to open file: {}", e))?; + + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + // Create a probe hint using the file extension + let mut hint = Hint::new(); + if let Some(extension) = path.extension() { + if let Some(ext_str) = extension.to_str() { + hint.with_extension(ext_str); + } + } + + // Probe the media source + let probed = symphonia::default::get_probe() + .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default()) + .map_err(|e| format!("Failed to probe file: {}", e))?; + + let mut format = probed.format; + + // Find the default audio track + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL) + .ok_or_else(|| "No audio tracks found".to_string())?; + + let track_id = track.id; + + // Get audio parameters + let codec_params = &track.codec_params; + let channels = codec_params.channels + .ok_or_else(|| "Channel count not specified".to_string())? + .count() as u32; + let sample_rate = codec_params.sample_rate + .ok_or_else(|| "Sample rate not specified".to_string())?; + + // Create decoder + let mut decoder = symphonia::default::get_codecs() + .make(&codec_params, &DecoderOptions::default()) + .map_err(|e| format!("Failed to create decoder: {}", e))?; + + // Decode all packets + let mut audio_data = Vec::new(); + let mut sample_buf = None; + + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(Error::ResetRequired) => { + return Err("Decoder reset required (not implemented)".to_string()); + } + Err(Error::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + // End of file + break; + } + Err(e) => { + return Err(format!("Failed to read packet: {}", e)); + } + }; + + // Skip packets for other tracks + if packet.track_id() != track_id { + continue; + } + + // Decode the packet + match decoder.decode(&packet) { + Ok(decoded) => { + // Initialize sample buffer on first packet + if sample_buf.is_none() { + let spec = *decoded.spec(); + let duration = decoded.capacity() as u64; + sample_buf = Some(SampleBuffer::::new(duration, spec)); + } + + // Copy decoded audio to sample buffer + if let Some(ref mut buf) = sample_buf { + buf.copy_interleaved_ref(decoded); + audio_data.extend_from_slice(buf.samples()); + } + } + Err(Error::DecodeError(e)) => { + eprintln!("Decode error: {}", e); + continue; + } + Err(e) => { + return Err(format!("Decode failed: {}", e)); + } + } + } + + let frames = (audio_data.len() / channels as usize) as u64; + + Ok(AudioFile { + data: audio_data, + channels, + sample_rate, + frames, + }) + } +} diff --git a/daw-backend/src/io/mod.rs b/daw-backend/src/io/mod.rs new file mode 100644 index 0000000..940ea0d --- /dev/null +++ b/daw-backend/src/io/mod.rs @@ -0,0 +1,3 @@ +pub mod audio_file; + +pub use audio_file::AudioFile; diff --git a/daw-backend/src/lib.rs b/daw-backend/src/lib.rs new file mode 100644 index 0000000..335fd4e --- /dev/null +++ b/daw-backend/src/lib.rs @@ -0,0 +1,14 @@ +// DAW Backend - Phase 4: Clips & Timeline +// +// A DAW backend with timeline-based playback, clips, and audio pool. +// Supports multiple tracks, mixing, per-track volume/mute/solo, and shared audio data. +// Uses lock-free command queues, cpal for audio I/O, and symphonia for audio file decoding. + +pub mod audio; +pub mod command; +pub mod io; + +// Re-export commonly used types +pub use audio::{AudioPool, Clip, ClipId, Engine, EngineController, PoolAudioFile, Track, TrackId}; +pub use command::{AudioEvent, Command}; +pub use io::AudioFile; diff --git a/daw-backend/src/main.rs b/daw-backend/src/main.rs new file mode 100644 index 0000000..cd73da1 --- /dev/null +++ b/daw-backend/src/main.rs @@ -0,0 +1,420 @@ +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use daw_backend::{AudioEvent, AudioFile, Clip, Engine, PoolAudioFile, Track}; +use std::env; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::Mutex; +use std::thread; +use std::time::Duration; + +fn main() -> Result<(), Box> { + // Get audio file paths from command line arguments + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} [audio_file2] [audio_file3] ...", args[0]); + eprintln!("Example: {} track1.wav track2.wav", args[0]); + return Ok(()); + } + + println!("DAW Backend - Phase 4: Clips & Timeline\n"); + + // Load all audio files + let mut audio_files = Vec::new(); + let mut max_sample_rate = 0; + let mut max_channels = 0; + + for (i, path) in args.iter().skip(1).enumerate() { + println!("Loading file {}: {}", i + 1, path); + match AudioFile::load(path) { + Ok(audio_file) => { + let duration = audio_file.frames as f64 / audio_file.sample_rate as f64; + println!( + " {} Hz, {} channels, {} frames ({:.2}s)", + audio_file.sample_rate, audio_file.channels, audio_file.frames, duration + ); + + max_sample_rate = max_sample_rate.max(audio_file.sample_rate); + max_channels = max_channels.max(audio_file.channels); + + audio_files.push(( + Path::new(path) + .file_name() + .unwrap() + .to_string_lossy() + .to_string(), + PathBuf::from(path), + audio_file, + )); + } + Err(e) => { + eprintln!(" Error loading {}: {}", path, e); + eprintln!(" Skipping this file..."); + } + } + } + + if audio_files.is_empty() { + eprintln!("No audio files loaded. Exiting."); + return Ok(()); + } + + println!("\nProject settings:"); + println!(" Sample rate: {} Hz", max_sample_rate); + println!(" Channels: {}", max_channels); + println!(" Files: {}", audio_files.len()); + + // Initialize cpal + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or("No output device available")?; + println!("\nUsing audio device: {}", device.name()?); + + // Get the default output config to determine sample format + let default_config = device.default_output_config()?; + let sample_format = default_config.sample_format(); + + // Create a custom config matching the project settings + let config = cpal::StreamConfig { + channels: max_channels as u16, + sample_rate: cpal::SampleRate(max_sample_rate), + buffer_size: cpal::BufferSize::Default, + }; + + println!("Output config: {:?} with format {:?}", config, sample_format); + + // Create lock-free command and event queues + let (command_tx, command_rx) = rtrb::RingBuffer::::new(256); + let (event_tx, event_rx) = rtrb::RingBuffer::::new(256); + + // Create the audio engine + let mut engine = Engine::new(max_sample_rate, max_channels, command_rx, event_tx); + + // Add all files to the audio pool and create tracks with clips + let mut track_ids = Vec::new(); + let mut clip_info = Vec::new(); // Store (track_id, clip_id, name, duration) + let mut max_duration = 0.0f64; + let mut clip_id_counter = 0u32; + + println!("\nCreating tracks and clips:"); + for (i, (name, path, audio_file)) in audio_files.into_iter().enumerate() { + let duration = audio_file.frames as f64 / audio_file.sample_rate as f64; + max_duration = max_duration.max(duration); + + // Add audio file to pool + let pool_file = PoolAudioFile::new( + path, + audio_file.data, + audio_file.channels, + audio_file.sample_rate, + ); + let pool_index = engine.audio_pool_mut().add_file(pool_file); + + // Create track + let track_id = i as u32; + let mut track = Track::new(track_id, name.clone()); + + // Create clip that plays the entire file starting at time 0 + let clip_id = clip_id_counter; + let clip = Clip::new( + clip_id, + pool_index, + 0.0, // start at beginning of timeline + duration, // full duration + 0.0, // no offset into file + ); + clip_id_counter += 1; + + track.add_clip(clip); + engine.add_track(track); + track_ids.push(track_id); + clip_info.push((track_id, clip_id, name.clone(), duration)); + + println!(" Track {}: {} (clip {} at 0.0s, duration {:.2}s)", i, name, clip_id, duration); + } + + println!("\nTimeline duration: {:.2}s", max_duration); + + let mut controller = engine.get_controller(command_tx); + + // Wrap engine in Arc for thread-safe access + let engine = Arc::new(Mutex::new(engine)); + + // Build the output stream + let stream = match sample_format { + cpal::SampleFormat::F32 => build_stream::(&device, &config, engine)?, + cpal::SampleFormat::I16 => build_stream::(&device, &config, engine)?, + cpal::SampleFormat::U16 => build_stream::(&device, &config, engine)?, + _ => return Err("Unsupported sample format".into()), + }; + + // Start the audio stream + stream.play()?; + println!("\nAudio stream started!"); + print_help(); + print_status(0.0, max_duration, &track_ids); + + // Spawn event listener thread + let event_rx = Arc::new(Mutex::new(event_rx)); + let event_rx_clone = Arc::clone(&event_rx); + let _event_thread = thread::spawn(move || { + loop { + thread::sleep(Duration::from_millis(50)); + let mut rx = event_rx_clone.lock().unwrap(); + while let Ok(event) = rx.pop() { + match event { + AudioEvent::PlaybackPosition(pos) => { + // Clear the line and show position + print!("\r\x1b[K"); + print!("Position: {:.2}s / {:.2}s", pos, max_duration); + print!(" ["); + let bar_width = 30; + let filled = ((pos / max_duration) * bar_width as f64) as usize; + for i in 0..bar_width { + if i < filled { + print!("="); + } else if i == filled { + print!(">"); + } else { + print!(" "); + } + } + print!("]"); + io::stdout().flush().ok(); + } + AudioEvent::PlaybackStopped => { + print!("\r\x1b[K"); + println!("Playback stopped (end of timeline)"); + print!("> "); + io::stdout().flush().ok(); + } + AudioEvent::BufferUnderrun => { + eprintln!("\nWarning: Buffer underrun detected"); + } + } + } + } + }); + + // Simple command loop + loop { + let mut input = String::new(); + print!("\r\x1b[K> "); + io::stdout().flush()?; + io::stdin().read_line(&mut input)?; + let input = input.trim(); + + // Parse input + if input.is_empty() { + controller.play(); + println!("Playing..."); + } else if input == "q" || input == "quit" { + println!("Quitting..."); + break; + } else if input == "s" || input == "stop" { + controller.stop(); + println!("Stopped (reset to beginning)"); + } else if input == "p" || input == "play" { + controller.play(); + println!("Playing..."); + } else if input == "pause" { + controller.pause(); + println!("Paused"); + } else if input.starts_with("seek ") { + // Parse seek time + if let Ok(seconds) = input[5..].trim().parse::() { + if seconds >= 0.0 { + controller.seek(seconds); + println!("Seeking to {:.2}s", seconds); + } else { + println!("Invalid seek time (must be >= 0.0)"); + } + } else { + println!("Invalid seek format. Usage: seek "); + } + } else if input.starts_with("volume ") { + // Parse: volume + let parts: Vec<&str> = input.split_whitespace().collect(); + if parts.len() == 3 { + if let (Ok(track_id), Ok(volume)) = (parts[1].parse::(), parts[2].parse::()) { + if track_ids.contains(&track_id) { + controller.set_track_volume(track_id, volume); + println!("Set track {} volume to {:.2}", track_id, volume); + } else { + println!("Invalid track ID. Available tracks: {:?}", track_ids); + } + } else { + println!("Invalid format. Usage: volume "); + } + } else { + println!("Usage: volume "); + } + } else if input.starts_with("mute ") { + // Parse: mute + if let Ok(track_id) = input[5..].trim().parse::() { + if track_ids.contains(&track_id) { + controller.set_track_mute(track_id, true); + println!("Muted track {}", track_id); + } else { + println!("Invalid track ID. Available tracks: {:?}", track_ids); + } + } else { + println!("Usage: mute "); + } + } else if input.starts_with("unmute ") { + // Parse: unmute + if let Ok(track_id) = input[7..].trim().parse::() { + if track_ids.contains(&track_id) { + controller.set_track_mute(track_id, false); + println!("Unmuted track {}", track_id); + } else { + println!("Invalid track ID. Available tracks: {:?}", track_ids); + } + } else { + println!("Usage: unmute "); + } + } else if input.starts_with("solo ") { + // Parse: solo + if let Ok(track_id) = input[5..].trim().parse::() { + if track_ids.contains(&track_id) { + controller.set_track_solo(track_id, true); + println!("Soloed track {}", track_id); + } else { + println!("Invalid track ID. Available tracks: {:?}", track_ids); + } + } else { + println!("Usage: solo "); + } + } else if input.starts_with("unsolo ") { + // Parse: unsolo + if let Ok(track_id) = input[7..].trim().parse::() { + if track_ids.contains(&track_id) { + controller.set_track_solo(track_id, false); + println!("Unsoloed track {}", track_id); + } else { + println!("Invalid track ID. Available tracks: {:?}", track_ids); + } + } else { + println!("Usage: unsolo "); + } + } else if input.starts_with("move ") { + // Parse: move + let parts: Vec<&str> = input.split_whitespace().collect(); + if parts.len() == 4 { + if let (Ok(track_id), Ok(clip_id), Ok(time)) = + (parts[1].parse::(), parts[2].parse::(), parts[3].parse::()) { + // Validate track and clip exist + if let Some((_tid, _cid, name, _)) = clip_info.iter().find(|(t, c, _, _)| *t == track_id && *c == clip_id) { + controller.move_clip(track_id, clip_id, time); + println!("Moved clip {} ('{}') on track {} to {:.2}s", clip_id, name, track_id, time); + } else { + println!("Invalid track ID or clip ID"); + println!("Available clips:"); + for (tid, cid, name, dur) in &clip_info { + println!(" Track {}, Clip {} ('{}', duration {:.2}s)", tid, cid, name, dur); + } + } + } else { + println!("Invalid format. Usage: move