commit 8199769093f4d7df97cb079108f98706a8a668cd Author: feie9456 Date: Sun Mar 29 01:19:51 2026 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c8944fe --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1762 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.11.0", + "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 = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.0", + "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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +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 = "claxon" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688" + +[[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" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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 0.54.0", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "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 = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[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 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[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.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +dependencies = [ + "cfg-if", + "futures-util", + "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 = "lewton" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" +dependencies = [ + "byteorder", + "ogg", + "tinyvec", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[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.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.11.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[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 0.3.1", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-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 = "ogg" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e" +dependencies = [ + "byteorder", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[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-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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 = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rodio" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" +dependencies = [ + "claxon", + "cpal", + "hound", + "lewton", + "symphonia", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-mp3", + "symphonia-core", + "symphonia-metadata", +] + +[[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-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-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 = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[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 = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow", +] + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "voice-ime" +version = "0.1.0" +dependencies = [ + "base64", + "cpal", + "futures-util", + "rodio", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "windows 0.61.3", + "winres", +] + +[[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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" +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 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[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-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[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 0.2.1", +] + +[[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-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[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 = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "winres" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" +dependencies = [ + "toml", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e3d2949 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "voice-ime" +version = "0.1.0" +edition = "2024" + +[dependencies] +windows = { version = "0.61", features = [ + "Win32_UI_WindowsAndMessaging", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Shell", + "Win32_Graphics_Gdi", + "Win32_System_LibraryLoader", + "Win32_Foundation", + "Win32_UI_Controls", +] } +cpal = "0.15" +tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "time"] } +tokio-tungstenite = { version = "0.26", features = ["native-tls"] } +futures-util = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" +rodio = "0.20" + +[build-dependencies] +winres = "0.1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..2315abf --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Voice IME 语音输入法 + +Windows 系统托盘语音输入工具。按下快捷键开始录音,通过阿里云 Qwen ASR 实时语音识别 API 将语音转为文字,自动输入到当前光标位置。 + +## 功能 + +- **快捷键切换录音**:默认 F10,按一次开始,再按一次停止 +- **流式语音识别**:使用 Qwen3 ASR Realtime API,支持 VAD 自动断句,边说边输入 +- **增量文本插入**:对识别结果做 diff,仅输入变化部分,不影响输入框已有内容 +- **系统托盘**:托盘图标显示当前状态(空闲/录音中),右键菜单提供设置 +- **音效提示**:录音开始和停止时播放提示音 +- **暂停媒体播放**:录音时可自动暂停系统媒体播放(可关闭) +- **可自定义配置**: + - 快捷键 + - API Key + - ASR 模型 + - 媒体暂停开关 + +## 使用方法 + +### 获取 API Key + +前往 [阿里云百炼](https://bailian.console.aliyun.com/) 开通 Qwen ASR 服务并获取 API Key。 + +### 运行 + +``` +cargo build --release +./target/release/voice-ime.exe +``` + +首次启动会弹窗要求输入 API Key。输入后程序最小化到系统托盘。 + +### 操作 + +| 操作 | 说明 | +|------|------| +| 按下快捷键(默认 F10) | 开始/停止录音 | +| 右键托盘图标 | 打开设置菜单 | + +### 右键菜单 + +- **设置快捷键** — 按下任意键即可更换 +- **录音时暂停媒体播放** — 勾选开关 +- **设置 API Key** — 修改 ASR 服务密钥 +- **设置模型** — 修改 ASR 模型名称 +- **退出** + +### 配置文件 + +配置保存在 `%APPDATA%\voice-ime\config.json`,格式示例: + +```json +{ + "hotkey_vk": 121, + "media_pause_enabled": true, + "api_key": "sk-xxxxxxxx", + "model": "qwen3-asr-flash-realtime-2026-02-10" +} +``` + +## 技术栈 + +- **Rust 2024 Edition** +- **windows** crate — Win32 API(托盘图标、热键、SendInput 文字输入) +- **cpal** — WASAPI 麦克风采集 +- **tokio + tokio-tungstenite** — 异步 WebSocket 客户端 +- **rodio** — 音效播放 +- **serde** — 配置序列化 + +## 系统要求 + +- Windows 10/11 +- 麦克风 +- 网络连接(用于访问阿里云 ASR API) + +## 许可证 + +MIT diff --git a/assets/idle.ico b/assets/idle.ico new file mode 100644 index 0000000..d2a5873 Binary files /dev/null and b/assets/idle.ico differ diff --git a/assets/recording.ico b/assets/recording.ico new file mode 100644 index 0000000..192bf88 Binary files /dev/null and b/assets/recording.ico differ diff --git a/assets/start.mp3 b/assets/start.mp3 new file mode 100644 index 0000000..8cbd9b3 Binary files /dev/null and b/assets/start.mp3 differ diff --git a/assets/stop.mp3 b/assets/stop.mp3 new file mode 100644 index 0000000..317caad Binary files /dev/null and b/assets/stop.mp3 differ diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..228e886 --- /dev/null +++ b/build.rs @@ -0,0 +1,7 @@ +fn main() { + if std::env::var_os("CARGO_CFG_TARGET_OS").as_deref() == Some(std::ffi::OsStr::new("windows")) { + let mut res = winres::WindowsResource::new(); + res.set_icon("assets/idle.ico"); + res.compile().expect("Failed to compile Windows resources"); + } +} diff --git a/convert_icon.py b/convert_icon.py new file mode 100644 index 0000000..d1e17f0 --- /dev/null +++ b/convert_icon.py @@ -0,0 +1,6 @@ +from PIL import Image +for name in ['idle', 'recording']: + img = Image.open(f'{name}.png').convert('RGBA') + sizes = [(16,16),(24,24),(32,32),(48,48),(64,64),(128,128),(256,256)] + img.save(f'{name}.ico', format='ICO', sizes=sizes) + print(f'{name}.ico created') \ No newline at end of file diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..85840e3 --- /dev/null +++ b/src/audio.rs @@ -0,0 +1,140 @@ +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{SampleFormat, Stream, StreamConfig}; +use tokio::sync::mpsc; + +const TARGET_SAMPLE_RATE: u32 = 16000; + +pub struct AudioCapture { + stream: Stream, +} + +pub struct AudioCaptureConfig { + #[allow(dead_code)] + pub sample_rate: u32, +} + +/// Linear interpolation resampling from `from_rate` to `to_rate`. +fn resample(samples: &[i16], from_rate: u32, to_rate: u32) -> Vec { + if from_rate == to_rate { + return samples.to_vec(); + } + let ratio = from_rate as f64 / to_rate as f64; + let out_len = (samples.len() as f64 / ratio) as usize; + let mut output = Vec::with_capacity(out_len); + for i in 0..out_len { + let src_pos = i as f64 * ratio; + let idx = src_pos as usize; + let frac = src_pos - idx as f64; + let s = if idx + 1 < samples.len() { + let a = samples[idx] as f64; + let b = samples[idx + 1] as f64; + (a + frac * (b - a)) as i16 + } else { + samples[idx.min(samples.len().saturating_sub(1))] + }; + output.push(s); + } + output +} + +/// Mix multi-channel i16 to mono, resample to 16kHz, return PCM bytes. +fn process_i16(data: &[i16], channels: u16, source_rate: u32) -> Vec { + let ch = channels as usize; + let mono: Vec = data + .chunks(ch) + .map(|frame| { + let sum: i32 = frame.iter().map(|&s| s as i32).sum(); + (sum / ch as i32) as i16 + }) + .collect(); + let resampled = resample(&mono, source_rate, TARGET_SAMPLE_RATE); + resampled.iter().flat_map(|s| s.to_le_bytes()).collect() +} + +/// Mix multi-channel f32 to mono, resample to 16kHz, return PCM bytes. +fn process_f32(data: &[f32], channels: u16, source_rate: u32) -> Vec { + let ch = channels as usize; + let mono: Vec = data + .chunks(ch) + .map(|frame| { + let sum: f32 = frame.iter().sum(); + let m = sum / ch as f32; + (m * 32768.0).clamp(-32768.0, 32767.0) as i16 + }) + .collect(); + let resampled = resample(&mono, source_rate, TARGET_SAMPLE_RATE); + resampled.iter().flat_map(|s| s.to_le_bytes()).collect() +} + +impl AudioCapture { + /// Start capturing audio from the default input device. + /// Audio data is always resampled to 16kHz mono PCM i16 LE. + pub fn start(tx: mpsc::UnboundedSender>) -> Result<(Self, AudioCaptureConfig), String> { + let host = cpal::default_host(); + let device = host + .default_input_device() + .ok_or_else(|| "No input device available".to_string())?; + + let default_config = device + .default_input_config() + .map_err(|e| format!("Failed to get default input config: {e}"))?; + + let source_rate = default_config.sample_rate().0; + let channels = default_config.channels(); + let sample_format = default_config.sample_format(); + let config: StreamConfig = default_config.into(); + + eprintln!("[voice-ime] Audio device: {source_rate}Hz, {channels}ch, {sample_format:?} → resampling to {TARGET_SAMPLE_RATE}Hz mono"); + + let stream = match sample_format { + SampleFormat::I16 => { + let ch = channels; + let rate = source_rate; + device + .build_input_stream( + &config, + move |data: &[i16], _: &cpal::InputCallbackInfo| { + let bytes = process_i16(data, ch, rate); + let _ = tx.send(bytes); + }, + |err| eprintln!("Audio capture error: {err}"), + None, + ) + .map_err(|e| format!("Failed to build i16 input stream: {e}"))? + } + SampleFormat::F32 => { + let ch = channels; + let rate = source_rate; + device + .build_input_stream( + &config, + move |data: &[f32], _: &cpal::InputCallbackInfo| { + let bytes = process_f32(data, ch, rate); + let _ = tx.send(bytes); + }, + |err| eprintln!("Audio capture error: {err}"), + None, + ) + .map_err(|e| format!("Failed to build f32 input stream: {e}"))? + } + _ => return Err(format!("Unsupported sample format: {sample_format:?}")), + }; + + stream + .play() + .map_err(|e| format!("Failed to start audio stream: {e}"))?; + + Ok(( + AudioCapture { stream }, + AudioCaptureConfig { + sample_rate: TARGET_SAMPLE_RATE, + }, + )) + } +} + +impl Drop for AudioCapture { + fn drop(&mut self) { + let _ = self.stream.pause(); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..fd53b1f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,116 @@ +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::fs; +use std::path::PathBuf; + +pub const DEFAULT_MODEL: &str = "qwen3-asr-flash-realtime-2026-02-10"; + +#[derive(Serialize, Deserialize, Clone)] +pub struct Config { + /// Virtual key code for the hotkey (default: VK_F10 = 0x79) + pub hotkey_vk: u16, + /// Whether to send media play/pause when toggling recording + pub media_pause_enabled: bool, + /// API key for Qwen3 ASR service + #[serde(default)] + pub api_key: String, + /// ASR model name + #[serde(default = "default_model")] + pub model: String, +} + +fn default_model() -> String { + DEFAULT_MODEL.to_string() +} + +impl Default for Config { + fn default() -> Self { + Config { + hotkey_vk: 0x79, // VK_F10 + media_pause_enabled: true, + api_key: String::new(), + model: DEFAULT_MODEL.to_string(), + } + } +} + +fn config_path() -> PathBuf { + let dir = std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| { + dirs_fallback_appdata() + }) + .join("voice-ime"); + let _ = fs::create_dir_all(&dir); + dir.join("config.json") +} + +/// Fallback: %USERPROFILE%\AppData\Roaming +fn dirs_fallback_appdata() -> PathBuf { + std::env::var_os("USERPROFILE") + .map(|p| PathBuf::from(p).join("AppData").join("Roaming")) + .unwrap_or_else(|| PathBuf::from(".")) +} + +thread_local! { + static CONFIG: RefCell = RefCell::new(Config::default()); +} + +pub fn load() { + let path = config_path(); + if let Ok(data) = fs::read_to_string(&path) { + if let Ok(cfg) = serde_json::from_str::(&data) { + CONFIG.with(|c| *c.borrow_mut() = cfg); + } + } +} + +pub fn save() { + let path = config_path(); + CONFIG.with(|c| { + if let Ok(json) = serde_json::to_string_pretty(&*c.borrow()) { + let _ = fs::write(&path, json); + } + }); +} + +pub fn get() -> Config { + CONFIG.with(|c| c.borrow().clone()) +} + +pub fn set_hotkey_vk(vk: u16) { + CONFIG.with(|c| c.borrow_mut().hotkey_vk = vk); + save(); +} + +pub fn set_media_pause(enabled: bool) { + CONFIG.with(|c| c.borrow_mut().media_pause_enabled = enabled); + save(); +} + +pub fn set_api_key(key: String) { + CONFIG.with(|c| c.borrow_mut().api_key = key); + save(); +} + +pub fn set_model(model: String) { + CONFIG.with(|c| c.borrow_mut().model = model); + save(); +} + +/// Return a display name for a virtual key code. +pub fn vk_name(vk: u16) -> String { + match vk { + 0x70..=0x87 => format!("F{}", vk - 0x70 + 1), + 0x21 => "PageUp".into(), + 0x22 => "PageDown".into(), + 0x23 => "End".into(), + 0x24 => "Home".into(), + 0x2D => "Insert".into(), + 0x2E => "Delete".into(), + 0x13 => "Pause".into(), + 0x91 => "ScrollLock".into(), + 0xC0 => "`".into(), + _ => format!("VK(0x{vk:02X})"), + } +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..8e68ad0 --- /dev/null +++ b/src/input.rs @@ -0,0 +1,139 @@ +use std::mem; +use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, + VIRTUAL_KEY, VK_BACK, VK_MEDIA_PLAY_PAUSE, +}; + +fn send_inputs(inputs: &[INPUT]) { + if inputs.is_empty() { + return; + } + unsafe { + SendInput(inputs, mem::size_of::() as i32); + } +} + +fn make_unicode_key_down(scan: u16) -> INPUT { + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(0), + wScan: scan, + dwFlags: KEYEVENTF_UNICODE, + time: 0, + dwExtraInfo: 0, + }, + }, + } +} + +fn make_unicode_key_up(scan: u16) -> INPUT { + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(0), + wScan: scan, + dwFlags: KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, + time: 0, + dwExtraInfo: 0, + }, + }, + } +} + +fn make_vk_key_down(vk: VIRTUAL_KEY) -> INPUT { + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 { + ki: KEYBDINPUT { + wVk: vk, + wScan: 0, + dwFlags: windows::Win32::UI::Input::KeyboardAndMouse::KEYBD_EVENT_FLAGS(0), + time: 0, + dwExtraInfo: 0, + }, + }, + } +} + +fn make_vk_key_up(vk: VIRTUAL_KEY) -> INPUT { + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 { + ki: KEYBDINPUT { + wVk: vk, + wScan: 0, + dwFlags: KEYEVENTF_KEYUP, + time: 0, + dwExtraInfo: 0, + }, + }, + } +} + +/// Send N backspace key presses. +fn send_backspaces(count: usize) { + if count == 0 { + return; + } + let mut inputs = Vec::with_capacity(count * 2); + for _ in 0..count { + inputs.push(make_vk_key_down(VK_BACK)); + inputs.push(make_vk_key_up(VK_BACK)); + } + send_inputs(&inputs); +} + +/// Type a string using SendInput with KEYEVENTF_UNICODE. +/// Handles surrogate pairs for characters outside BMP. +fn send_unicode_string(text: &str) { + if text.is_empty() { + return; + } + let mut inputs = Vec::new(); + for c in text.chars() { + let mut buf = [0u16; 2]; + let encoded = c.encode_utf16(&mut buf); + for &code_unit in encoded.iter() { + inputs.push(make_unicode_key_down(code_unit)); + inputs.push(make_unicode_key_up(code_unit)); + } + } + send_inputs(&inputs); +} + +/// Compute the common prefix length (in chars) between two strings. +fn common_prefix_chars(a: &str, b: &str) -> usize { + a.chars() + .zip(b.chars()) + .take_while(|(ca, cb)| ca == cb) + .count() +} + +/// Given the previously inserted text and the new full text from ASR, +/// send the minimal backspaces + new characters to update the input field. +/// Returns the new "last inserted" text (i.e. `current`). +pub fn apply_text_update(last: &str, current: &str) -> String { + let prefix_len = common_prefix_chars(last, current); + let last_char_count = last.chars().count(); + let backspace_count = last_char_count - prefix_len; + + // Get the byte offset where the common prefix ends in `current` + let new_suffix: String = current.chars().skip(prefix_len).collect(); + + send_backspaces(backspace_count); + send_unicode_string(&new_suffix); + + current.to_string() +} + +/// Simulate pressing the media Play/Pause key. +pub fn send_media_play_pause() { + let inputs = [ + make_vk_key_down(VK_MEDIA_PLAY_PAUSE), + make_vk_key_up(VK_MEDIA_PLAY_PAUSE), + ]; + send_inputs(&inputs); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..30d842f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,679 @@ +#![windows_subsystem = "windows"] + +mod audio; +mod config; +mod input; +mod session; +mod sound; +mod ws; + +use session::RecordingSession; +use std::cell::RefCell; +use windows::core::{w, PCWSTR}; +use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM}; +use windows::Win32::Graphics::Gdi::{GetStockObject, BLACK_BRUSH}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::Input::KeyboardAndMouse::{ + RegisterHotKey, UnregisterHotKey, HOT_KEY_MODIFIERS, +}; +use windows::Win32::UI::Shell::{ + Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY, + NOTIFYICONDATAW, +}; +use windows::Win32::UI::WindowsAndMessaging::*; + +static ICO_IDLE: &[u8] = include_bytes!("../assets/idle.ico"); +static ICO_RECORDING: &[u8] = include_bytes!("../assets/recording.ico"); + +/// Parse an ICO file and load the best-matching icon entry as an HICON. +fn load_icon_from_ico(data: &[u8]) -> HICON { + // ICO header: reserved(2) + type(2) + count(2) = 6 bytes + // Each entry: width(1) + height(1) + colorCount(1) + reserved(1) + // + planes(2) + bitCount(2) + bytesInRes(4) + imageOffset(4) = 16 bytes + if data.len() < 6 { + return HICON::default(); + } + let count = u16::from_le_bytes([data[4], data[5]]) as usize; + if count == 0 || data.len() < 6 + count * 16 { + return HICON::default(); + } + + // Get system small icon size for tray + let desired = unsafe { GetSystemMetrics(SM_CXSMICON) } as u32; + + // Find best entry: prefer exact match on desired size, else closest larger, else largest + let mut best_idx = 0; + let mut best_w = 0u32; + for i in 0..count { + let off = 6 + i * 16; + let w = if data[off] == 0 { 256 } else { data[off] as u32 }; + if w == desired { + best_idx = i; + best_w = w; + break; + } + if (w >= desired && (best_w < desired || w < best_w)) + || (best_w < desired && w > best_w) + { + best_idx = i; + best_w = w; + } + } + + let off = 6 + best_idx * 16; + let bytes_in_res = u32::from_le_bytes([data[off + 8], data[off + 9], data[off + 10], data[off + 11]]) as usize; + let image_offset = u32::from_le_bytes([data[off + 12], data[off + 13], data[off + 14], data[off + 15]]) as usize; + + if data.len() < image_offset + bytes_in_res { + return HICON::default(); + } + + let image_data = &data[image_offset..image_offset + bytes_in_res]; + unsafe { + CreateIconFromResourceEx( + image_data, + true, + 0x00030000, + desired as i32, + desired as i32, + LR_DEFAULTCOLOR, + ) + .unwrap_or_default() + } +} + +const WM_TRAYICON: u32 = WM_APP + 1; +const HOTKEY_ID: i32 = 1; +const IDM_EXIT: usize = 1001; +const IDM_CHANGE_HOTKEY: usize = 1002; +const IDM_TOGGLE_MEDIA_PAUSE: usize = 1003; +const IDM_SET_API_KEY: usize = 1004; +const IDM_SET_MODEL: usize = 1005; + +thread_local! { + static SESSION: RefCell> = RefCell::new(None); + static HWND_MAIN: RefCell = RefCell::new(HWND::default()); + /// When true, next key press in the dialog sets the hotkey. + static PICKING_HOTKEY: RefCell = RefCell::new(false); +} + +fn set_tray_tooltip(hwnd: HWND, tip: &str, recording: bool) { + let mut nid = NOTIFYICONDATAW { + cbSize: std::mem::size_of::() as u32, + hWnd: hwnd, + uID: 1, + uFlags: NIF_TIP | NIF_ICON, + ..Default::default() + }; + + // Set tooltip + let tip_wide: Vec = tip.encode_utf16().chain(std::iter::once(0)).collect(); + let len = tip_wide.len().min(nid.szTip.len()); + nid.szTip[..len].copy_from_slice(&tip_wide[..len]); + + nid.hIcon = if recording { + load_icon_from_ico(ICO_RECORDING) + } else { + load_icon_from_ico(ICO_IDLE) + }; + + unsafe { + let _ = Shell_NotifyIconW(NIM_MODIFY, &nid); + } +} + +fn toggle_recording(hwnd: HWND) { + SESSION.with(|s| { + let mut session = s.borrow_mut(); + let cfg = config::get(); + if session.is_some() { + // Stop recording + session.take().unwrap().stop(); + set_tray_tooltip(hwnd, "语音输入 - 空闲", false); + sound::play_stop(); + if cfg.media_pause_enabled { + input::send_media_play_pause(); + } + } else { + // Start recording + if cfg.media_pause_enabled { + input::send_media_play_pause(); + } + match RecordingSession::start() { + Ok(s) => { + *session = Some(s); + set_tray_tooltip(hwnd, "语音输入 - 录音中...", true); + sound::play_start(); + } + Err(e) => { + eprintln!("[voice-ime] Failed to start recording: {e}"); + set_tray_tooltip(hwnd, &format!("语音输入 - 错误: {e}"), false); + } + } + } + }); +} + +/// Show a generic single-line text input dialog. Returns Some(text) if confirmed, None if cancelled. +fn show_text_input_dialog(hwnd: HWND, title: &str, label: &str, initial: &str) -> Option { + unsafe { + let instance = GetModuleHandleW(None).unwrap(); + + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + lpfnWndProc: Some(textinput_wnd_proc), + hInstance: instance.into(), + lpszClassName: w!("VoiceIMETextInput"), + hbrBackground: std::mem::transmute(GetStockObject( + windows::Win32::Graphics::Gdi::WHITE_BRUSH, + )), + ..Default::default() + }; + RegisterClassExW(&wc); + + let screen_w = GetSystemMetrics(SM_CXSCREEN); + let screen_h = GetSystemMetrics(SM_CYSCREEN); + let dlg_w = 420; + let dlg_h = 160; + + TEXTINPUT_RESULT.with(|r| *r.borrow_mut() = None); + TEXTINPUT_LABEL.with(|r| *r.borrow_mut() = label.to_string()); + TEXTINPUT_INITIAL.with(|r| *r.borrow_mut() = initial.to_string()); + + let mut title_w: Vec = title.encode_utf16().collect(); + title_w.push(0); + + let dlg = CreateWindowExW( + WS_EX_TOPMOST | WS_EX_TOOLWINDOW, + w!("VoiceIMETextInput"), + PCWSTR(title_w.as_ptr()), + WS_POPUP | WS_CAPTION | WS_SYSMENU, + (screen_w - dlg_w) / 2, + (screen_h - dlg_h) / 2, + dlg_w, + dlg_h, + Some(hwnd), + None, + Some(instance.into()), + None, + ) + .unwrap(); + + let _ = ShowWindow(dlg, SW_SHOW); + let _ = SetForegroundWindow(dlg); + + let mut msg = MSG::default(); + while GetMessageW(&mut msg, None, 0, 0).as_bool() { + if !IsWindow(Some(dlg)).as_bool() { + break; + } + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + TEXTINPUT_RESULT.with(|r| r.borrow().clone()).filter(|s| !s.is_empty()) + } +} + +thread_local! { + static TEXTINPUT_RESULT: RefCell> = RefCell::new(None); + static TEXTINPUT_LABEL: RefCell = RefCell::new(String::new()); + static TEXTINPUT_INITIAL: RefCell = RefCell::new(String::new()); + static TEXTINPUT_EDIT_HWND: RefCell = RefCell::new(HWND::default()); +} + +const IDC_TEXTINPUT_EDIT: i32 = 3001; +const IDC_TEXTINPUT_OK: i32 = 3002; + +unsafe extern "system" fn textinput_wnd_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_CREATE => { + let instance = unsafe { GetModuleHandleW(None).unwrap() }; + + let label_text = TEXTINPUT_LABEL.with(|r| r.borrow().clone()); + let mut label_w: Vec = label_text.encode_utf16().collect(); + label_w.push(0); + unsafe { + let _ = CreateWindowExW( + WINDOW_EX_STYLE::default(), + w!("STATIC"), + PCWSTR(label_w.as_ptr()), + WS_CHILD | WS_VISIBLE, + 15, 15, 380, 20, + Some(hwnd), None, Some(instance.into()), None, + ); + } + + let init_text = TEXTINPUT_INITIAL.with(|r| r.borrow().clone()); + let mut init_w: Vec = init_text.encode_utf16().collect(); + init_w.push(0); + let edit = unsafe { + CreateWindowExW( + WS_EX_CLIENTEDGE, + w!("EDIT"), + PCWSTR(init_w.as_ptr()), + WS_CHILD | WS_VISIBLE | WINDOW_STYLE(0x0080), + 15, 42, 375, 25, + Some(hwnd), Some(HMENU(IDC_TEXTINPUT_EDIT as _)), + Some(instance.into()), None, + ).unwrap() + }; + TEXTINPUT_EDIT_HWND.with(|h| *h.borrow_mut() = edit); + + unsafe { + let _ = CreateWindowExW( + WINDOW_EX_STYLE::default(), + w!("BUTTON"), + w!("确定"), + WS_CHILD | WS_VISIBLE | WINDOW_STYLE(0x0001), + 160, 80, 90, 30, + Some(hwnd), Some(HMENU(IDC_TEXTINPUT_OK as _)), + Some(instance.into()), None, + ); + } + + unsafe { + let _ = SendMessageW(edit, 0x00B1, Some(WPARAM(0)), Some(LPARAM(-1))); + let _ = windows::Win32::UI::Input::KeyboardAndMouse::SetFocus(Some(edit)); + } + LRESULT(0) + } + WM_COMMAND => { + let id = (wparam.0 & 0xFFFF) as i32; + if id == IDC_TEXTINPUT_OK { + let edit = TEXTINPUT_EDIT_HWND.with(|h| *h.borrow()); + let len = unsafe { GetWindowTextLengthW(edit) } as usize; + let mut buf = vec![0u16; len + 1]; + unsafe { GetWindowTextW(edit, &mut buf) }; + let text = String::from_utf16_lossy(&buf[..len]); + TEXTINPUT_RESULT.with(|r| *r.borrow_mut() = Some(text.trim().to_string())); + unsafe { let _ = DestroyWindow(hwnd); } + } + LRESULT(0) + } + WM_CLOSE => { + TEXTINPUT_RESULT.with(|r| *r.borrow_mut() = Some(String::new())); + unsafe { let _ = DestroyWindow(hwnd); } + LRESULT(0) + } + WM_DESTROY => { + unsafe { PostQuitMessage(0); } + LRESULT(0) + } + _ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }, + } +} + +/// Show API key input dialog. Returns true if user provided a key. +fn show_api_key_dialog(hwnd: HWND) -> bool { + let current = config::get().api_key; + if let Some(key) = show_text_input_dialog(hwnd, "设置 API Key", "请输入 Qwen ASR API Key:", ¤t) { + config::set_api_key(key); + true + } else { + false + } +} + +/// Show model input dialog. +fn show_model_dialog(hwnd: HWND) { + let current = config::get().model; + if let Some(model) = show_text_input_dialog(hwnd, "设置模型", "请输入 ASR 模型名称:", ¤t) { + config::set_model(model); + } +} + +fn show_context_menu(hwnd: HWND) { + unsafe { + let cfg = config::get(); + let menu = CreatePopupMenu().unwrap(); + + // Hotkey item + let hotkey_label: Vec = format!("设置快捷键 (当前: {})\0", config::vk_name(cfg.hotkey_vk)) + .encode_utf16() + .collect(); + AppendMenuW(menu, MF_STRING, IDM_CHANGE_HOTKEY, PCWSTR(hotkey_label.as_ptr())).unwrap(); + + // Media pause toggle + let pause_label: Vec = "录音时暂停媒体播放\0".encode_utf16().collect(); + let flags = if cfg.media_pause_enabled { + MF_STRING | MF_CHECKED + } else { + MF_STRING | MF_UNCHECKED + }; + AppendMenuW(menu, flags, IDM_TOGGLE_MEDIA_PAUSE, PCWSTR(pause_label.as_ptr())).unwrap(); + + // API Key + let api_key_label: Vec = "设置 API Key...\0".encode_utf16().collect(); + AppendMenuW(menu, MF_STRING, IDM_SET_API_KEY, PCWSTR(api_key_label.as_ptr())).unwrap(); + + // Model + let model_label: Vec = format!("设置模型 ({})...\0", cfg.model).encode_utf16().collect(); + AppendMenuW(menu, MF_STRING, IDM_SET_MODEL, PCWSTR(model_label.as_ptr())).unwrap(); + + // Separator + AppendMenuW(menu, MF_SEPARATOR, 0, None).unwrap(); + + // Exit + let exit_label: Vec = "退出\0".encode_utf16().collect(); + AppendMenuW(menu, MF_STRING, IDM_EXIT, PCWSTR(exit_label.as_ptr())).unwrap(); + + let mut pt = windows::Win32::Foundation::POINT::default(); + let _ = GetCursorPos(&mut pt); + + // Required for the menu to disappear when clicking outside + let _ = SetForegroundWindow(hwnd); + + let _ = TrackPopupMenu(menu, TPM_BOTTOMALIGN | TPM_LEFTALIGN, pt.x, pt.y, Some(0), hwnd, None); + let _ = DestroyMenu(menu); + } +} + +/// Register the global hotkey using the current config. +fn register_configured_hotkey(hwnd: HWND) -> bool { + let vk = config::get().hotkey_vk; + unsafe { + RegisterHotKey( + Some(hwnd), + HOTKEY_ID, + HOT_KEY_MODIFIERS(0), + vk as u32, + ) + .is_ok() + } +} + +/// Show a small dialog that captures the next key press as the new hotkey. +fn show_hotkey_picker(hwnd: HWND) { + // Create a small popup window for key capture + unsafe { + let instance = GetModuleHandleW(None).unwrap(); + + // Register class for picker window (once is fine, re-register is harmless) + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + lpfnWndProc: Some(picker_wnd_proc), + hInstance: instance.into(), + lpszClassName: w!("VoiceIMEPicker"), + hbrBackground: std::mem::transmute(GetStockObject( + windows::Win32::Graphics::Gdi::WHITE_BRUSH, + )), + ..Default::default() + }; + RegisterClassExW(&wc); + + // Get screen center + let screen_w = GetSystemMetrics(SM_CXSCREEN); + let screen_h = GetSystemMetrics(SM_CYSCREEN); + let dlg_w = 320; + let dlg_h = 120; + + let picker = CreateWindowExW( + WS_EX_TOPMOST | WS_EX_TOOLWINDOW, + w!("VoiceIMEPicker"), + w!("设置快捷键"), + WS_POPUP | WS_CAPTION | WS_SYSMENU, + (screen_w - dlg_w) / 2, + (screen_h - dlg_h) / 2, + dlg_w, + dlg_h, + Some(hwnd), + None, + Some(instance.into()), + None, + ) + .unwrap(); + + // Unregister current hotkey so the key can be captured + let _ = UnregisterHotKey(Some(hwnd), HOTKEY_ID); + PICKING_HOTKEY.with(|p| *p.borrow_mut() = true); + + let _ = ShowWindow(picker, SW_SHOW); + let _ = SetForegroundWindow(picker); + + // Run a modal message loop for the picker + let mut msg = MSG::default(); + while GetMessageW(&mut msg, None, 0, 0).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + if !PICKING_HOTKEY.with(|p| *p.borrow()) { + break; + } + } + + // Re-register hotkey with (possibly new) config + if !register_configured_hotkey(hwnd) { + let text: Vec = format!( + "无法注册快捷键 {},可能被其他程序占用。\0", + config::vk_name(config::get().hotkey_vk) + ) + .encode_utf16() + .collect(); + let title: Vec = "语音输入\0".encode_utf16().collect(); + MessageBoxW( + Some(hwnd), + PCWSTR(text.as_ptr()), + PCWSTR(title.as_ptr()), + MB_OK | MB_ICONWARNING, + ); + } + } +} + +unsafe extern "system" fn picker_wnd_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_CREATE => { + // Create a static label + let text: Vec = "请按下新的快捷键...\0".encode_utf16().collect(); + let instance = unsafe { GetModuleHandleW(None).unwrap() }; + unsafe { + let _ = CreateWindowExW( + WINDOW_EX_STYLE::default(), + w!("STATIC"), + PCWSTR(text.as_ptr()), + WS_CHILD | WS_VISIBLE | WINDOW_STYLE(0x01), + 0, + 25, + 320, + 40, + Some(hwnd), + None, + Some(instance.into()), + None, + ); + } + LRESULT(0) + } + WM_KEYDOWN | WM_SYSKEYDOWN => { + let vk = (wparam.0 & 0xFF) as u16; + // Ignore modifier-only keys + if !matches!(vk, 0x10 | 0x11 | 0x12 | 0xA0..=0xA5) { + config::set_hotkey_vk(vk); + PICKING_HOTKEY.with(|p| *p.borrow_mut() = false); + unsafe { let _ = DestroyWindow(hwnd); } + } + LRESULT(0) + } + WM_CLOSE => { + PICKING_HOTKEY.with(|p| *p.borrow_mut() = false); + unsafe { let _ = DestroyWindow(hwnd); } + LRESULT(0) + } + WM_DESTROY => { + PICKING_HOTKEY.with(|p| *p.borrow_mut() = false); + LRESULT(0) + } + _ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }, + } +} + +unsafe extern "system" fn wnd_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_HOTKEY => { + if wparam.0 == HOTKEY_ID as usize { + toggle_recording(hwnd); + } + LRESULT(0) + } + WM_COMMAND => { + let id = (wparam.0 & 0xFFFF) as usize; + if id == IDM_EXIT { + // Clean up and exit + SESSION.with(|s| { + let mut session = s.borrow_mut(); + if let Some(mut sess) = session.take() { + sess.stop(); + } + }); + unsafe { DestroyWindow(hwnd).unwrap() }; + } else if id == IDM_CHANGE_HOTKEY { + show_hotkey_picker(hwnd); + // Update tray tooltip with new hotkey name + set_tray_tooltip(hwnd, &format!("语音输入 ({})", config::vk_name(config::get().hotkey_vk)), false); + } else if id == IDM_TOGGLE_MEDIA_PAUSE { + let enabled = config::get().media_pause_enabled; + config::set_media_pause(!enabled); + } else if id == IDM_SET_API_KEY { + show_api_key_dialog(hwnd); + } else if id == IDM_SET_MODEL { + show_model_dialog(hwnd); + } + LRESULT(0) + } + x if x == WM_TRAYICON => { + let event = (lparam.0 & 0xFFFF) as u32; + if event == WM_RBUTTONUP { + show_context_menu(hwnd); + } + LRESULT(0) + } + WM_DESTROY => { + // Remove tray icon + let nid = NOTIFYICONDATAW { + cbSize: std::mem::size_of::() as u32, + hWnd: hwnd, + uID: 1, + ..Default::default() + }; + unsafe { let _ = Shell_NotifyIconW(NIM_DELETE, &nid); }; + + // Unregister hotkey + unsafe { let _ = UnregisterHotKey(Some(hwnd), HOTKEY_ID); }; + + unsafe { PostQuitMessage(0) }; + LRESULT(0) + } + _ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }, + } +} + +fn create_hidden_window() -> HWND { + unsafe { + let instance = GetModuleHandleW(None).unwrap(); + + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + lpfnWndProc: Some(wnd_proc), + hInstance: instance.into(), + lpszClassName: w!("VoiceIMEWindow"), + hbrBackground: std::mem::transmute(GetStockObject(BLACK_BRUSH)), + ..Default::default() + }; + + RegisterClassExW(&wc); + + let hwnd = CreateWindowExW( + WINDOW_EX_STYLE::default(), + w!("VoiceIMEWindow"), + w!("Voice IME"), + WS_OVERLAPPEDWINDOW, + 0, + 0, + 0, + 0, + Some(HWND_MESSAGE), // Message-only window + None, + Some(instance.into()), + None, + ) + .unwrap(); + + hwnd + } +} + +fn create_tray_icon(hwnd: HWND) { + let mut nid = NOTIFYICONDATAW { + cbSize: std::mem::size_of::() as u32, + hWnd: hwnd, + uID: 1, + uFlags: NIF_MESSAGE | NIF_ICON | NIF_TIP, + uCallbackMessage: WM_TRAYICON, + ..Default::default() + }; + + nid.hIcon = load_icon_from_ico(ICO_IDLE); + + let tip = "语音输入 - 空闲 (F10)"; + let tip_wide: Vec = tip.encode_utf16().chain(std::iter::once(0)).collect(); + let len = tip_wide.len().min(nid.szTip.len()); + nid.szTip[..len].copy_from_slice(&tip_wide[..len]); + + unsafe { + let _ = Shell_NotifyIconW(NIM_ADD, &nid); + } +} + +fn main() { + config::load(); + + let hwnd = create_hidden_window(); + + HWND_MAIN.with(|h| *h.borrow_mut() = hwnd); + + // Prompt for API key if not configured + if config::get().api_key.is_empty() { + if !show_api_key_dialog(hwnd) { + return; + } + } + + // Register global hotkey from config + if !register_configured_hotkey(hwnd) { + eprintln!("[voice-ime] Failed to register hotkey. Is another instance running?"); + return; + } + + create_tray_icon(hwnd); + + let hotkey_name = config::vk_name(config::get().hotkey_vk); + set_tray_tooltip(hwnd, &format!("语音输入 ({})", hotkey_name), false); + + eprintln!("[voice-ime] Running. Press {} to toggle recording.", hotkey_name); + + // Message loop + unsafe { + let mut msg = MSG::default(); + while GetMessageW(&mut msg, None, 0, 0).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + + eprintln!("[voice-ime] Exiting."); +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..ace94ad --- /dev/null +++ b/src/session.rs @@ -0,0 +1,99 @@ +use std::sync::{Arc, Mutex}; +use std::thread; +use tokio::sync::mpsc; + +use crate::audio::AudioCapture; +use crate::config; +use crate::input; +use crate::ws; + +/// Tracks text state across multiple speech segments in a VAD session. +struct TextState { + /// Concatenation of all completed segment transcripts. + completed_text: String, + /// Current segment's partial preview (text + stash from latest .text event). + current_partial: String, + /// The full text we last typed into the input field. + last_displayed: String, +} + +pub struct RecordingSession { + stop_tx: Option>, + thread_handle: Option>, + _capture: Option, +} + +impl RecordingSession { + pub fn start() -> Result { + let (stop_tx, stop_rx) = mpsc::channel::<()>(1); + let (audio_tx, audio_rx) = mpsc::unbounded_channel::>(); + + let (capture, _audio_cfg) = AudioCapture::start(audio_tx)?; + + let cfg = config::get(); + let api_key = cfg.api_key.clone(); + let model = cfg.model.clone(); + + let thread_handle = thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create tokio runtime"); + + let state = Arc::new(Mutex::new(TextState { + completed_text: String::new(), + current_partial: String::new(), + last_displayed: String::new(), + })); + + let on_event = { + let state = state.clone(); + move |event: ws::AsrEvent| { + let mut st = state.lock().unwrap(); + match event { + ws::AsrEvent::Partial { text, stash } => { + st.current_partial = format!("{text}{stash}"); + let full = format!("{}{}", st.completed_text, st.current_partial); + st.last_displayed = input::apply_text_update(&st.last_displayed, &full); + } + ws::AsrEvent::SegmentCompleted { transcript } => { + st.completed_text.push_str(&transcript); + st.current_partial.clear(); + let full = st.completed_text.clone(); + st.last_displayed = input::apply_text_update(&st.last_displayed, &full); + } + } + } + }; + + let result = rt.block_on(ws::run_ws_session(&api_key, &model, audio_rx, stop_rx, on_event)); + + if let Err(e) = result { + eprintln!("[voice-ime] Recording session error: {e}"); + } + }); + + Ok(RecordingSession { + stop_tx: Some(stop_tx), + thread_handle: Some(thread_handle), + _capture: Some(capture), + }) + } + + pub fn stop(&mut self) { + self._capture.take(); + + if let Some(tx) = self.stop_tx.take() { + let _ = tx.blocking_send(()); + } + if let Some(handle) = self.thread_handle.take() { + let _ = handle.join(); + } + } +} + +impl Drop for RecordingSession { + fn drop(&mut self) { + self.stop(); + } +} diff --git a/src/sound.rs b/src/sound.rs new file mode 100644 index 0000000..7dd2682 --- /dev/null +++ b/src/sound.rs @@ -0,0 +1,31 @@ +use rodio::{Decoder, OutputStream, Sink}; +use std::io::Cursor; + +static SND_START: &[u8] = include_bytes!("../assets/start.mp3"); +static SND_STOP: &[u8] = include_bytes!("../assets/stop.mp3"); + +/// Play an embedded sound in a background thread (non-blocking). +fn play_bytes(data: &'static [u8]) { + std::thread::spawn(move || { + let Ok((_stream, handle)) = OutputStream::try_default() else { + return; + }; + let Ok(sink) = Sink::try_new(&handle) else { + return; + }; + let cursor = Cursor::new(data); + let Ok(source) = Decoder::new(cursor) else { + return; + }; + sink.append(source); + sink.sleep_until_end(); + }); +} + +pub fn play_start() { + play_bytes(SND_START); +} + +pub fn play_stop() { + play_bytes(SND_STOP); +} diff --git a/src/ws.rs b/src/ws.rs new file mode 100644 index 0000000..2f91187 --- /dev/null +++ b/src/ws.rs @@ -0,0 +1,230 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; +use tokio::sync::mpsc; +use tokio_tungstenite::{ + connect_async_tls_with_config, + tungstenite::{http::Request, Message}, +}; + +const WS_BASE_URL: &str = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime?model="; + +/// Events emitted by the ASR WebSocket session. +pub enum AsrEvent { + /// Partial result for current speech segment. Full preview = text + stash. + Partial { text: String, stash: String }, + /// Final result for a completed speech segment. + SegmentCompleted { transcript: String }, +} + +/// Run a WebSocket ASR session using the Qwen3 ASR Realtime API. +pub async fn run_ws_session( + api_key: &str, + model: &str, + mut audio_rx: mpsc::UnboundedReceiver>, + mut stop_rx: mpsc::Receiver<()>, + on_event: impl Fn(AsrEvent) + Send + 'static, +) -> Result<(), String> { + let ws_url = format!("{WS_BASE_URL}{model}"); + // Build request with auth headers + let request = Request::builder() + .uri(&ws_url) + .header("Authorization", format!("Bearer {api_key}")) + .header("OpenAI-Beta", "realtime=v1") + .header("Host", "dashscope.aliyuncs.com") + .header("Connection", "Upgrade") + .header("Upgrade", "websocket") + .header("Sec-WebSocket-Version", "13") + .header( + "Sec-WebSocket-Key", + tokio_tungstenite::tungstenite::handshake::client::generate_key(), + ) + .body(()) + .map_err(|e| format!("Build request failed: {e}"))?; + + let (ws_stream, _) = connect_async_tls_with_config(request, None, false, None) + .await + .map_err(|e| format!("WebSocket connect failed: {e}"))?; + + let (mut write, mut read) = ws_stream.split(); + + // 1. Wait for session.created + wait_for_type(&mut read, "session.created").await?; + eprintln!("[voice-ime] WS: session.created"); + + // 2. Send session.update (VAD mode) + let session_update = serde_json::json!({ + "event_id": "evt_session_update", + "type": "session.update", + "session": { + "modalities": ["text"], + "input_audio_format": "pcm16", + "sample_rate": 16000, + "input_audio_transcription": { + "language": "zh" + }, + "turn_detection": { + "type": "server_vad", + "threshold": 0.0, + "silence_duration_ms": 400 + } + } + }); + write + .send(Message::Text(session_update.to_string().into())) + .await + .map_err(|e| format!("Send session.update failed: {e}"))?; + + // 3. Wait for session.updated + wait_for_type(&mut read, "session.updated").await?; + eprintln!("[voice-ime] WS: session.updated, streaming audio..."); + + // 4. Spawn sender task: forwards audio as base64 JSON events + let (finish_tx, mut finish_rx) = mpsc::channel::<()>(1); + + let send_task = tokio::spawn(async move { + let mut seq = 0u64; + loop { + tokio::select! { + biased; + _ = stop_rx.recv() => { + // Drain remaining audio + while let Ok(chunk) = audio_rx.try_recv() { + let _ = send_audio(&mut write, &chunk, &mut seq).await; + } + // Send session.finish + let finish = serde_json::json!({ + "event_id": "evt_finish", + "type": "session.finish" + }); + let _ = write.send(Message::Text(finish.to_string().into())).await; + let _ = finish_tx.send(()).await; + break; + } + chunk = audio_rx.recv() => { + match chunk { + Some(data) => { + if send_audio(&mut write, &data, &mut seq).await.is_err() { + break; + } + } + None => { + // Audio channel closed + let finish = serde_json::json!({ + "event_id": "evt_finish", + "type": "session.finish" + }); + let _ = write.send(Message::Text(finish.to_string().into())).await; + let _ = finish_tx.send(()).await; + break; + } + } + } + } + } + }); + + // 5. Receive task: process server events + let recv_task = tokio::spawn(async move { + let mut finish_sent = false; + loop { + tokio::select! { + biased; + _ = finish_rx.recv(), if !finish_sent => { + finish_sent = true; + // Keep reading until session.finished + } + msg = read.next() => { + match msg { + Some(Ok(Message::Text(text))) => { + let v: Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => continue, + }; + let event_type = v["type"].as_str().unwrap_or(""); + match event_type { + "conversation.item.input_audio_transcription.text" => { + let text_part = v["text"].as_str().unwrap_or("").to_string(); + let stash = v["stash"].as_str().unwrap_or("").to_string(); + on_event(AsrEvent::Partial { text: text_part, stash }); + } + "conversation.item.input_audio_transcription.completed" => { + let transcript = v["transcript"].as_str().unwrap_or("").to_string(); + eprintln!("[voice-ime] Segment completed: {transcript}"); + on_event(AsrEvent::SegmentCompleted { transcript }); + } + "session.finished" => { + eprintln!("[voice-ime] WS: session.finished"); + return; + } + "error" => { + let msg = v["error"]["message"].as_str().unwrap_or("unknown"); + eprintln!("[voice-ime] ASR error: {msg}"); + return; + } + _ => {} // ignore speech_started, speech_stopped, committed, etc. + } + } + Some(Ok(_)) => {} // ping/pong/binary + Some(Err(e)) => { + eprintln!("[voice-ime] WS read error: {e}"); + return; + } + None => return, + } + } + } + } + }); + + let _ = send_task.await; + let _ = recv_task.await; + + Ok(()) +} + +/// Send a PCM audio chunk as a base64-encoded input_audio_buffer.append event. +async fn send_audio(write: &mut S, pcm_bytes: &[u8], seq: &mut u64) -> Result<(), String> +where + S: futures_util::Sink + Unpin, + S::Error: std::fmt::Display, +{ + let encoded = STANDARD.encode(pcm_bytes); + *seq += 1; + let event = serde_json::json!({ + "event_id": format!("evt_audio_{seq}"), + "type": "input_audio_buffer.append", + "audio": encoded + }); + write + .send(Message::Text(event.to_string().into())) + .await + .map_err(|e| format!("Send audio failed: {e}")) +} + +/// Read messages until one matches the expected type. +async fn wait_for_type(read: &mut S, expected: &str) -> Result +where + S: futures_util::Stream> + Unpin, +{ + loop { + match read.next().await { + Some(Ok(Message::Text(text))) => { + let v: Value = serde_json::from_str(&text) + .map_err(|e| format!("Parse JSON failed: {e}"))?; + let t = v["type"].as_str().unwrap_or(""); + if t == expected { + return Ok(v); + } + if t == "error" { + let msg = v["error"]["message"].as_str().unwrap_or("unknown"); + return Err(format!("Server error: {msg}")); + } + // Ignore other event types while waiting + } + Some(Ok(_)) => {} // ignore non-text + Some(Err(e)) => return Err(format!("WS read error: {e}")), + None => return Err("Connection closed unexpectedly".to_string()), + } + } +}