From 53140e642a4e402ef9d694e50ada7d8a85456bca Mon Sep 17 00:00:00 2001 From: JorySeverijnse Date: Sun, 18 Jan 2026 15:34:01 +0100 Subject: [PATCH] initial commit --- .gitignore | 25 ++ Cargo.lock | 1167 +++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 34 ++ README.md | 138 ++++++ src/audio.rs | 97 ++++ src/lib.rs | 25 ++ src/main.rs | 255 +++++++++++ src/render.rs | 314 +++++++++++++ src/video.rs | 89 ++++ 9 files changed, 2144 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/audio.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/render.rs create mode 100644 src/video.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbe8281 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Generated by Cargo +target/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Temporary files +tmp/ +temp/ + +# Build artifacts +*.mp4 +*.wav +!test_sine.wav diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fd3e5aa --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1167 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.2.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[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 = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.9", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[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-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oscilloscope-video-gen" +version = "1.0.0" +dependencies = [ + "anyhow", + "clap", + "hound", + "image", + "rayon", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +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 = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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 = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87c86acb70a85b2c16f071f171847d1945e8f44812630463cd14ec83900ad01c" +dependencies = [ + "zune-core 0.5.1", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9203718 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "oscilloscope-video-gen" +version = "1.0.0" +edition = "2021" +description = "Generate oscilloscope-style visualizations from audio files" +authors = ["Oscilloscope Video Generator"] + +[lib] +path = "src/lib.rs" + +[[bin]] +path = "src/main.rs" +name = "oscilloscope-video-gen" + +[dependencies] +# Image processing for frame rendering +image = "0.25" + +# Parallel processing for faster rendering +rayon = "1.10" + +# WAV file handling +hound = "3.5" + +# Command line argument parsing +clap = { version = "4.4", features = ["derive"] } + +# Error handling +anyhow = "1.0" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e37e4b --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# Oscilloscope Video Generator + +A high-performance Rust tool for generating oscilloscope-style visualizations from audio files. Uses parallel rendering for fast processing. + +## Features + +- **Multiple visualization modes**: combined, separate, all (L/R + XY) +- **Parallel rendering**: Uses all CPU cores for fast frame generation +- **High quality output**: Supports up to 4K resolution at 60fps +- **Original audio**: Copies audio stream without re-encoding (perfect sync) +- **Customizable**: Colors, resolution, FPS, line thickness + +## Installation + +### From Source + +```bash +cd oscilloscope-video-gen +cargo install --path . +``` + +### Build Only + +```bash +cargo build --release +``` + +The binary will be at `target/release/oscilloscope-video-gen`. + +## Usage + +### Basic + +```bash +oscilloscope-video-gen -i audio.wav -o video.mp4 +``` + +### Full Options + +```bash +oscilloscope-video-gen \ + -i audio.wav \ + -o video.mp4 \ + --width 1920 \ + --height 1080 \ + --fps 30 \ + --mode all \ + --quality high \ + --left-color "#00ff00" \ + --right-color "#00ccff" \ + --xy-color "#ff8800" \ + --background "#0a0f0a" \ + --line-thickness 2 \ + --threads 8 \ + --overwrite \ + --verbose +``` + +### Options + +| Option | Default | Description | +|--------|---------|-------------| +| `-i, --input` | Required | Input WAV file | +| `-o, --output` | Auto-generated | Output MP4 file | +| `--width` | 1920 | Video width in pixels | +| `--height` | 1080 | Video height in pixels | +| `--fps` | 30 | Frames per second | +| `--mode` | all | Visualization mode | +| `--quality` | high | Video quality | +| `--left-color` | #00ff00 | Left channel color (hex) | +| `--right-color` | #00ccff | Right channel color (hex) | +| `--xy-color` | #ff8800 | XY mode color (hex) | +| `--background` | #0a0f0a | Background color (hex) | +| `--show-grid` | true | Show grid lines | +| `--line-thickness` | 2 | Line thickness in pixels | +| `--threads` | All cores | Number of rendering threads | +| `--overwrite` | false | Overwrite output file | +| `--verbose` | false | Enable verbose output | + +### Modes + +- `combined`: Both channels merged into single waveform +- `separate`: Left on top, Right on bottom +- `all`: Left and Right on top row, XY pattern on bottom + +### Quality Presets + +| Preset | Video Bitrate | +|--------|---------------| +| low | 2 Mbps | +| medium | 5 Mbps | +| high | 10 Mbps | + +## Requirements + +- Rust 1.70+ +- ffmpeg (for video encoding) + +## Building + +```bash +# Debug build +cargo build + +# Release build (optimized) +cargo build --release + +# Build with specific number of threads +cargo build --release --jobs 8 +``` + +## Troubleshooting + +### ffmpeg not found + +Make sure ffmpeg is installed and in your PATH: + +```bash +# Ubuntu/Debian +sudo apt install ffmpeg + +# macOS +brew install ffmpeg + +# Windows +winget install FFmpeg +``` + +### Out of memory + +For very long audio files, try: +- Lower resolution (`--width 1280 --height 720`) +- Lower FPS (`--fps 24`) +- Fewer threads (`--threads 4`) + +## License + +MIT diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..1f6d9e2 --- /dev/null +++ b/src/audio.rs @@ -0,0 +1,97 @@ +//! WAV audio decoding module. +//! +//! Handles reading and decoding WAV files into normalized sample data. + +use anyhow::{anyhow, Context, Result}; +use hound::WavReader; +use std::path::Path; + +/// Normalized audio sample data. +#[derive(Debug, Clone)] +pub struct AudioData { + /// Left channel samples, normalized to [-1.0, 1.0] + pub left_channel: Vec, + /// Right channel samples, normalized to [-1.0, 1.0] + pub right_channel: Vec, + /// Sample rate in Hz + pub sample_rate: u32, + /// Duration in seconds + pub duration: f64, +} + +impl AudioData { + /// Load and decode a WAV file. + /// + /// # Arguments + /// + /// * `file_path` - Path to the WAV file + /// + /// # Returns + /// + /// `Result` containing the decoded audio samples + /// + /// # Errors + /// + /// Returns an error if: + /// - The file cannot be opened + /// - Bit depth is not 16-bit + /// - Number of channels is not 1 or 2 + pub fn from_wav(file_path: &Path) -> Result { + let mut reader = WavReader::open(file_path) + .with_context(|| format!("Failed to open WAV file: {}", file_path.display()))?; + + let spec = reader.spec(); + + if spec.bits_per_sample != 16 { + return Err(anyhow!( + "Unsupported bit depth: {}. Only 16-bit WAV is supported.", + spec.bits_per_sample + )); + } + + if spec.channels != 1 && spec.channels != 2 { + return Err(anyhow!( + "Unsupported number of channels: {}. Only mono and stereo are supported.", + spec.channels + )); + } + + let sample_rate = spec.sample_rate; + let samples: Vec = reader.samples().map(|s| s.unwrap_or(0)).collect(); + let total_samples = samples.len() / spec.channels as usize; + let duration = total_samples as f64 / sample_rate as f64; + + let mut left_channel = Vec::with_capacity(total_samples); + let mut right_channel = Vec::with_capacity(total_samples); + + for i in 0..total_samples { + let offset = i * spec.channels as usize; + let left_sample = samples[offset] as f32 / 32768.0; + left_channel.push(left_sample); + + if spec.channels >= 2 { + let right_sample = samples[offset + 1] as f32 / 32768.0; + right_channel.push(right_sample); + } else { + right_channel.push(left_sample); + } + } + + Ok(AudioData { + left_channel, + right_channel, + sample_rate, + duration, + }) + } + + /// Get the total number of samples. + pub fn len(&self) -> usize { + self.left_channel.len() + } + + /// Check if the audio data is empty. + pub fn is_empty(&self) -> bool { + self.left_channel.is_empty() + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2dd8013 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,25 @@ +//! Oscilloscope Video Generator +//! +//! A high-performance tool for generating oscilloscope-style visualizations +//! from audio files. Uses parallel rendering for fast processing. +//! +//! # Example +//! +//! ```no_run +//! use oscilloscope_video_gen::{AudioData, RenderOptions, RenderMode}; +//! use std::path::PathBuf; +//! +//! fn main() -> Result<(), Box> { +//! let audio = AudioData::from_wav(&PathBuf::from("audio.wav"))?; +//! // ... use the library +//! Ok(()) +//! } +//! ``` + +pub mod audio; +pub mod render; +pub mod video; + +pub use audio::AudioData; +pub use render::{RenderMode, RenderOptions}; +pub use video::encode_video; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f207acc --- /dev/null +++ b/src/main.rs @@ -0,0 +1,255 @@ +//! Oscilloscope Video Generator +//! +//! A high-performance tool for generating oscilloscope-style visualizations +//! from audio files. Uses parallel rendering for fast processing. + +use anyhow::{Context, Result}; +use clap::{Parser, ValueEnum}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use oscilloscope_video_gen::audio::AudioData; +use oscilloscope_video_gen::render::{parse_rgb_hex, render_frames, RenderMode, RenderOptions}; +use oscilloscope_video_gen::video::{encode_video, cleanup_tmp_dir, VideoQuality}; + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum OutputMode { + Combined, + Separate, + All, +} + +impl From for RenderMode { + fn from(val: OutputMode) -> Self { + match val { + OutputMode::Combined => RenderMode::Combined, + OutputMode::Separate => RenderMode::Separate, + OutputMode::All => RenderMode::All, + } + } +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum OutputQuality { + Low, + Medium, + High, +} + +impl From for VideoQuality { + fn from(val: OutputQuality) -> Self { + match val { + OutputQuality::Low => VideoQuality::Low, + OutputQuality::Medium => VideoQuality::Medium, + OutputQuality::High => VideoQuality::High, + } + } +} + +/// Generate oscilloscope visualizations from audio files +#[derive(Parser, Debug)] +#[command(name = "oscilloscope-video-gen")] +#[command(author, version, about, long_about = None)] +struct Args { + /// Input audio file (WAV) + #[arg(short, long)] + input: PathBuf, + + /// Output video file + #[arg(short, long)] + output: Option, + + /// Video width (default: 1920) + #[arg(long, default_value = "1920")] + width: u32, + + /// Video height (default: 1080) + #[arg(long, default_value = "1080")] + height: u32, + + /// Frames per second (default: 30) + #[arg(long, default_value = "30")] + fps: u32, + + /// Display mode: combined, separate, all + #[arg(long, value_enum, default_value = "all")] + mode: OutputMode, + + /// Quality: low, medium, high + #[arg(long, value_enum, default_value = "high")] + quality: OutputQuality, + + /// Left channel color (RGB hex, default: #00ff00) + #[arg(long, default_value = "#00ff00")] + left_color: String, + + /// Right channel color (RGB hex, default: #00ccff) + #[arg(long, default_value = "#00ccff")] + right_color: String, + + /// XY mode color (RGB hex, default: #ff8800) + #[arg(long, default_value = "#ff8800")] + xy_color: String, + + /// Background color (RGB hex, default: #0a0f0a) + #[arg(long, default_value = "#0a0f0a")] + background: String, + + /// Show grid lines + #[arg(long, default_value = "true")] + show_grid: bool, + + /// Line thickness (default: 2) + #[arg(long, default_value = "2")] + line_thickness: u32, + + /// Number of rendering threads + #[arg(long)] + threads: Option, + + /// Overwrite output file if it exists + #[arg(long, default_value = "false")] + overwrite: bool, + + /// Enable verbose output + #[arg(long, default_value = "false")] + verbose: bool, +} + +fn main() -> Result<()> { + let args = Args::parse(); + + // Set number of threads + if let Some(threads) = args.threads { + rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build_global() + .unwrap(); + } + + // Parse colors + let left_color = + parse_rgb_hex(&args.left_color).context("Failed to parse left_color")?; + let right_color = + parse_rgb_hex(&args.right_color).context("Failed to parse right_color")?; + let xy_color = parse_rgb_hex(&args.xy_color).context("Failed to parse xy_color")?; + let background = + parse_rgb_hex(&args.background).context("Failed to parse background")?; + + // Create options + let options = RenderOptions { + width: args.width, + height: args.height, + fps: args.fps, + mode: args.mode.into(), + left_color, + right_color, + xy_color, + background, + show_grid: args.show_grid, + line_thickness: args.line_thickness, + }; + + // Determine output path + let output = match args.output { + Some(path) => path, + None => { + let mut path = args.input.clone(); + path.set_extension("mp4"); + path + } + }; + + if args.verbose { + println!("Oscilloscope Video Generator"); + println!("============================"); + println!("Input: {}", args.input.display()); + println!("Output: {}", output.display()); + println!("Resolution: {}x{}", args.width, args.height); + println!("FPS: {}", args.fps); + println!("Mode: {:?}", args.mode); + println!("Quality: {:?}", args.quality); + println!("Threads: {:?}", args.threads.unwrap_or_else(|| rayon::current_num_threads())); + println!(); + } else { + println!("Oscilloscope Video Generator"); + println!("============================"); + println!("Input: {}", args.input.display()); + println!("Output: {}", output.display()); + println!("Resolution: {}x{} @ {}fps", args.width, args.height, args.fps); + println!("Mode: {:?}", args.mode); + println!(); + } + + // Decode audio + let audio_data = AudioData::from_wav(&args.input) + .with_context(|| format!("Failed to decode audio: {}", args.input.display()))?; + + if args.verbose { + println!( + "Audio: {}Hz, {:.2}s duration, {} samples", + audio_data.sample_rate, + audio_data.duration, + audio_data.len() + ); + } + + // Create temp directory + let tmp_dir = std::env::temp_dir().join("oscilloscope-render"); + if tmp_dir.exists() { + cleanup_tmp_dir(&tmp_dir); + } + std::fs::create_dir_all(&tmp_dir)?; + + // Progress callback + let progress = Arc::new(AtomicUsize::new(0)); + let progress_callback = move |percent: f64, current: usize, total: usize| { + let prev = progress.fetch_add(0, Ordering::SeqCst); + if current - prev >= 30 || current == total || current == 1 { + progress.store(current, Ordering::SeqCst); + print!("\rRendering: {:.0}% ({}/{})", percent, current, total); + let _ = std::io::stdout().flush(); + } + }; + + // Render frames + println!("Rendering frames..."); + let frame_files = render_frames(&audio_data, &options, &tmp_dir, &progress_callback)?; + println!(); + println!("Rendered {} frames", frame_files.len()); + + // Check if output exists and handle overwrite + if output.exists() && !args.overwrite { + return Err(anyhow::anyhow!( + "Output file already exists: {}. Use --overwrite to replace it.", + output.display() + )); + } + + // Encode video + encode_video( + &frame_files, + &args.input, + &output, + args.fps, + args.quality.into(), + args.overwrite, + ) + .context("Failed to encode video")?; + + // Cleanup + println!("Cleaning up temporary files..."); + cleanup_tmp_dir(&tmp_dir); + + let file_size = std::fs::metadata(&output) + .map(|m| m.len()) + .unwrap_or(0); + + println!("\nDone!"); + println!("Output: {}", output.display()); + println!("Size: {:.2} MB", file_size as f64 / 1_000_000.0); + + Ok(()) +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..e6e0290 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,314 @@ +//! Frame rendering module. +//! +//! Contains all the logic for drawing oscilloscope visualizations. + +use crate::audio::AudioData; +use anyhow::{anyhow, Context, Result}; +use image::ImageBuffer; +use std::path::{Path, PathBuf}; + +/// Render mode for the oscilloscope visualization. +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum RenderMode { + /// Both channels merged into a single waveform + Combined, + /// Left channel on top, Right channel on bottom + Separate, + /// Left and Right on top row, XY on bottom + All, +} + +/// Rendering options for the visualization. +#[derive(Debug, Clone)] +pub struct RenderOptions { + pub width: u32, + pub height: u32, + pub fps: u32, + pub mode: RenderMode, + pub left_color: image::Rgb, + pub right_color: image::Rgb, + pub xy_color: image::Rgb, + pub background: image::Rgb, + pub show_grid: bool, + pub line_thickness: u32, +} + +/// Draw a line between two points using Bresenham's algorithm. +pub fn draw_line( + buffer: &mut ImageBuffer, Vec>, + x0: i32, + y0: i32, + x1: i32, + y1: i32, + color: image::Rgb, +) { + let dx = (x1 - x0).abs(); + let dy = -(y1 - y0).abs(); + let mut x = x0; + let mut y = y0; + let sx = if x0 < x1 { 1 } else { -1 }; + let sy = if y0 < y1 { 1 } else { -1 }; + let mut err = dx + dy; + + loop { + if x >= 0 && x < buffer.width() as i32 && y >= 0 && y < buffer.height() as i32 { + buffer.put_pixel(x as u32, y as u32, color); + } + + if x == x1 && y == y1 { + break; + } + + let e2 = 2 * err; + if e2 >= dy { + err += dy; + x += sx; + } + if e2 <= dx { + err += dx; + y += sy; + } + } +} + +/// Draw grid lines (graticule). +fn draw_graticule( + buffer: &mut ImageBuffer, Vec>, + primary_color: image::Rgb, + show_grid: bool, +) { + if !show_grid { + return; + } + + let (width, height) = buffer.dimensions(); + + for x in 0..width { + buffer.put_pixel(x, height / 2, primary_color); + } + + for y in 0..height { + buffer.put_pixel(width / 2, y, primary_color); + } +} + +/// Parse RGB hex color string. +pub fn parse_rgb_hex(hex: &str) -> Result> { + let hex = hex.trim_start_matches('#'); + if hex.len() != 6 { + return Err(anyhow::anyhow!("Invalid RGB hex: {}", hex)); + } + let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| anyhow!("Invalid red component: {}", &hex[0..2]))?; + let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| anyhow!("Invalid green component: {}", &hex[2..4]))?; + let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| anyhow!("Invalid blue component: {}", &hex[4..6]))?; + Ok(image::Rgb([r, g, b])) +} + +/// Draw a single frame of the visualization. +pub fn draw_frame( + audio_data: &AudioData, + start_sample: usize, + samples_per_frame: usize, + options: &RenderOptions, +) -> ImageBuffer, Vec> { + let width = options.width; + let height = options.height; + let mut buffer = ImageBuffer::new(width, height); + + for pixel in buffer.pixels_mut() { + *pixel = options.background; + } + + if options.show_grid { + draw_graticule(&mut buffer, options.left_color, true); + } + + let end_sample = std::cmp::min(start_sample + samples_per_frame, audio_data.left_channel.len()); + + match options.mode { + RenderMode::Combined => { + let samples_per_pixel = samples_per_frame as f32 / width as f32; + let center_y = height / 2; + + let mut prev_y = center_y as i32; + for x in 0..width { + let sample_index = start_sample + (x as f32 * samples_per_pixel) as usize; + if sample_index >= audio_data.left_channel.len() { + break; + } + let sample = (audio_data.left_channel[sample_index] + + audio_data.right_channel[sample_index]) + / 2.0; + let y = center_y as i32 - (sample * (height as f32 * 0.4)) as i32; + + draw_line(&mut buffer, x as i32, prev_y, x as i32, y, options.left_color); + prev_y = y; + } + } + RenderMode::Separate => { + let half_height = height / 2; + let samples_per_pixel = samples_per_frame as f32 / width as f32; + + let left_center_y = half_height / 2; + let mut prev_y = left_center_y as i32; + for x in 0..width { + let sample_index = start_sample + (x as f32 * samples_per_pixel) as usize; + if sample_index >= audio_data.left_channel.len() { + break; + } + let sample = audio_data.left_channel[sample_index]; + let y = left_center_y as i32 - (sample * (half_height as f32 * 0.35)) as i32; + + draw_line(&mut buffer, x as i32, prev_y, x as i32, y, options.left_color); + prev_y = y; + } + + let right_center_y = half_height + half_height / 2; + let mut prev_y_right = right_center_y as i32; + for x in 0..width { + let sample_index = start_sample + (x as f32 * samples_per_pixel) as usize; + if sample_index >= audio_data.right_channel.len() { + break; + } + let sample = audio_data.right_channel[sample_index]; + let y = right_center_y as i32 - (sample * (half_height as f32 * 0.35)) as i32; + + draw_line(&mut buffer, x as i32, prev_y_right, x as i32, y, options.right_color); + prev_y_right = y; + } + + for x in 0..width { + buffer.put_pixel(x, half_height, image::Rgb([40, 40, 40])); + } + } + RenderMode::All => { + let top_height = height / 2; + let bottom_height = height / 2; + let half_width = width / 2; + let samples_per_pixel = samples_per_frame as f32 / half_width as f32; + + let left_center_y = top_height / 2; + let mut prev_y = left_center_y as i32; + for x in 0..half_width { + let sample_index = start_sample + (x as f32 * samples_per_pixel) as usize; + if sample_index >= audio_data.left_channel.len() { + break; + } + let sample = audio_data.left_channel[sample_index]; + let y = left_center_y as i32 - (sample * (top_height as f32 * 0.35)) as i32; + + draw_line(&mut buffer, x as i32, prev_y, x as i32, y, options.left_color); + prev_y = y; + } + + let right_center_y = top_height / 2; + let mut prev_y_right = right_center_y as i32; + for x in 0..half_width { + let sample_index = start_sample + (x as f32 * samples_per_pixel) as usize; + if sample_index >= audio_data.left_channel.len() { + break; + } + let sample = audio_data.right_channel[sample_index]; + let y = right_center_y as i32 - (sample * (top_height as f32 * 0.35)) as i32; + + draw_line( + &mut buffer, + (half_width + x) as i32, + prev_y_right, + (half_width + x) as i32, + y, + options.right_color, + ); + prev_y_right = y; + } + + let xy_center_x = width / 2; + let xy_center_y = top_height + bottom_height / 2; + let xy_scale = std::cmp::min(half_width, bottom_height) as f32 * 0.35; + + let xy_samples = (end_sample - start_sample).min(samples_per_frame); + let mut prev_x = xy_center_x as i32 + (audio_data.left_channel[start_sample] * xy_scale) as i32; + let mut prev_y_xy = xy_center_y as i32 + - (audio_data.right_channel[start_sample] * xy_scale) as i32; + + for i in 1..xy_samples { + let sample_idx = start_sample + i; + if sample_idx >= audio_data.left_channel.len() { + break; + } + let x = xy_center_x as i32 + + (audio_data.left_channel[sample_idx] * xy_scale) as i32; + let y = xy_center_y as i32 + - (audio_data.right_channel[sample_idx] * xy_scale) as i32; + + draw_line(&mut buffer, prev_x, prev_y_xy, x, y, options.xy_color); + + prev_x = x; + prev_y_xy = y; + } + + for x in 0..width { + buffer.put_pixel(x, top_height, image::Rgb([40, 40, 40])); + } + for y in 0..top_height { + buffer.put_pixel(half_width, y, image::Rgb([40, 40, 40])); + } + } + } + + buffer +} + +/// Render frames to PNG files. +pub fn render_frames( + audio_data: &AudioData, + options: &RenderOptions, + tmp_dir: &Path, + progress_callback: &(impl Fn(f64, usize, usize) + Send + Sync), +) -> Result, anyhow::Error> { + let total_samples = audio_data.left_channel.len(); + let samples_per_frame = (audio_data.sample_rate / options.fps) as usize; + let total_frames = ((audio_data.duration * options.fps as f64) as usize).max(1); + + let frame_files: Vec = (0..total_frames) + .map(|i| tmp_dir.join(format!("frame_{:06}.png", i))) + .collect(); + + use rayon::prelude::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let progress = Arc::new(AtomicUsize::new(0)); + + frame_files + .par_iter() + .enumerate() + .try_for_each(|(frame_idx, frame_file): (usize, &PathBuf)| { + let start_sample = std::cmp::min( + frame_idx * samples_per_frame, + total_samples.saturating_sub(1), + ); + + let frame = draw_frame(audio_data, start_sample, samples_per_frame, options); + + let file = std::fs::File::create(frame_file) + .with_context(|| format!("Failed to create frame file: {}", frame_file.display()))?; + let mut writer = std::io::BufWriter::new(file); + frame + .write_to(&mut writer, image::ImageFormat::Png) + .with_context(|| format!("Failed to write frame: {}", frame_file.display()))?; + + let current = progress.fetch_add(1, Ordering::SeqCst) + 1; + if current % 30 == 0 || current == total_frames { + progress_callback( + current as f64 / total_frames as f64 * 100.0, + current, + total_frames, + ); + } + + Ok::<_, anyhow::Error>(()) + })?; + + Ok(frame_files) +} diff --git a/src/video.rs b/src/video.rs new file mode 100644 index 0000000..b355107 --- /dev/null +++ b/src/video.rs @@ -0,0 +1,89 @@ +//! Video encoding module. +//! +//! Handles encoding rendered frames into video files using ffmpeg. + +use anyhow::{anyhow, Context, Result}; +use std::path::{Path, PathBuf}; + +/// Quality preset for video encoding. +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum VideoQuality { + Low, + Medium, + High, +} + +/// Get ffmpeg quality settings. +fn get_quality_settings(quality: VideoQuality) -> (&'static str, &'static str) { + match quality { + VideoQuality::Low => ("2M", "128k"), + VideoQuality::Medium => ("5M", "192k"), + VideoQuality::High => ("10M", "320k"), + } +} + +/// Encode video using ffmpeg. +pub fn encode_video( + frame_files: &[PathBuf], + audio_file: &Path, + output_file: &Path, + fps: u32, + quality: VideoQuality, + overwrite: bool, +) -> Result<()> { + let (video_bitrate, _audio_bitrate) = get_quality_settings(quality); + + let tmp_dir = frame_files + .get(0) + .and_then(|p| p.parent()) + .unwrap_or(Path::new(".")); + + let frame_pattern = tmp_dir.join("frame_%06d.png"); + + let mut cmd = std::process::Command::new("ffmpeg"); + + if overwrite { + cmd.arg("-y"); + } + + cmd.args([ + "-framerate", + &fps.to_string(), + "-i", + frame_pattern.to_str().ok_or_else(|| anyhow!("Invalid frame pattern"))?, + "-i", + audio_file.to_str().ok_or_else(|| anyhow!("Invalid audio file path"))?, + "-r", + &fps.to_string(), + "-c:v", + "libx264", + "-b:v", + video_bitrate, + "-c:a", + "copy", + "-pix_fmt", + "yuv420p", + "-shortest", + output_file.to_str().ok_or_else(|| anyhow!("Invalid output path"))?, + ]); + + let output = cmd + .output() + .with_context(|| "Failed to execute ffmpeg")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("ffmpeg failed: {}", stderr)); + } + + println!("Video saved to: {}", output_file.display()); + + Ok(()) +} + +/// Clean up temporary files. +pub fn cleanup_tmp_dir(tmp_dir: &Path) { + if tmp_dir.exists() { + let _ = std::fs::remove_dir_all(tmp_dir); + } +}