diff --git a/.gitignore b/.gitignore index 319cb0c..ed30599 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,13 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +# Python +.venv +.mypy_cache +__pycache__ + # Misc .DS_Store *.pem .mi.json +xiaozhi.json diff --git a/packages/server-python/.python-version b/packages/server-python/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/packages/server-python/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/packages/server-python/Cargo.lock b/packages/server-python/Cargo.lock new file mode 100644 index 0000000..917a777 --- /dev/null +++ b/packages/server-python/Cargo.lock @@ -0,0 +1,903 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[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 = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010" + +[[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 = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "open-xiaoai" +version = "1.0.0" +dependencies = [ + "futures", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "uuid", +] + +[[package]] +name = "open_xiaoai_server" +version = "1.0.0" +dependencies = [ + "open-xiaoai", + "pyo3", + "pyo3-async-runtimes", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + +[[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.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17da310086b068fbdcefbba30aeb3721d5bb9af8db4987d6735b2183ca567229" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-async-runtimes" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0b83dc42f9d41f50d38180dad65f0c99763b65a3ff2a81bf351dd35a1df8bf" +dependencies = [ + "futures", + "once_cell", + "pin-project-lite", + "pyo3", + "pyo3-async-runtimes-macros", + "tokio", +] + +[[package]] +name = "pyo3-async-runtimes-macros" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf103ba4062fbb1e8022d9ed9b9830fbab074b2db0a0496c78e45a62f4330bcd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e27165889bd793000a098bb966adc4300c312497ea25cf7a690a9f0ac5aa5fc1" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05280526e1dbf6b420062f3ef228b78c0c54ba94e157f5cb724a609d0f2faabc" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3ce5686aa4d3f63359a5100c62a127c9f15e8398e5fdeb5deef1fed5cd5f44" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4cf6faa0cbfb0ed08e89beb8103ae9724eb4750e3a78084ba4017cbe94f3855" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha", + "rand_core", + "zerocopy", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[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 = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "socket2" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[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", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "uuid" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "zerocopy" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/packages/server-python/Cargo.toml b/packages/server-python/Cargo.toml new file mode 100644 index 0000000..710e156 --- /dev/null +++ b/packages/server-python/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "open_xiaoai_server" +version = "1.0.0" +edition = "2021" + +[lib] +name = "open_xiaoai_server" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.24.0", features = ["extension-module", "abi3-py38"] } +pyo3-async-runtimes = { version = "0.24", features = [ + "attributes", + "tokio-runtime", +] } +open-xiaoai = { path = "../client-rust" } +tokio = { version = "1.32.0", features = ["full"] } +tokio-tungstenite = "0.26.2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" \ No newline at end of file diff --git a/packages/server-python/README.md b/packages/server-python/README.md new file mode 100644 index 0000000..a9cf594 --- /dev/null +++ b/packages/server-python/README.md @@ -0,0 +1,45 @@ +# Python Server + +[Open-XiaoAI](https://github.com/idootop/open-xiaoai) 的 Python 版 Server 端,用来演示小爱音箱接入[小智 AI](https://github.com/78/xiaozhi-esp32)。 + +> [!NOTE] +> 该演示使用的 Python 版小智 AI 客户端基于 [py-xiaozhi](https://github.com/Huang-junsen/py-xiaozhi) 项目,特此鸣谢。 + +## 环境准备 + +为了能够正常编译运行该项目,你需要安装以下依赖环境/工具: + +- uv:https://github.com/astral-sh/uv +- Rust: https://www.rust-lang.org/learn/get-started + +> 如果你是 macOS 系统,还需要手动安装 [Opus](https://opus-codec.org/) 👉 `brew install opus` + +## 编译运行 + +> [!NOTE] +> 请先确认你已经将小爱音箱刷机成功,并安装运行了 Rust 补丁程序 [👉 教程](../client-rust/README.md),否则该 Python Server 端启动后收不到音频输入,将无法正常工作。 + +```bash +# 安装 Python 依赖 +uv sync + +# 编译运行 +uv run main.py +``` + +如果你只是想体验一下小智 AI,请使用以下命令启动: + +```bash +uv run main.py --mode xiaozhi +``` + +该模式下使用电脑的麦克风和扬声器作为音频输入输出设备,无需连接小爱音箱。 + +## 注意事项 + +首次启动会自动打开小智 AI [管理后台](https://xiaozhi.me/),然后提供一个验证码用来绑定设备。 + +请按照提示注册你的小智 AI 账号,然后创建 Agent 绑定设备即可开始体验。 + +> [!NOTE] +> 本项目只是一个简单的演示程序,抛砖引玉。如果你需要更多的功能,比如唤醒词识别、语音转文字、连续对话等,可以参考开源的 [xiaozhi-esp32-server](https://github.com/xinnan-tech/xiaozhi-esp32-server) 项目,借助 Python 丰富的 AI 生态,自行实现自己想要的功能。 diff --git a/packages/server-python/main.py b/packages/server-python/main.py new file mode 100644 index 0000000..94c1717 --- /dev/null +++ b/packages/server-python/main.py @@ -0,0 +1,32 @@ +import logging +import signal +import sys + +from xiaozhi.services.audio.setup import setup_opus +from xiaozhi.utils.logging_config import setup_logging +from xiaozhi.xiaoai import XiaoAi +from xiaozhi.xiaozhi import XiaoZhi + +logger = logging.getLogger("Main") + + +def main(): + setup_opus() + logger.info("应用程序已启动,按Ctrl+C退出") + XiaoZhi.instance().run() + return 0 + + +def setup_graceful_shutdown(): + def signal_handler(_sig, _frame): + XiaoZhi.instance().shutdown() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + + +if __name__ == "__main__": + XiaoAi.setup_mode() + setup_logging() + setup_graceful_shutdown() + sys.exit(main()) diff --git a/packages/server-python/pyproject.toml b/packages/server-python/pyproject.toml new file mode 100644 index 0000000..b001514 --- /dev/null +++ b/packages/server-python/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "open-xiaoai-python" +version = "1.0.0" +description = "小爱音箱接入小智 AI" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "asyncio>=3.4.3", + "cryptography>=44.0.2", + "maturin>=1.8.3", + "numpy>=2.2.3", + "opuslib>=3.0.1", + "pyaudio>=0.2.14", + "requests>=2.32.3", + "websockets>=15.0.1", +] + +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[tool.maturin] +features = ["pyo3/extension-module"] + +[tool.uv] +# Rebuild package when any rust files change +# https://github.com/PyO3/maturin/issues/2314#issuecomment-2488542771 +cache-keys = [ + { file = "pyproject.toml" }, + { file = "Cargo.toml" }, + { file = "src/**/*.rs" }, +] diff --git a/packages/server-python/src/lib.rs b/packages/server-python/src/lib.rs new file mode 100644 index 0000000..5bf43cc --- /dev/null +++ b/packages/server-python/src/lib.rs @@ -0,0 +1,35 @@ +use open_xiaoai::services::connect::message::MessageManager; +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use server::AppServer; + +pub mod macros; +pub mod python; +pub mod server; + +#[pyfunction] +fn on_output_data(py: Python, data: Py) -> PyResult> { + let bytes = data.as_bytes(py).to_vec(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let _ = MessageManager::instance() + .send_stream("play", bytes, None) + .await; + Ok(()) + }) +} + +#[pyfunction] +fn start_server(py: Python) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async { + AppServer::run().await; + Ok(()) + }) +} + +#[pymodule] +fn open_xiaoai_server(_py: Python, m: Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(start_server, &m)?)?; + m.add_function(wrap_pyfunction!(on_output_data, &m)?)?; + crate::python::init_module(&m)?; + Ok(()) +} diff --git a/packages/server-python/src/macros.rs b/packages/server-python/src/macros.rs new file mode 100644 index 0000000..d1e1aca --- /dev/null +++ b/packages/server-python/src/macros.rs @@ -0,0 +1,14 @@ +/// 提供一个简便的日志记录宏,将消息传递给 Python 端 +/// +/// # 示例 +/// +/// ``` +/// pylog!("这是一条日志消息"); +/// pylog!("带有变量的日志: {}", variable); +/// ``` +#[macro_export] +macro_rules! pylog { + ($($arg:tt)*) => { + crate::python::PythonManager::instance().log(format!($($arg)*)); + }; +} diff --git a/packages/server-python/src/python.rs b/packages/server-python/src/python.rs new file mode 100644 index 0000000..ef4ead5 --- /dev/null +++ b/packages/server-python/src/python.rs @@ -0,0 +1,77 @@ +use pyo3::prelude::*; +use std::collections::HashMap; +use std::ffi::CString; +use std::sync::{Arc, LazyLock, RwLock}; + +pub struct PythonManager { + functions: Arc>>, +} + +static INSTANCE: LazyLock = LazyLock::new(PythonManager::new); + +impl PythonManager { + pub fn new() -> Self { + Self { + functions: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub fn instance() -> &'static Self { + &INSTANCE + } + + fn register_fn(&self, key: &str, function: PyObject) -> PyResult<()> { + let mut functions = self.functions.write().unwrap(); + functions.insert(key.to_string(), function); + Ok(()) + } + + fn unregister_fn(&self, key: &str) -> PyResult<()> { + let mut functions = self.functions.write().unwrap(); + functions.remove(key); + Ok(()) + } + + pub fn call_fn(&self, key: &str, arg: Option) -> PyResult { + let functions = self.functions.read().unwrap(); + if let Some(function) = functions.get(key) { + Python::with_gil(|py| match arg { + None => function.call0(py), + Some(arg) => function.call1(py, (arg,)), + }) + } else { + Err(pyo3::exceptions::PyKeyError::new_err(format!( + "未找到函数: {}", + key + ))) + } + } + + pub fn log(&self, text: String) { + let _ = self.eval(&format!("print('{}')", text)); + } + + pub fn eval(&self, script: &str) -> PyResult<()> { + Python::with_gil(|py| { + let c_script = CString::new(script)?; + py.run(&c_script, None, None)?; + Ok(()) + }) + } +} + +#[pyfunction] +fn register_fn(key: &str, function: PyObject) -> PyResult<()> { + PythonManager::instance().register_fn(key, function) +} + +#[pyfunction] +fn unregister_fn(key: &str) -> PyResult<()> { + PythonManager::instance().unregister_fn(key) +} + +pub fn init_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(register_fn, m)?)?; + m.add_function(wrap_pyfunction!(unregister_fn, m)?)?; + Ok(()) +} diff --git a/packages/server-python/src/server.rs b/packages/server-python/src/server.rs new file mode 100644 index 0000000..46195ff --- /dev/null +++ b/packages/server-python/src/server.rs @@ -0,0 +1,107 @@ +use open_xiaoai::base::{AppError, VERSION}; +use open_xiaoai::services::connect::data::{Event, Request, Response, Stream}; +use open_xiaoai::services::connect::handler::MessageHandler; +use open_xiaoai::services::connect::message::{MessageManager, WsStream}; +use open_xiaoai::services::connect::rpc::RPC; +use open_xiaoai::services::speaker::SpeakerManager; +use open_xiaoai::utils::task::TaskManager; +use pyo3::types::PyBytes; +use pyo3::Python; +use serde_json::json; +use tokio::net::{TcpListener, TcpStream}; +use tokio_tungstenite::accept_async; + +use crate::python::PythonManager; + +pub struct AppServer; + +async fn test() -> Result<(), AppError> { + SpeakerManager::play_text("已连接").await?; + + let _ = RPC::instance() + .call_remote("start_recording", None, None) + .await; + + let _ = RPC::instance().call_remote("start_play", None, None).await; + + Ok(()) +} + +impl AppServer { + pub async fn connect(stream: TcpStream) -> Result { + let ws_stream = accept_async(stream).await?; + Ok(WsStream::Server(ws_stream)) + } + + pub async fn run() { + let addr = "0.0.0.0:4399"; + let listener = TcpListener::bind(&addr) + .await + .expect(format!("❌ 绑定地址失败: {}", &addr).as_str()); + crate::pylog!("✅ 已启动: {:?}", addr); + while let Ok((stream, addr)) = listener.accept().await { + // 同一时刻只处理一个连接 + AppServer::handle_connection(stream, addr).await; + } + } + + async fn handle_connection(stream: TcpStream, addr: std::net::SocketAddr) { + let Ok(ws_stream) = AppServer::connect(stream).await else { + crate::pylog!("❌ 连接异常: {}", addr); + return; + }; + crate::pylog!("✅ 已连接: {:?}", addr); + AppServer::init(ws_stream).await; + if let Err(e) = MessageManager::instance().process_messages().await { + crate::pylog!("❌ 消息处理异常: {}", e); + } + AppServer::dispose().await; + crate::pylog!("❌ 已断开连接"); + } + + async fn init(ws_stream: WsStream) { + MessageManager::instance().init(ws_stream).await; + MessageHandler::::instance() + .set_handler(on_event) + .await; + MessageHandler::::instance() + .set_handler(on_stream) + .await; + + let rpc = RPC::instance(); + rpc.add_command("get_version", get_version).await; + + let test = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let _ = test().await; + }); + TaskManager::instance().add("test", test).await; + } + + async fn dispose() { + MessageManager::instance().dispose().await; + TaskManager::instance().dispose("test").await; + } +} + +async fn get_version(_: Request) -> Result { + let data = json!(VERSION.to_string()); + Ok(Response::from_data(data)) +} + +async fn on_stream(stream: Stream) -> Result<(), AppError> { + let Stream { tag, bytes, .. } = stream; + match tag.as_str() { + "record" => { + let data = Python::with_gil(|py| PyBytes::new(py, &bytes).into()); + PythonManager::instance().call_fn("on_input_data", Some(data))?; + } + _ => {} + } + Ok(()) +} + +async fn on_event(event: Event) -> Result<(), AppError> { + crate::pylog!("🔥 收到 Event: {:?}", event); + Ok(()) +} diff --git a/packages/server-python/uv.lock b/packages/server-python/uv.lock new file mode 100644 index 0000000..18ee57e --- /dev/null +++ b/packages/server-python/uv.lock @@ -0,0 +1,300 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" + +[[package]] +name = "asyncio" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/54/054bafaf2c0fb8473d423743e191fcdf49b2c1fd5e9af3524efbe097bafd/asyncio-3.4.3.tar.gz", hash = "sha256:83360ff8bc97980e4ff25c964c7bd3923d333d177aa4f7fb736b019f26c7cb41", size = 204411 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/74/07679c5b9f98a7cb0fc147b1ef1cc1853bc07a4eb9cb5731e24732c5f773/asyncio-3.4.3-py3-none-any.whl", hash = "sha256:c4d18b22701821de07bd6aea8b53d21449ec0ec5680645e5317062ea21817d2d", size = 101767 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +] + +[[package]] +name = "cryptography" +version = "44.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/25/4ce80c78963834b8a9fd1cc1266be5ed8d1840785c0f2e1b73b8d128d505/cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", size = 710807 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/ef/83e632cfa801b221570c5f58c0369db6fa6cef7d9ff859feab1aae1a8a0f/cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", size = 6676361 }, + { url = "https://files.pythonhosted.org/packages/30/ec/7ea7c1e4c8fc8329506b46c6c4a52e2f20318425d48e0fe597977c71dbce/cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", size = 3952350 }, + { url = "https://files.pythonhosted.org/packages/27/61/72e3afdb3c5ac510330feba4fc1faa0fe62e070592d6ad00c40bb69165e5/cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", size = 4166572 }, + { url = "https://files.pythonhosted.org/packages/26/e4/ba680f0b35ed4a07d87f9e98f3ebccb05091f3bf6b5a478b943253b3bbd5/cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", size = 3958124 }, + { url = "https://files.pythonhosted.org/packages/9c/e8/44ae3e68c8b6d1cbc59040288056df2ad7f7f03bbcaca6b503c737ab8e73/cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", size = 3678122 }, + { url = "https://files.pythonhosted.org/packages/27/7b/664ea5e0d1eab511a10e480baf1c5d3e681c7d91718f60e149cec09edf01/cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", size = 4191831 }, + { url = "https://files.pythonhosted.org/packages/2a/07/79554a9c40eb11345e1861f46f845fa71c9e25bf66d132e123d9feb8e7f9/cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", size = 3960583 }, + { url = "https://files.pythonhosted.org/packages/bb/6d/858e356a49a4f0b591bd6789d821427de18432212e137290b6d8a817e9bf/cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308", size = 4191753 }, + { url = "https://files.pythonhosted.org/packages/b2/80/62df41ba4916067fa6b125aa8c14d7e9181773f0d5d0bd4dcef580d8b7c6/cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", size = 4079550 }, + { url = "https://files.pythonhosted.org/packages/f3/cd/2558cc08f7b1bb40683f99ff4327f8dcfc7de3affc669e9065e14824511b/cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", size = 4298367 }, + { url = "https://files.pythonhosted.org/packages/71/59/94ccc74788945bc3bd4cf355d19867e8057ff5fdbcac781b1ff95b700fb1/cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79", size = 2772843 }, + { url = "https://files.pythonhosted.org/packages/ca/2c/0d0bbaf61ba05acb32f0841853cfa33ebb7a9ab3d9ed8bb004bd39f2da6a/cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa", size = 3209057 }, + { url = "https://files.pythonhosted.org/packages/9e/be/7a26142e6d0f7683d8a382dd963745e65db895a79a280a30525ec92be890/cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3", size = 6677789 }, + { url = "https://files.pythonhosted.org/packages/06/88/638865be7198a84a7713950b1db7343391c6066a20e614f8fa286eb178ed/cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", size = 3951919 }, + { url = "https://files.pythonhosted.org/packages/d7/fc/99fe639bcdf58561dfad1faa8a7369d1dc13f20acd78371bb97a01613585/cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", size = 4167812 }, + { url = "https://files.pythonhosted.org/packages/53/7b/aafe60210ec93d5d7f552592a28192e51d3c6b6be449e7fd0a91399b5d07/cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", size = 3958571 }, + { url = "https://files.pythonhosted.org/packages/16/32/051f7ce79ad5a6ef5e26a92b37f172ee2d6e1cce09931646eef8de1e9827/cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", size = 3679832 }, + { url = "https://files.pythonhosted.org/packages/78/2b/999b2a1e1ba2206f2d3bca267d68f350beb2b048a41ea827e08ce7260098/cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", size = 4193719 }, + { url = "https://files.pythonhosted.org/packages/72/97/430e56e39a1356e8e8f10f723211a0e256e11895ef1a135f30d7d40f2540/cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", size = 3960852 }, + { url = "https://files.pythonhosted.org/packages/89/33/c1cf182c152e1d262cac56850939530c05ca6c8d149aa0dcee490b417e99/cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", size = 4193906 }, + { url = "https://files.pythonhosted.org/packages/e1/99/87cf26d4f125380dc674233971069bc28d19b07f7755b29861570e513650/cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", size = 4081572 }, + { url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631 }, + { url = "https://files.pythonhosted.org/packages/e2/a5/5bc097adb4b6d22a24dea53c51f37e480aaec3465285c253098642696423/cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", size = 2773792 }, + { url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "maturin" +version = "1.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/0b/3fd746cf5cfa3c8d7e20ea08c0dbc2c2c765ae051d0fc43d808a38bc9548/maturin-1.8.3.tar.gz", hash = "sha256:304762f86fd53a8031b1bf006d12572a2aa0a5235485031113195cc0152e1e12", size = 199656 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/80/08579d0184ba743345bbd350a3b6419510ff8a4f6e9e671f713160d41fbb/maturin-1.8.3-py3-none-linux_armv6l.whl", hash = "sha256:fa27466b627150123729b2e611f9f9cfade84d24385d72c6877f78c30de30e89", size = 7758366 }, + { url = "https://files.pythonhosted.org/packages/87/1c/00755d28ae277daa828e183c3d118e2923e8b8f0cba4ff708b15d274ac0e/maturin-1.8.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:583404d20d7f1d9c8f3c18dcab9014faacabbed6be02da80062c06cd0e279554", size = 15201378 }, + { url = "https://files.pythonhosted.org/packages/58/9f/b8738dc55ba3eb149ad2686d3f9b3c24e44b7baff46cc6baa85e5dc8cf5e/maturin-1.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f9ffdac53dfe0089cf19b597410bc552eb34c856ddb41482b243a695e8b549d3", size = 7934215 }, + { url = "https://files.pythonhosted.org/packages/18/68/300f1a279486d6f63b624a76b81f0fba82545d027127c1ca4d5ded0d1b43/maturin-1.8.3-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:7949a4a17637341f84e88f4cbf0c155998780bbb7a145ed735725b907881c0ae", size = 7791270 }, + { url = "https://files.pythonhosted.org/packages/2e/6d/bf1b8bb9a8b1d9adad242b4089794be318446142975762d04f04ffabae40/maturin-1.8.3-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:11564fac7486313b7baf3aa4e82c20e1b20364aad3fde2ccbc4c07693c0b7e16", size = 8282824 }, + { url = "https://files.pythonhosted.org/packages/18/e9/c601de8699e546774d3f9e761eab374988f88e4ed7006afbb5ff61bb41c0/maturin-1.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:8555d8701cdba6c19c4705a22ce4d2a1814efd792f55dc5873262ff903317540", size = 7595855 }, + { url = "https://files.pythonhosted.org/packages/34/b8/82ae650e6b589289f4219a480f2b220bcf3d9391ae9ea02cc7a58ef59cfc/maturin-1.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:5b2a513468c1c9b4d1728d4b6d3d044b7c183985ef819c9ef15e373b70d99c7d", size = 7634888 }, + { url = "https://files.pythonhosted.org/packages/ac/4a/4f898a66cf4697267c447a6f240b6cbcc2dcacd3cab89735bfbd44810be1/maturin-1.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:2fe8fdff420cfccde127bbc3d8e40835163dcebb6d28c49fffd9aaf1e6ec1090", size = 9810616 }, + { url = "https://files.pythonhosted.org/packages/31/bd/21b0d471e6ade0801f27c33672bca4d563eb1d1e624e534f3bef8a01b1ac/maturin-1.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22cd8b6dc490fee99a62590f914f0c04ddad1dd6dbd5c7e933b3f882b1bd78c2", size = 10923701 }, + { url = "https://files.pythonhosted.org/packages/6c/07/85c32f03ed757c0626e2fc5a9d6454988228be96a756991702a2a757691b/maturin-1.8.3-py3-none-win32.whl", hash = "sha256:2427924546c9d1079b1c0c6c5c1d380e1b8187baba4e6342cca81597a0f3d697", size = 7016502 }, + { url = "https://files.pythonhosted.org/packages/d5/62/f92a130a370dd7aca13c316844b82853647f048cfe1594a81f628ab7101f/maturin-1.8.3-py3-none-win_amd64.whl", hash = "sha256:85f2b882d8235c1c1cb0a38d382ccd5b3ba0674d99cb548d49df9342cc688e36", size = 7953286 }, + { url = "https://files.pythonhosted.org/packages/04/95/8379140838cd95472de843e982d0bf674e8dbf25a899c44e2f76b15704d9/maturin-1.8.3-py3-none-win_arm64.whl", hash = "sha256:33939aabf9a06a8a14ca6c399d32616c7e574fcca8d4ff6dcd984441051f32fb", size = 6687772 }, +] + +[[package]] +name = "numpy" +version = "2.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/78/31103410a57bc2c2b93a3597340a8119588571f6a4539067546cb9a0bfac/numpy-2.2.4.tar.gz", hash = "sha256:9ba03692a45d3eef66559efe1d1096c4b9b75c0986b5dff5530c378fb8331d4f", size = 20270701 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/30/182db21d4f2a95904cec1a6f779479ea1ac07c0647f064dea454ec650c42/numpy-2.2.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a7b9084668aa0f64e64bd00d27ba5146ef1c3a8835f3bd912e7a9e01326804c4", size = 20947156 }, + { url = "https://files.pythonhosted.org/packages/24/6d/9483566acfbda6c62c6bc74b6e981c777229d2af93c8eb2469b26ac1b7bc/numpy-2.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dbe512c511956b893d2dacd007d955a3f03d555ae05cfa3ff1c1ff6df8851854", size = 14133092 }, + { url = "https://files.pythonhosted.org/packages/27/f6/dba8a258acbf9d2bed2525cdcbb9493ef9bae5199d7a9cb92ee7e9b2aea6/numpy-2.2.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:bb649f8b207ab07caebba230d851b579a3c8711a851d29efe15008e31bb4de24", size = 5163515 }, + { url = "https://files.pythonhosted.org/packages/62/30/82116199d1c249446723c68f2c9da40d7f062551036f50b8c4caa42ae252/numpy-2.2.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:f34dc300df798742b3d06515aa2a0aee20941c13579d7a2f2e10af01ae4901ee", size = 6696558 }, + { url = "https://files.pythonhosted.org/packages/0e/b2/54122b3c6df5df3e87582b2e9430f1bdb63af4023c739ba300164c9ae503/numpy-2.2.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3f7ac96b16955634e223b579a3e5798df59007ca43e8d451a0e6a50f6bfdfba", size = 14084742 }, + { url = "https://files.pythonhosted.org/packages/02/e2/e2cbb8d634151aab9528ef7b8bab52ee4ab10e076509285602c2a3a686e0/numpy-2.2.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f92084defa704deadd4e0a5ab1dc52d8ac9e8a8ef617f3fbb853e79b0ea3592", size = 16134051 }, + { url = "https://files.pythonhosted.org/packages/8e/21/efd47800e4affc993e8be50c1b768de038363dd88865920439ef7b422c60/numpy-2.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4e84a6283b36632e2a5b56e121961f6542ab886bc9e12f8f9818b3c266bfbb", size = 15578972 }, + { url = "https://files.pythonhosted.org/packages/04/1e/f8bb88f6157045dd5d9b27ccf433d016981032690969aa5c19e332b138c0/numpy-2.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:11c43995255eb4127115956495f43e9343736edb7fcdb0d973defd9de14cd84f", size = 17898106 }, + { url = "https://files.pythonhosted.org/packages/2b/93/df59a5a3897c1f036ae8ff845e45f4081bb06943039ae28a3c1c7c780f22/numpy-2.2.4-cp312-cp312-win32.whl", hash = "sha256:65ef3468b53269eb5fdb3a5c09508c032b793da03251d5f8722b1194f1790c00", size = 6311190 }, + { url = "https://files.pythonhosted.org/packages/46/69/8c4f928741c2a8efa255fdc7e9097527c6dc4e4df147e3cadc5d9357ce85/numpy-2.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:2aad3c17ed2ff455b8eaafe06bcdae0062a1db77cb99f4b9cbb5f4ecb13c5146", size = 12644305 }, + { url = "https://files.pythonhosted.org/packages/2a/d0/bd5ad792e78017f5decfb2ecc947422a3669a34f775679a76317af671ffc/numpy-2.2.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cf4e5c6a278d620dee9ddeb487dc6a860f9b199eadeecc567f777daace1e9e7", size = 20933623 }, + { url = "https://files.pythonhosted.org/packages/c3/bc/2b3545766337b95409868f8e62053135bdc7fa2ce630aba983a2aa60b559/numpy-2.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1974afec0b479e50438fc3648974268f972e2d908ddb6d7fb634598cdb8260a0", size = 14148681 }, + { url = "https://files.pythonhosted.org/packages/6a/70/67b24d68a56551d43a6ec9fe8c5f91b526d4c1a46a6387b956bf2d64744e/numpy-2.2.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:79bd5f0a02aa16808fcbc79a9a376a147cc1045f7dfe44c6e7d53fa8b8a79392", size = 5148759 }, + { url = "https://files.pythonhosted.org/packages/1c/8b/e2fc8a75fcb7be12d90b31477c9356c0cbb44abce7ffb36be39a0017afad/numpy-2.2.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:3387dd7232804b341165cedcb90694565a6015433ee076c6754775e85d86f1fc", size = 6683092 }, + { url = "https://files.pythonhosted.org/packages/13/73/41b7b27f169ecf368b52533edb72e56a133f9e86256e809e169362553b49/numpy-2.2.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f527d8fdb0286fd2fd97a2a96c6be17ba4232da346931d967a0630050dfd298", size = 14081422 }, + { url = "https://files.pythonhosted.org/packages/4b/04/e208ff3ae3ddfbafc05910f89546382f15a3f10186b1f56bd99f159689c2/numpy-2.2.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce43e386c16898b91e162e5baaad90c4b06f9dcbe36282490032cec98dc8ae7", size = 16132202 }, + { url = "https://files.pythonhosted.org/packages/fe/bc/2218160574d862d5e55f803d88ddcad88beff94791f9c5f86d67bd8fbf1c/numpy-2.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31504f970f563d99f71a3512d0c01a645b692b12a63630d6aafa0939e52361e6", size = 15573131 }, + { url = "https://files.pythonhosted.org/packages/a5/78/97c775bc4f05abc8a8426436b7cb1be806a02a2994b195945600855e3a25/numpy-2.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:81413336ef121a6ba746892fad881a83351ee3e1e4011f52e97fba79233611fd", size = 17894270 }, + { url = "https://files.pythonhosted.org/packages/b9/eb/38c06217a5f6de27dcb41524ca95a44e395e6a1decdc0c99fec0832ce6ae/numpy-2.2.4-cp313-cp313-win32.whl", hash = "sha256:f486038e44caa08dbd97275a9a35a283a8f1d2f0ee60ac260a1790e76660833c", size = 6308141 }, + { url = "https://files.pythonhosted.org/packages/52/17/d0dd10ab6d125c6d11ffb6dfa3423c3571befab8358d4f85cd4471964fcd/numpy-2.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:207a2b8441cc8b6a2a78c9ddc64d00d20c303d79fba08c577752f080c4007ee3", size = 12636885 }, + { url = "https://files.pythonhosted.org/packages/fa/e2/793288ede17a0fdc921172916efb40f3cbc2aa97e76c5c84aba6dc7e8747/numpy-2.2.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8120575cb4882318c791f839a4fd66161a6fa46f3f0a5e613071aae35b5dd8f8", size = 20961829 }, + { url = "https://files.pythonhosted.org/packages/3a/75/bb4573f6c462afd1ea5cbedcc362fe3e9bdbcc57aefd37c681be1155fbaa/numpy-2.2.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a761ba0fa886a7bb33c6c8f6f20213735cb19642c580a931c625ee377ee8bd39", size = 14161419 }, + { url = "https://files.pythonhosted.org/packages/03/68/07b4cd01090ca46c7a336958b413cdbe75002286295f2addea767b7f16c9/numpy-2.2.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:ac0280f1ba4a4bfff363a99a6aceed4f8e123f8a9b234c89140f5e894e452ecd", size = 5196414 }, + { url = "https://files.pythonhosted.org/packages/a5/fd/d4a29478d622fedff5c4b4b4cedfc37a00691079623c0575978d2446db9e/numpy-2.2.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:879cf3a9a2b53a4672a168c21375166171bc3932b7e21f622201811c43cdd3b0", size = 6709379 }, + { url = "https://files.pythonhosted.org/packages/41/78/96dddb75bb9be730b87c72f30ffdd62611aba234e4e460576a068c98eff6/numpy-2.2.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f05d4198c1bacc9124018109c5fba2f3201dbe7ab6e92ff100494f236209c960", size = 14051725 }, + { url = "https://files.pythonhosted.org/packages/00/06/5306b8199bffac2a29d9119c11f457f6c7d41115a335b78d3f86fad4dbe8/numpy-2.2.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f085ce2e813a50dfd0e01fbfc0c12bbe5d2063d99f8b29da30e544fb6483b8", size = 16101638 }, + { url = "https://files.pythonhosted.org/packages/fa/03/74c5b631ee1ded596945c12027649e6344614144369fd3ec1aaced782882/numpy-2.2.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:92bda934a791c01d6d9d8e038363c50918ef7c40601552a58ac84c9613a665bc", size = 15571717 }, + { url = "https://files.pythonhosted.org/packages/cb/dc/4fc7c0283abe0981e3b89f9b332a134e237dd476b0c018e1e21083310c31/numpy-2.2.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ee4d528022f4c5ff67332469e10efe06a267e32f4067dc76bb7e2cddf3cd25ff", size = 17879998 }, + { url = "https://files.pythonhosted.org/packages/e5/2b/878576190c5cfa29ed896b518cc516aecc7c98a919e20706c12480465f43/numpy-2.2.4-cp313-cp313t-win32.whl", hash = "sha256:05c076d531e9998e7e694c36e8b349969c56eadd2cdcd07242958489d79a7286", size = 6366896 }, + { url = "https://files.pythonhosted.org/packages/3e/05/eb7eec66b95cf697f08c754ef26c3549d03ebd682819f794cb039574a0a6/numpy-2.2.4-cp313-cp313t-win_amd64.whl", hash = "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", size = 12739119 }, +] + +[[package]] +name = "open-xiaoai-python" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "asyncio" }, + { name = "cryptography" }, + { name = "maturin" }, + { name = "numpy" }, + { name = "opuslib" }, + { name = "pyaudio" }, + { name = "requests" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "asyncio", specifier = ">=3.4.3" }, + { name = "cryptography", specifier = ">=44.0.2" }, + { name = "maturin", specifier = ">=1.8.3" }, + { name = "numpy", specifier = ">=2.2.3" }, + { name = "opuslib", specifier = ">=3.0.1" }, + { name = "pyaudio", specifier = ">=0.2.14" }, + { name = "requests", specifier = ">=2.32.3" }, + { name = "websockets", specifier = ">=15.0.1" }, +] + +[[package]] +name = "opuslib" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/55/826befabb29fd3902bad6d6d7308790894c7ad4d73f051728a0c53d37cd7/opuslib-3.0.1.tar.gz", hash = "sha256:2cb045e5b03e7fc50dfefe431e3404dddddbd8f5961c10c51e32dfb69a044c97", size = 8550 } + +[[package]] +name = "pyaudio" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/1d/8878c7752febb0f6716a7e1a52cb92ac98871c5aa522cba181878091607c/PyAudio-0.2.14.tar.gz", hash = "sha256:78dfff3879b4994d1f4fc6485646a57755c6ee3c19647a491f790a0895bd2f87", size = 47066 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/45/8d2b76e8f6db783f9326c1305f3f816d4a12c8eda5edc6a2e1d03c097c3b/PyAudio-0.2.14-cp312-cp312-win32.whl", hash = "sha256:5fce4bcdd2e0e8c063d835dbe2860dac46437506af509353c7f8114d4bacbd5b", size = 144750 }, + { url = "https://files.pythonhosted.org/packages/b0/6a/d25812e5f79f06285767ec607b39149d02aa3b31d50c2269768f48768930/PyAudio-0.2.14-cp312-cp312-win_amd64.whl", hash = "sha256:12f2f1ba04e06ff95d80700a78967897a489c05e093e3bffa05a84ed9c0a7fa3", size = 164126 }, + { url = "https://files.pythonhosted.org/packages/3a/77/66cd37111a87c1589b63524f3d3c848011d21ca97828422c7fde7665ff0d/PyAudio-0.2.14-cp313-cp313-win32.whl", hash = "sha256:95328285b4dab57ea8c52a4a996cb52be6d629353315be5bfda403d15932a497", size = 150982 }, + { url = "https://files.pythonhosted.org/packages/a5/8b/7f9a061c1cc2b230f9ac02a6003fcd14c85ce1828013aecbaf45aa988d20/PyAudio-0.2.14-cp313-cp313-win_amd64.whl", hash = "sha256:692d8c1446f52ed2662120bcd9ddcb5aa2b71f38bda31e58b19fb4672fffba69", size = 173655 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "urllib3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, +] diff --git a/packages/server-python/xiaozhi/services/audio/codec.py b/packages/server-python/xiaozhi/services/audio/codec.py new file mode 100644 index 0000000..4e5868a --- /dev/null +++ b/packages/server-python/xiaozhi/services/audio/codec.py @@ -0,0 +1,271 @@ +import logging +import queue +import numpy as np +import pyaudio +import opuslib +from xiaozhi.services.protocols.typing import AudioConfig +import time +import sys + +from xiaozhi.services.audio.stream import MyAudio +from xiaozhi.xiaoai import XiaoAi + +logger = logging.getLogger("AudioCodec") + + +class AudioCodec: + """音频编解码器类,处理音频的录制和播放""" + + def __init__(self): + """初始化音频编解码器""" + self.audio = None + self.input_stream = None + self.output_stream = None + self.opus_encoder = None + self.opus_decoder = None + self.audio_decode_queue = queue.Queue() + self._is_closing = False + + self._initialize_audio() + + def _initialize_audio(self): + """初始化音频设备和编解码器""" + try: + self.audio = MyAudio() if XiaoAi.mode == "xiaoai" else pyaudio.PyAudio() + + # 初始化音频输入流 + self.input_stream = self.audio.open( + format=pyaudio.paInt16, + channels=AudioConfig.CHANNELS, + rate=AudioConfig.SAMPLE_RATE, + input=True, + frames_per_buffer=AudioConfig.FRAME_SIZE, + ) + + # 初始化音频输出流 + self.output_stream = self.audio.open( + format=pyaudio.paInt16, + channels=AudioConfig.CHANNELS, + rate=AudioConfig.SAMPLE_RATE, + output=True, + frames_per_buffer=AudioConfig.FRAME_SIZE, + ) + + # 初始化Opus编码器 + self.opus_encoder = opuslib.Encoder( + fs=AudioConfig.SAMPLE_RATE, + channels=AudioConfig.CHANNELS, + application=opuslib.APPLICATION_AUDIO, + ) + + # 初始化Opus解码器 + self.opus_decoder = opuslib.Decoder( + fs=AudioConfig.SAMPLE_RATE, channels=AudioConfig.CHANNELS + ) + + logger.info("音频设备和编解码器初始化成功") + except Exception as e: + logger.error(f"初始化音频设备失败: {e}") + raise + + def read_audio(self): + """读取音频输入数据并编码""" + try: + data = self.input_stream.read( + AudioConfig.FRAME_SIZE, exception_on_overflow=False + ) + if not data: + return None + return self.opus_encoder.encode(data, AudioConfig.FRAME_SIZE) + except Exception as e: + logger.error(f"读取音频输入时出错: {e}") + return None + + def write_audio(self, opus_data): + """将编码的音频数据添加到播放队列""" + self.audio_decode_queue.put(opus_data) + + def play_audio(self): + """处理并播放队列中的音频数据""" + try: + # 批量处理多个音频包以减少处理延迟 + batch_size = min(10, self.audio_decode_queue.qsize()) + if batch_size == 0: + return False + + # 创建缓冲区存储解码后的数据 + buffer = bytearray() + + for _ in range(batch_size): + if self.audio_decode_queue.empty(): + break + + opus_data = self.audio_decode_queue.get_nowait() + try: + pcm_data = self.opus_decoder.decode( + opus_data, AudioConfig.FRAME_SIZE, decode_fec=False + ) + buffer.extend(pcm_data) + except Exception as e: + logger.error(f"解码音频数据时出错: {e}") + + # 只有在有数据时才处理和播放 + if len(buffer) > 0: + # 转换为numpy数组 + pcm_array = np.frombuffer(buffer, dtype=np.int16) + + # 播放音频 + try: + if self.output_stream and self.output_stream.is_active(): + self.output_stream.write(pcm_array.tobytes()) + return True + else: + # MAC 特定:如果流不活跃,尝试重新初始化 + self._reinitialize_output_stream() + if self.output_stream and self.output_stream.is_active(): + self.output_stream.write(pcm_array.tobytes()) + return True + except OSError as e: + if "Stream closed" in str(e) or "Internal PortAudio error" in str( + e + ): + logger.error(f"播放音频时出错: {e}") + self._reinitialize_output_stream() + else: + logger.error(f"播放音频时出错: {e}") + except queue.Empty: + pass + except Exception as e: + logger.error(f"播放音频时出错: {e}") + self._reinitialize_output_stream() + + return False + + def has_pending_audio(self): + """检查是否还有待播放的音频数据""" + return not self.audio_decode_queue.empty() + + def wait_for_audio_complete(self, timeout=5.0): + # 等待音频队列清空 + attempt = 0 + max_attempts = 15 + while not self.audio_decode_queue.empty() and attempt < max_attempts: + time.sleep(0.1) + attempt += 1 + + # 在关闭前清空任何剩余数据 + while not self.audio_decode_queue.empty(): + try: + self.audio_decode_queue.get_nowait() + except queue.Empty: + break + + def clear_audio_queue(self): + """清空音频队列""" + while not self.audio_decode_queue.empty(): + try: + self.audio_decode_queue.get_nowait() + except queue.Empty: + break + + def start_streams(self): + """启动音频流""" + if not self.input_stream.is_active(): + self.input_stream.start_stream() + if not self.output_stream.is_active(): + self.output_stream.start_stream() + + def stop_streams(self): + """停止音频流""" + if self.input_stream and self.input_stream.is_active(): + self.input_stream.stop_stream() + if self.output_stream and self.output_stream.is_active(): + self.output_stream.stop_stream() + + def _reinitialize_output_stream(self): + """重新初始化音频输出流""" + if self._is_closing: # 如果正在关闭,不要重新初始化 + return + + try: + if self.output_stream: + try: + if self.output_stream.is_active(): + self.output_stream.stop_stream() + self.output_stream.close() + except Exception as e: + # logger.warning(f"关闭旧输出流时出错: {e}") + pass + + # 在 MAC 上添加短暂延迟 + if sys.platform in ("darwin", "linux"): + time.sleep(0.1) + + self.output_stream = self.audio.open( + format=pyaudio.paInt16, + channels=AudioConfig.CHANNELS, + rate=AudioConfig.SAMPLE_RATE, + output=True, + frames_per_buffer=AudioConfig.FRAME_SIZE, + ) + logger.info("音频输出流重新初始化成功") + except Exception as e: + logger.error(f"重新初始化音频输出流失败: {e}") + raise + + def close(self): + """关闭音频编解码器,确保资源正确释放""" + if self._is_closing: # 防止重复关闭 + return + + self._is_closing = True + logger.info("开始关闭音频编解码器...") + + try: + # 等待并清理剩余音频数据 + self.wait_for_audio_complete() + + # 关闭输入流 + if self.input_stream: + logger.debug("正在关闭输入流...") + try: + if self.input_stream.is_active(): + self.input_stream.stop_stream() + self.input_stream.close() + except Exception as e: + logger.error(f"关闭输入流时出错: {e}") + self.input_stream = None + + # 关闭输出流 + if self.output_stream: + logger.debug("正在关闭输出流...") + try: + if self.output_stream.is_active(): + self.output_stream.stop_stream() + self.output_stream.close() + except Exception as e: + logger.error(f"关闭输出流时出错: {e}") + self.output_stream = None + + # 关闭 PyAudio 实例 + if self.audio: + logger.debug("正在终止 PyAudio...") + try: + self.audio.terminate() + except Exception as e: + logger.error(f"终止 PyAudio 时出错: {e}") + self.audio = None + + # 清理编解码器 + self.opus_encoder = None + self.opus_decoder = None + + logger.info("音频编解码器关闭完成") + except Exception as e: + logger.error(f"关闭音频编解码器时发生错误: {e}") + finally: + self._is_closing = False + + def __del__(self): + """析构函数,确保资源被释放""" + self.close() diff --git a/packages/server-python/xiaozhi/services/audio/opus.dll b/packages/server-python/xiaozhi/services/audio/opus.dll new file mode 100644 index 0000000..65e51b1 Binary files /dev/null and b/packages/server-python/xiaozhi/services/audio/opus.dll differ diff --git a/packages/server-python/xiaozhi/services/audio/setup.py b/packages/server-python/xiaozhi/services/audio/setup.py new file mode 100644 index 0000000..21b4593 --- /dev/null +++ b/packages/server-python/xiaozhi/services/audio/setup.py @@ -0,0 +1,45 @@ +import os +import sys +import subprocess +import logging + +logger = logging.getLogger("Opus") + + +def setup_opus(): + libs_dir = "" + lib_path = "" + + logger.info("正在加载 Opus 动态库,请耐心等待...") + + if sys.platform == "win32": + libs_dir = os.path.dirname(os.path.abspath(__file__)) + lib_path = os.path.join(libs_dir, "opus.dll") + elif sys.platform == "darwin": + result = subprocess.check_output( + "brew list opus | grep libopus.dylib", shell=True + ) + lib_path = result.decode("utf-8").strip() + if not lib_path.endswith("libopus.dylib"): + raise RuntimeError("请先安装 Opus: brew install opus") + libs_dir = os.path.dirname(lib_path) + else: + raise RuntimeError(f"暂不支持 {sys.platform} 平台") + + import ctypes.util + + original_find_library = ctypes.util.find_library + + def patched_find_library(name): + if name == "opus": + return lib_path + return original_find_library(name) + + ctypes.util.find_library = patched_find_library + + if hasattr(os, "add_dll_directory"): + os.add_dll_directory(libs_dir) + + os.environ["PATH"] = libs_dir + os.pathsep + os.environ.get("PATH", "") + + ctypes.CDLL(lib_path) diff --git a/packages/server-python/xiaozhi/services/audio/stream.py b/packages/server-python/xiaozhi/services/audio/stream.py new file mode 100644 index 0000000..d5d042f --- /dev/null +++ b/packages/server-python/xiaozhi/services/audio/stream.py @@ -0,0 +1,290 @@ +from typing import ClassVar, Optional, Callable, Any +from threading import Lock +from collections import deque + + +class GlobalStream: + """ + 全局音频缓冲区,用于存储和分发音频数据 + + 用来将小爱音箱的音频输入输出流,适配到小智 AI 的音频输入输出流 + """ + + _instance = None + _lock = Lock() + + # 默认缓冲区大小(以字节为单位) + DEFAULT_BUFFER_SIZE = 1024 * 1024 # 1MB + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super(GlobalStream, cls).__new__(cls) + cls._instance._initialize() + return cls._instance + + def _initialize(self): + """初始化实例变量""" + self._max_buffer_size = self.DEFAULT_BUFFER_SIZE + self._input_buffer = deque(maxlen=self._max_buffer_size) + self._is_input_active = False + self._is_output_active = False + self._input_readers = {} # 跟踪每个流的读取位置 + self._reader_counter = 0 # 为每个读取器分配唯一ID + self._buffer_overflow_count = 0 # 记录缓冲区溢出次数 + self.on_output_data = None # 输入数据回调函数 + + def set_buffer_size(self, frames: int) -> None: + if frames <= 0: + raise ValueError("缓冲区大小必须大于0") + + # 创建新的有限长度缓冲区 + new_input_buffer = deque(self._input_buffer, maxlen=frames * 2) + + # 替换旧缓冲区 + self._input_buffer = new_input_buffer + self._max_buffer_size = frames * 2 + + # 重置读取位置,因为缓冲区可能已经改变 + for reader_id in self._input_readers: + self._input_readers[reader_id] = 0 + + def register_reader(self) -> int: + """注册一个新的读取器并返回其ID""" + reader_id = self._reader_counter + self._input_readers[reader_id] = 0 # 初始位置为0 + self._reader_counter += 1 + return reader_id + + def unregister_reader(self, reader_id: int) -> None: + """注销一个读取器""" + if reader_id in self._input_readers: + del self._input_readers[reader_id] + + def read(self, reader_id: int, num_frames: int) -> bytes: + num_frames = num_frames * 2 + if not self._is_input_active: + return bytes(num_frames) + + if reader_id not in self._input_readers: + return bytes(num_frames) + + # 将输入缓冲区转换为列表以便随机访问 + buffer_list = list(self._input_buffer) + current_pos = self._input_readers[reader_id] + + # 如果当前位置超出缓冲区大小,返回空字节 + if current_pos >= len(buffer_list): + return bytes(num_frames) + + # 读取数据 + end_pos = min(current_pos + num_frames, len(buffer_list)) + data = bytes(buffer_list[current_pos:end_pos]) + + # 更新读取位置 + self._input_readers[reader_id] = end_pos + + # 如果数据不足,用零填充 + if len(data) < num_frames: + data += bytes(num_frames - len(data)) + + return data + + # 转发输出音频流 + def write(self, frames: bytes) -> None: + """写入数据到输出缓冲区""" + if not self._is_output_active: + return + + if self.on_output_data: + self.on_output_data(frames) + + # 添加输入音频流 + def add_input_data(self, data: bytes) -> None: + if not self._is_input_active: + return + + """添加输入数据到全局缓冲区""" + # 检查是否会溢出 + if len(self._input_buffer) + len(data) > self._max_buffer_size: + self._buffer_overflow_count += 1 + + for b in data: + self._input_buffer.append(b) + + # 如果有读取器的位置已经超出了缓冲区大小,需要调整 + if len(self._input_buffer) >= self._max_buffer_size: + for reader_id in self._input_readers: + if self._input_readers[reader_id] > len(self._input_buffer) // 2: + # 将读取位置重置到缓冲区中间,避免读取器永远跟不上 + self._input_readers[reader_id] = len(self._input_buffer) // 2 + + def start_input(self) -> None: + """启动输入流""" + self._is_input_active = True + + def stop_input(self) -> None: + """停止输入流""" + self._is_input_active = False + self.clear() + + def start_output(self) -> None: + """启动输出流""" + self._is_output_active = True + + def stop_output(self) -> None: + """停止输出流""" + self._is_output_active = False + + def is_input_active(self) -> bool: + """检查输入流是否活跃""" + return self._is_input_active + + def is_output_active(self) -> bool: + """检查输出流是否活跃""" + return self._is_output_active + + def clear(self) -> None: + """清空缓冲区""" + self._input_buffer.clear() + for reader_id in self._input_readers: + self._input_readers[reader_id] = 0 + + +class MyStream: + """音频流类,用于读写音频数据""" + + def __init__( + self, + rate: int, + channels: int, + format: int, + input: bool = False, + output: bool = False, + frames_per_buffer: int = 1024, + start: bool = True, + ) -> None: + self._rate = rate + self._channels = channels + self._format = format + self._frames_per_buffer = frames_per_buffer + self._is_input = input + self._is_output = output + self._is_active = False + self._is_closed = False + + # 获取全局音频缓冲区 + self._global_buffer = GlobalStream() + + # 注册读取器ID + self._reader_id = self._global_buffer.register_reader() if input else None + + # 如果需要,启动流 + if start: + self.start_stream() + + def close(self) -> None: + """关闭流""" + if not self._is_closed: + self.stop_stream() + if self._is_input and self._reader_id is not None: + self._global_buffer.unregister_reader(self._reader_id) + self._is_closed = True + + def is_active(self) -> bool: + """检查流是否活跃""" + return self._is_active + + def start_stream(self) -> None: + """启动流""" + if not self._is_active and not self._is_closed: + self._is_active = True + if self._is_input: + self._global_buffer.start_input() + if self._is_output: + self._global_buffer.start_output() + + def stop_stream(self) -> None: + """停止流""" + if self._is_active: + self._is_active = False + if self._is_input: + self._global_buffer.stop_input() + if self._is_output: + self._global_buffer.stop_output() + + def read(self, num_frames: int, exception_on_overflow=False) -> bytes: + """从输入流读取数据""" + if ( + not self._is_input + or self._is_closed + or not self._is_active + or self._reader_id is None + ): + return bytes(num_frames) + + return self._global_buffer.read(self._reader_id, num_frames) + + def write(self, frames: bytes) -> None: + """写入数据到输出流""" + if not self._is_output or self._is_closed or not self._is_active: + return + + self._global_buffer.write(frames) + + +class MyAudio: + """PyAudio替代品,用于创建和管理音频流""" + + Stream: ClassVar[type] = MyStream + + def __init__(self, buffer_size: int = GlobalStream.DEFAULT_BUFFER_SIZE) -> None: + # 初始化全局音频缓冲区 + self._global_buffer = GlobalStream() + # 设置缓冲区大小 + if buffer_size != GlobalStream.DEFAULT_BUFFER_SIZE: + self._global_buffer.set_buffer_size(buffer_size) + self._is_terminated = False + + def open( + self, + rate: int, + channels: int, + format: int, + input: bool = False, + output: bool = False, + input_device_index: Optional[int] = None, + output_device_index: Optional[int] = None, + frames_per_buffer: int = 1024, + start: bool = True, + input_host_api_specific_stream_info: Optional[Any] = None, + output_host_api_specific_stream_info: Optional[Any] = None, + stream_callback: Optional[Callable] = None, + ) -> MyStream: + """打开一个新的音频流""" + if self._is_terminated: + raise RuntimeError("MyAudio instance has been terminated") + + # 启动全局输入/输出流(如果需要) + if input: + self._global_buffer.start_input() + if output: + self._global_buffer.start_output() + + # 创建并返回一个新的流实例 + return MyStream( + rate=rate, + channels=channels, + format=format, + input=input, + output=output, + frames_per_buffer=frames_per_buffer, + start=start, + ) + + def terminate(self) -> None: + """终止MyAudio实例""" + if not self._is_terminated: + self._global_buffer.stop_input() + self._global_buffer.stop_output() + self._is_terminated = True diff --git a/packages/server-python/xiaozhi/services/display/base_display.py b/packages/server-python/xiaozhi/services/display/base_display.py new file mode 100644 index 0000000..0640b0d --- /dev/null +++ b/packages/server-python/xiaozhi/services/display/base_display.py @@ -0,0 +1,53 @@ +from abc import ABC, abstractmethod +from typing import Optional, Callable +import logging + +class BaseDisplay(ABC): + """显示接口的抽象基类""" + + def __init__(self): + self.logger = logging.getLogger(self.__class__.__name__) + self.current_volume = 70 # 默认音量 + + @abstractmethod + def set_callbacks(self, + press_callback: Optional[Callable] = None, + release_callback: Optional[Callable] = None, + status_callback: Optional[Callable] = None, + text_callback: Optional[Callable] = None, + emotion_callback: Optional[Callable] = None, + mode_callback: Optional[Callable] = None, + auto_callback: Optional[Callable] = None, + abort_callback: Optional[Callable] = None): # 添加打断回调参数 + """设置回调函数""" + pass + + @abstractmethod + def update_button_status(self, text: str): + """更新按钮状态""" + pass + + @abstractmethod + def update_status(self, status: str): + """更新状态文本""" + pass + + @abstractmethod + def update_text(self, text: str): + """更新TTS文本""" + pass + + @abstractmethod + def update_emotion(self, emotion: str): + """更新表情""" + pass + + @abstractmethod + def start(self): + """启动显示""" + pass + + @abstractmethod + def on_close(self): + """关闭显示""" + pass \ No newline at end of file diff --git a/packages/server-python/xiaozhi/services/display/gui_display.py b/packages/server-python/xiaozhi/services/display/gui_display.py new file mode 100644 index 0000000..9a492ac --- /dev/null +++ b/packages/server-python/xiaozhi/services/display/gui_display.py @@ -0,0 +1,284 @@ +import threading +import tkinter as tk +from tkinter import ttk +import queue +import logging +import time +from typing import Optional, Callable + +from xiaozhi.services.display.base_display import BaseDisplay + + +class GuiDisplay(BaseDisplay): + def __init__(self): + super().__init__() # 调用父类初始化 + """创建 GUI 界面""" + # 初始化日志 + self.logger = logging.getLogger("Display") + + # 创建主窗口 + self.root = tk.Tk() + self.root.title("小爱音箱接入小智 AI 演示") + self.root.geometry("520x360") + + # 在窗口底部添加作者信息 + self.author_label = ttk.Label(self.root, text="作者: https://del.wang") + self.author_label.pack(side=tk.BOTTOM, pady=5) + + # 让窗口居中显示 + self.root.update_idletasks() + width = self.root.winfo_width() + height = self.root.winfo_height() + x = (self.root.winfo_screenwidth() // 2) - (width // 2) + y = (self.root.winfo_screenheight() // 2) - (height // 2) + self.root.geometry(f"+{x}+{y}") + + # 状态显示 + self.status_frame = ttk.Frame(self.root) + self.status_frame.pack(pady=20) + self.status_label = ttk.Label(self.status_frame, text="状态: 未连接") + self.status_label.pack(side=tk.LEFT) + + # 表情显示 + self.emotion_label = tk.Label(self.root, text="😊", font=("Segoe UI Emoji", 32)) + self.emotion_label.pack(padx=20, pady=20) + + # TTS文本显示 + self.tts_text_label = ttk.Label( + self.root, text="很高兴认识你!", wraplength=250 + ) + self.tts_text_label.pack(padx=20, pady=10) + + # 控制按钮 + self.btn_frame = ttk.Frame(self.root) + self.btn_frame.pack(pady=20) + + # 手动模式按钮 + self.manual_btn = ttk.Button(self.btn_frame, text="按住说话") + self.manual_btn.bind("", self._on_manual_button_press) + self.manual_btn.bind("", self._on_manual_button_release) + self.manual_btn.pack(side=tk.LEFT, padx=10) + + # 打断按钮 + self.abort_btn = ttk.Button( + self.btn_frame, text="停止播放", command=self._on_abort_button_click + ) + self.abort_btn.pack(side=tk.LEFT, padx=10) + + # 对话模式标志 + self.auto_mode = False + + # 回调函数 + self.button_press_callback = None + self.button_release_callback = None + self.status_update_callback = None + self.text_update_callback = None + self.emotion_update_callback = None + self.mode_callback = None + self.auto_callback = None + self.abort_callback = None + + # 更新队列 + self.update_queue = queue.Queue() + + # 运行标志 + self._running = True + + # 设置窗口关闭处理 + self.root.protocol("WM_DELETE_WINDOW", self.on_close) + + # 启动更新处理 + self.root.after(100, self._process_updates) + + def set_callbacks( + self, + press_callback: Optional[Callable] = None, + release_callback: Optional[Callable] = None, + status_callback: Optional[Callable] = None, + text_callback: Optional[Callable] = None, + emotion_callback: Optional[Callable] = None, + mode_callback: Optional[Callable] = None, + auto_callback: Optional[Callable] = None, + abort_callback: Optional[Callable] = None, + ): + """设置回调函数""" + self.button_press_callback = press_callback + self.button_release_callback = release_callback + self.status_update_callback = status_callback + self.text_update_callback = text_callback + self.emotion_update_callback = emotion_callback + self.mode_callback = mode_callback + self.auto_callback = auto_callback + self.abort_callback = abort_callback + + def _process_updates(self): + """处理更新队列""" + try: + while True: + try: + # 非阻塞方式获取更新 + update_func = self.update_queue.get_nowait() + update_func() + self.update_queue.task_done() + except queue.Empty: + break + finally: + if self._running: + self.root.after(100, self._process_updates) + + def _on_manual_button_press(self, event): + """手动模式按钮按下事件处理""" + try: + # 更新按钮文本为"松开以停止" + self.manual_btn.config(text="松开以停止") + + # 调用回调函数 + if self.button_press_callback: + self.button_press_callback() + except Exception as e: + self.logger.error(f"按钮按下回调执行失败: {e}") + + def _on_manual_button_release(self, event): + """手动模式按钮释放事件处理""" + try: + # 更新按钮文本为"按住说话" + self.manual_btn.config(text="按住说话") + + # 调用回调函数 + if self.button_release_callback: + self.button_release_callback() + except Exception as e: + self.logger.error(f"按钮释放回调执行失败: {e}") + + def _on_auto_button_click(self): + """自动模式按钮点击事件处理""" + try: + if self.auto_callback: + self.auto_callback() + except Exception as e: + self.logger.error(f"自动模式按钮回调执行失败: {e}") + + def _on_abort_button_click(self): + """打断按钮点击事件处理""" + try: + if self.abort_callback: + self.abort_callback() + except Exception as e: + self.logger.error(f"打断按钮回调执行失败: {e}") + + def _on_mode_button_click(self): + """对话模式切换按钮点击事件""" + try: + # 检查是否可以切换模式(通过回调函数询问应用程序当前状态) + if self.mode_callback: + # 如果回调函数返回False,表示当前不能切换模式 + if not self.mode_callback(not self.auto_mode): + return + + # 切换模式 + self.auto_mode = not self.auto_mode + + # 更新按钮显示 + if self.auto_mode: + # 切换到自动模式 + self.update_mode_button_status("自动对话") + + # 隐藏手动按钮,显示自动按钮 + self.update_queue.put(lambda: self._switch_to_auto_mode()) + else: + # 切换到手动模式 + self.update_mode_button_status("手动对话") + + # 隐藏自动按钮,显示手动按钮 + self.update_queue.put(lambda: self._switch_to_manual_mode()) + + except Exception as e: + self.logger.error(f"模式切换按钮回调执行失败: {e}") + + def _switch_to_auto_mode(self): + """切换到自动模式的UI更新""" + self.manual_btn.pack_forget() # 移除手动按钮 + self.auto_btn.pack( + side=tk.LEFT, padx=10, before=self.abort_btn + ) # 显示自动按钮,放在打断按钮前面 + + def _switch_to_manual_mode(self): + """切换到手动模式的UI更新""" + self.auto_btn.pack_forget() # 移除自动按钮 + self.manual_btn.pack( + side=tk.LEFT, padx=10, before=self.abort_btn + ) # 显示手动按钮,放在打断按钮前面 + + def update_status(self, status: str): + """更新状态文本""" + self.update_queue.put(lambda: self.status_label.config(text=f"状态: {status}")) + + def update_text(self, text: str): + """更新TTS文本""" + self.update_queue.put(lambda: self.tts_text_label.config(text=text)) + + def update_emotion(self, emotion: str): + """更新表情""" + self.update_queue.put(lambda: self.emotion_label.config(text=emotion)) + + def start_update_threads(self): + """启动更新线程""" + + def update_loop(): + while self._running: + try: + # 更新状态 + if self.status_update_callback: + status = self.status_update_callback() + if status: + self.update_status(status) + + # 更新文本 + if self.text_update_callback: + text = self.text_update_callback() + if text: + self.update_text(text) + + # 更新表情 + if self.emotion_update_callback: + emotion = self.emotion_update_callback() + if emotion: + self.update_emotion(emotion) + + except Exception as e: + self.logger.error(f"更新失败: {e}") + time.sleep(0.1) + + threading.Thread(target=update_loop, daemon=True).start() + + def on_close(self): + """关闭窗口处理""" + self._running = False + self.root.destroy() + + def start(self): + """启动GUI""" + try: + # 启动更新线程 + self.start_update_threads() + # 在主线程中运行主循环 + self.logger.info("开始启动GUI主循环") + self.root.mainloop() + except Exception as e: + self.logger.error(f"GUI启动失败: {e}", exc_info=True) + # 尝试回退到CLI模式 + print(f"GUI启动失败: {e},请尝试使用CLI模式") + + def update_mode_button_status(self, text: str): + """更新模式按钮状态""" + self.update_queue.put(lambda: self.mode_btn.config(text=text)) + + def update_button_status(self, text: str): + """更新按钮状态 - 保留此方法以满足抽象基类要求""" + # 根据当前模式更新相应的按钮 + if self.auto_mode: + self.update_queue.put(lambda: self.auto_btn.config(text=text)) + else: + # 在手动模式下,不通过此方法更新按钮文本 + # 因为按钮文本由按下/释放事件直接控制 + pass diff --git a/packages/server-python/xiaozhi/services/protocols/protocol.py b/packages/server-python/xiaozhi/services/protocols/protocol.py new file mode 100644 index 0000000..a6c70f2 --- /dev/null +++ b/packages/server-python/xiaozhi/services/protocols/protocol.py @@ -0,0 +1,83 @@ +import json + +from xiaozhi.services.protocols.typing import AbortReason, ListeningMode + + +class Protocol: + def __init__(self): + self.session_id = "" + self.on_incoming_json = None + self.on_incoming_audio = None + self.on_audio_channel_opened = None + self.on_audio_channel_closed = None + self.on_network_error = None + + def on_incoming_json(self, callback): + """设置JSON消息接收回调函数""" + self.on_incoming_json = callback + + def on_incoming_audio(self, callback): + """设置音频数据接收回调函数""" + self.on_incoming_audio = callback + + def on_audio_channel_opened(self, callback): + """设置音频通道打开回调函数""" + self.on_audio_channel_opened = callback + + def on_audio_channel_closed(self, callback): + """设置音频通道关闭回调函数""" + self.on_audio_channel_closed = callback + + def on_network_error(self, callback): + """设置网络错误回调函数""" + self.on_network_error = callback + + async def send_text(self, message): + """发送文本消息的抽象方法,需要在子类中实现""" + raise NotImplementedError("send_text方法必须由子类实现") + + async def send_abort_speaking(self, reason): + """发送中止语音的消息""" + message = {"session_id": self.session_id, "type": "abort"} + if reason == AbortReason.WAKE_WORD_DETECTED: + message["reason"] = "wake_word_detected" + await self.send_text(json.dumps(message)) + + + async def send_start_listening(self, mode): + """发送开始监听的消息""" + mode_map = { + ListeningMode.ALWAYS_ON: "realtime", + ListeningMode.AUTO_STOP: "auto", + ListeningMode.MANUAL: "manual", + } + message = { + "session_id": self.session_id, + "type": "listen", + "state": "start", + "mode": mode_map[mode], + } + await self.send_text(json.dumps(message)) + + async def send_stop_listening(self): + """发送停止监听的消息""" + message = {"session_id": self.session_id, "type": "listen", "state": "stop"} + await self.send_text(json.dumps(message)) + + async def send_iot_descriptors(self, descriptors): + """发送物联网设备描述信息""" + message = { + "session_id": self.session_id, + "type": "iot", + "descriptors": json.loads(descriptors), + } + await self.send_text(json.dumps(message)) + + async def send_iot_states(self, states): + """发送物联网设备状态信息""" + message = { + "session_id": self.session_id, + "type": "iot", + "states": json.loads(states), + } + await self.send_text(json.dumps(message)) diff --git a/packages/server-python/xiaozhi/services/protocols/typing.py b/packages/server-python/xiaozhi/services/protocols/typing.py new file mode 100644 index 0000000..f5b13db --- /dev/null +++ b/packages/server-python/xiaozhi/services/protocols/typing.py @@ -0,0 +1,30 @@ +class ListeningMode: + """监听模式""" + ALWAYS_ON = "always_on" + AUTO_STOP = "auto_stop" + MANUAL = "manual" + +class AbortReason: + """中止原因""" + NONE = "none" + WAKE_WORD_DETECTED = "wake_word_detected" + +class DeviceState: + """设备状态""" + IDLE = "idle" + CONNECTING = "connecting" + LISTENING = "listening" + SPEAKING = "speaking" + +class EventType: + """事件类型""" + SCHEDULE_EVENT = "schedule_event" + AUDIO_INPUT_READY_EVENT = "audio_input_ready_event" + AUDIO_OUTPUT_READY_EVENT = "audio_output_ready_event" + +class AudioConfig: + """音频配置""" + SAMPLE_RATE = 24000 + CHANNELS = 1 + FRAME_DURATION = 60 # ms + FRAME_SIZE = int(SAMPLE_RATE * (FRAME_DURATION / 1000)) diff --git a/packages/server-python/xiaozhi/services/protocols/websocket_protocol.py b/packages/server-python/xiaozhi/services/protocols/websocket_protocol.py new file mode 100644 index 0000000..99e8400 --- /dev/null +++ b/packages/server-python/xiaozhi/services/protocols/websocket_protocol.py @@ -0,0 +1,214 @@ +import asyncio +import json +import logging +import websockets + + +from xiaozhi.services.protocols.protocol import Protocol +from xiaozhi.utils.config_manager import ConfigManager + + +logger = logging.getLogger("WebsocketProtocol") + + +class WebsocketProtocol(Protocol): + def __init__(self): + super().__init__() + # 获取配置管理器实例 + self.config = ConfigManager.instance() + self.websocket = None + self.server_sample_rate = 16000 + self.connected = False + self.hello_received = None # 初始化时先设为 None + self.WEBSOCKET_URL = self.config.get_config("NETWORK.WEBSOCKET_URL") + self.WEBSOCKET_ACCESS_TOKEN = self.config.get_config( + "NETWORK.WEBSOCKET_ACCESS_TOKEN" + ) + self.CLIENT_ID = self.config.get_client_id() + self.DEVICE_ID = self.config.get_device_id() + + async def connect(self) -> bool: + """连接到WebSocket服务器""" + try: + # 在连接时创建 Event,确保在正确的事件循环中 + self.hello_received = asyncio.Event() + + # 配置连接 + headers = { + "Authorization": f"Bearer {self.WEBSOCKET_ACCESS_TOKEN}", + "Protocol-Version": "1", + "Device-Id": self.DEVICE_ID, # 获取设备MAC地址 + "Client-Id": self.CLIENT_ID, + } + + # 建立WebSocket连接 (兼容不同Python版本的写法) + try: + # 新的写法 (在Python 3.11+版本中) + self.websocket = await websockets.connect( + uri=self.WEBSOCKET_URL, additional_headers=headers + ) + except TypeError: + # 旧的写法 (在较早的Python版本中) + self.websocket = await websockets.connect( + self.WEBSOCKET_URL, extra_headers=headers + ) + + # 启动消息处理循环 + asyncio.create_task(self._message_handler()) + + # 发送客户端hello消息 + hello_message = { + "type": "hello", + "version": 1, + "transport": "websocket", + "audio_params": { + "format": "opus", + "sample_rate": 16000, + "channels": 1, + "frame_duration": 60, + }, + } + await self.send_text(json.dumps(hello_message)) + + # 等待服务器hello响应 + try: + await asyncio.wait_for(self.hello_received.wait(), timeout=10.0) + self.connected = True + logger.info("已连接到WebSocket服务器") + return True + except asyncio.TimeoutError: + logger.error("等待服务器hello响应超时") + if self.on_network_error: + self.on_network_error("等待响应超时") + return False + + except Exception as e: + logger.error(f"WebSocket连接失败: {e}") + if self.on_network_error: + self.on_network_error(f"无法连接服务: {str(e)}") + return False + + async def _message_handler(self): + """处理接收到的WebSocket消息""" + try: + async for message in self.websocket: + if isinstance(message, str): + try: + data = json.loads(message) + msg_type = data.get("type") + if msg_type == "hello": + # 处理服务器 hello 消息 + await self._handle_server_hello(data) + else: + if self.on_incoming_json: + self.on_incoming_json(data) + except json.JSONDecodeError as e: + logger.error(f"无效的JSON消息: {message}, 错误: {e}") + elif self.on_incoming_audio: # 使用 elif 更清晰 + self.on_incoming_audio(message) + + except websockets.ConnectionClosed: + logger.info("WebSocket连接已关闭") + self.connected = False + if self.on_audio_channel_closed: + # 使用 schedule 确保回调在主线程中执行 + await self.on_audio_channel_closed() + except Exception as e: + logger.error(f"消息处理错误: {e}") + self.connected = False + if self.on_network_error: + # 使用 schedule 确保错误处理在主线程中执行 + self.on_network_error(f"连接错误: {str(e)}") + + async def send_audio(self, data: bytes): + """发送音频数据""" + if not self.is_audio_channel_opened(): # 使用已有的 is_connected 方法 + return + + try: + await self.websocket.send(data) + except Exception as e: + logger.error(f"发送音频数据失败: {e}") + if self.on_network_error: + self.on_network_error(f"发送音频失败: {str(e)}") + + async def send_text(self, message: str): + """发送文本消息""" + if self.websocket: + try: + await self.websocket.send(message) + except Exception as e: + await self.close_audio_channel() + if self.on_network_error: + self.on_network_error(f"发送消息失败: {str(e)}") + + def is_audio_channel_opened(self) -> bool: + """检查音频通道是否打开""" + return self.websocket is not None and self.connected + + async def open_audio_channel(self) -> bool: + """建立 WebSocket 连接 + + 如果尚未连接,则创建新的 WebSocket 连接 + Returns: + bool: 连接是否成功 + """ + if not self.connected: + return await self.connect() + return True + + async def _handle_server_hello(self, data: dict): + """处理服务器的 hello 消息 + + 解析服务器返回的 hello 消息,设置相关参数并通知音频通道已打开 + + Args: + data: 服务器返回的 hello 消息数据 + """ + try: + # 验证传输方式 + transport = data.get("transport") + if not transport or transport != "websocket": + logger.error(f"不支持的传输方式: {transport}") + return + + # 获取音频参数 + audio_params = data.get("audio_params") + if audio_params: + # 获取服务器的采样率 + sample_rate = audio_params.get("sample_rate") + if sample_rate: + self.server_sample_rate = sample_rate + # 如果服务器采样率与本地不同,记录警告 + if sample_rate != self.server_sample_rate: + logger.warning( + f"服务器的音频采样率 {sample_rate} " + f"与设备输出的采样率 {self.server_sample_rate} 不一致," + "重采样后可能会失真" + ) + + # 设置 hello 接收事件 + self.hello_received.set() + + # 通知音频通道已打开 + if self.on_audio_channel_opened: + await self.on_audio_channel_opened() + + logger.info("成功处理服务器 hello 消息") + + except Exception as e: + logger.error(f"处理服务器 hello 消息时出错: {e}") + if self.on_network_error: + self.on_network_error(f"处理服务器响应失败: {str(e)}") + + async def close_audio_channel(self): + """关闭音频通道""" + if self.websocket: + try: + await self.websocket.close() + self.websocket = None + self.connected = False + if self.on_audio_channel_closed: + await self.on_audio_channel_closed() + except Exception as e: + logger.error(f"关闭WebSocket连接失败: {e}") diff --git a/packages/server-python/xiaozhi/utils/config_manager.py b/packages/server-python/xiaozhi/utils/config_manager.py new file mode 100644 index 0000000..e00b178 --- /dev/null +++ b/packages/server-python/xiaozhi/utils/config_manager.py @@ -0,0 +1,256 @@ +import json +import logging +import os +from pathlib import Path +from typing import Dict, Any, Optional +import threading +import requests +import socket +import uuid + +logger = logging.getLogger("ConfigManager") + + +class ConfigManager: + """配置管理器 - 单例模式""" + + _instance = None + _lock = threading.Lock() + CONFIG_FILE = Path(os.getcwd()) / "xiaozhi.json" + + # 默认配置 + DEFAULT_CONFIG = { + "CLIENT_ID": None, + "DEVICE_ID": None, + "NETWORK": { + "OTA_VERSION_URL": "https://api.tenclass.net/xiaozhi/ota/", + "WEBSOCKET_URL": "wss://api.tenclass.net/xiaozhi/v1/", + "WEBSOCKET_ACCESS_TOKEN": "test-token", + }, + "MQTT_INFO": None, + } + + def __new__(cls): + """确保单例模式""" + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + """初始化配置管理器""" + self.logger = logger + if hasattr(self, "_initialized"): + return + self._initialized = True + + # 加载配置 + self._config = self._load_config() + self._initialize_client_id() + self._initialize_device_id() + self._initialize_mqtt_info() + + def _load_config(self) -> Dict[str, Any]: + """加载配置文件,如果不存在则创建""" + try: + if self.CONFIG_FILE.exists(): + config = json.loads(self.CONFIG_FILE.read_text(encoding="utf-8")) + return self._merge_configs(self.DEFAULT_CONFIG, config) + else: + self._save_config(self.DEFAULT_CONFIG) + return self.DEFAULT_CONFIG.copy() + except Exception as e: + logger.error(f"Error loading config: {e}") + return self.DEFAULT_CONFIG.copy() + + def _save_config(self, config: dict) -> bool: + """保存配置到文件""" + try: + self.CONFIG_FILE.write_text( + json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8" + ) + return True + except Exception as e: + logger.error(f"Error saving config: {e}") + return False + + @staticmethod + def _merge_configs(default: dict, custom: dict) -> dict: + """递归合并配置字典""" + result = default.copy() + for key, value in custom.items(): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): + result[key] = ConfigManager._merge_configs(result[key], value) + else: + result[key] = value + return result + + def get_client_id(self) -> str: + """获取客户端ID""" + return self._config["CLIENT_ID"] + + def get_device_id(self) -> Optional[str]: + """获取设备ID""" + return self._config.get("DEVICE_ID") + + def get_network_config(self) -> dict: + """获取网络配置""" + return self._config["NETWORK"] + + def get_config(self, path: str, default: Any = None) -> Any: + """ + 通过路径获取配置值 + """ + try: + value = self._config + for key in path.split("."): + value = value[key] + return value + except (KeyError, TypeError): + return default + + def update_config(self, path: str, value: Any) -> bool: + """ + 更新特定配置项 + """ + try: + current = self._config + *parts, last = path.split(".") + for part in parts: + current = current.setdefault(part, {}) + current[last] = value + return self._save_config(self._config) + except Exception as e: + logger.error(f"Error updating config {path}: {e}") + return False + + @classmethod + def instance(cls): + """获取配置管理器实例(线程安全)""" + with cls._lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def get_mac_address(self): + mac = uuid.UUID(int=uuid.getnode()).hex[-12:] + return ":".join([mac[i : i + 2] for i in range(0, 12, 2)]) + + def generate_uuid(self) -> str: + return str(uuid.uuid4()) + + def get_local_ip(self): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "127.0.0.1" + + def _initialize_client_id(self): + """确保存在客户端ID""" + if not self._config["CLIENT_ID"]: + client_id = self.generate_uuid() + success = self.update_config("CLIENT_ID", client_id) + if success: + logger.info(f"Generated new CLIENT_ID: {client_id}") + else: + logger.error("Failed to save new CLIENT_ID") + + def _initialize_device_id(self): + """确保存在设备ID""" + if not self._config["DEVICE_ID"]: + try: + device_hash = self.get_mac_address() + success = self.update_config("DEVICE_ID", device_hash) + if success: + logger.info(f"Generated new DEVICE_ID: {device_hash}") + else: + logger.error("Failed to save new DEVICE_ID") + except Exception as e: + logger.error(f"Error generating DEVICE_ID: {e}") + + def _initialize_mqtt_info(self): + try: + mqtt_info = self._get_ota_version() + if mqtt_info: + self.update_config("MQTT_INFO", mqtt_info) + self.logger.info("MQTT信息已成功更新") + return mqtt_info + else: + self.logger.warning("获取MQTT信息失败,使用已保存的配置") + return self.get_config("MQTT_INFO") + + except Exception as e: + self.logger.error(f"初始化MQTT信息失败: {e}") + return self.get_config("MQTT_INFO") + + def _get_ota_version(self): + """获取OTA服务器的MQTT信息""" + MAC_ADDR = self.get_device_id() + OTA_VERSION_URL = self.get_config("NETWORK.OTA_VERSION_URL") + headers = {"Device-Id": MAC_ADDR, "Content-Type": "application/json"} + + # 构建设备信息payload + payload = { + "flash_size": 16777216, # 闪存大小 (16MB) + "minimum_free_heap_size": 8318916, # 最小可用堆内存 + "mac_address": MAC_ADDR, # 设备MAC地址 + "chip_model_name": "esp32s3", # 芯片型号 + "chip_info": {"model": 9, "cores": 2, "revision": 2, "features": 18}, + "application": { + "name": "xiaozhi", + "version": "1.1.2", + "idf_version": "v5.3.2-dirty", + }, + "partition_table": [], + "ota": {"label": "factory"}, + "board": { + "type": "bread-compact-wifi", + "ip": self.get_local_ip(), + "mac": MAC_ADDR, + }, + } + + try: + # 发送请求到OTA服务器 + response = requests.post( + OTA_VERSION_URL, + headers=headers, + json=payload, + timeout=10, + ) + + # 检查HTTP状态码 + if response.status_code != 200: + self.logger.error(f"OTA服务器错误: HTTP {response.status_code}") + raise ValueError(f"OTA服务器返回错误状态码: {response.status_code}") + + # 解析JSON数据 + response_data = response.json() + + # 调试信息:打印完整的OTA响应 + self.logger.debug( + f"OTA服务器返回数据: {json.dumps(response_data, indent=4, ensure_ascii=False)}" + ) + + # 确保"mqtt"信息存在 + if "mqtt" in response_data: + self.logger.info(f"MQTT服务器信息已更新") + return response_data["mqtt"] + else: + self.logger.error("OTA服务器返回的数据无效: MQTT信息缺失") + raise ValueError("OTA服务器返回的数据无效,请检查服务器状态或MAC地址!") + + except requests.Timeout: + self.logger.error("OTA请求超时,请检查网络或服务器状态") + raise ValueError("OTA请求超时!请稍后重试。") + + except requests.RequestException as e: + self.logger.error(f"OTA请求失败: {e}") + raise ValueError("无法连接到OTA服务器,请检查网络连接!") diff --git a/packages/server-python/xiaozhi/utils/logging_config.py b/packages/server-python/xiaozhi/utils/logging_config.py new file mode 100644 index 0000000..6ce4475 --- /dev/null +++ b/packages/server-python/xiaozhi/utils/logging_config.py @@ -0,0 +1,30 @@ +import logging + + +def setup_logging(): + """配置日志系统""" + + # 创建根日志记录器 + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) # 设置根日志级别 + + # 清除已有的处理器(避免重复添加) + if root_logger.handlers: + root_logger.handlers.clear() + + # 创建控制台处理器 + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + + # 创建格式化器 + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + console_handler.setFormatter(formatter) + + # 添加处理器到根日志记录器 + root_logger.addHandler(console_handler) + + # 设置特定模块的日志级别 + logging.getLogger("XiaoZhi").setLevel(logging.INFO) + logging.getLogger("WebsocketProtocol").setLevel(logging.INFO) diff --git a/packages/server-python/xiaozhi/xiaoai.py b/packages/server-python/xiaozhi/xiaoai.py new file mode 100644 index 0000000..e35242c --- /dev/null +++ b/packages/server-python/xiaozhi/xiaoai.py @@ -0,0 +1,47 @@ +import argparse +import asyncio + +import numpy as np +import open_xiaoai_server + +from xiaozhi.services.audio.stream import GlobalStream + + +class XiaoAi: + mode = "xiaoai" + loop = asyncio.new_event_loop() + + @classmethod + def setup_mode(cls): + parser = argparse.ArgumentParser( + description="小爱音箱接入小智 AI 演示 | by: https://del.wang" + ) + parser.add_argument( + "--mode", + type=str, + choices=["xiaoai", "xiaozhi"], + default="xiaoai", + help="运行模式:【xiaoai】使用小爱音箱的输入输出音频(默认)、【xiaozhi】使用本地电脑的输入输出音频", + ) + args = parser.parse_args() + if args.mode == "xiaozhi": + cls.mode = "xiaozhi" + + @classmethod + def on_input_data(cls, data: bytes): + audio_array = np.frombuffer(data, dtype=np.uint16) + GlobalStream().add_input_data(audio_array.tobytes()) + + @classmethod + def on_output_data(cls, data: bytes): + async def on_output_data_async(data: bytes): + return await open_xiaoai_server.on_output_data(data) + + future = on_output_data_async(data) + cls.loop.run_until_complete(future) + + @classmethod + async def init_xiaoai(cls): + GlobalStream().on_output_data = cls.on_output_data + open_xiaoai_server.register_fn("on_input_data", cls.on_input_data) + await open_xiaoai_server.start_server() diff --git a/packages/server-python/xiaozhi/xiaozhi.py b/packages/server-python/xiaozhi/xiaozhi.py new file mode 100644 index 0000000..92a8158 --- /dev/null +++ b/packages/server-python/xiaozhi/xiaozhi.py @@ -0,0 +1,808 @@ +import asyncio +import json +import logging +import threading +import time + +from xiaozhi.services.display import gui_display +from xiaozhi.services.protocols.typing import ( + AbortReason, + AudioConfig, + DeviceState, + EventType, + ListeningMode, +) +from xiaozhi.services.protocols.websocket_protocol import WebsocketProtocol +from xiaozhi.utils.config_manager import ConfigManager +from xiaozhi.xiaoai import XiaoAi + +# 配置日志 +logger = logging.getLogger("XiaoZhi") + + +class XiaoZhi: + """智能音箱应用程序主类""" + + _instance = None + + @classmethod + def instance(cls): + """获取单例实例""" + if cls._instance is None: + cls._instance = XiaoZhi() + return cls._instance + + def __init__(self): + """初始化应用程序""" + # 确保单例模式 + if XiaoZhi._instance is not None: + raise Exception("XiaoZhi是单例类,请使用instance()获取实例") + XiaoZhi._instance = self + + # 获取配置管理器实例 + self.config = ConfigManager.instance() + + # 状态变量 + self.device_state = DeviceState.IDLE + self.voice_detected = False + self.keep_listening = False + self.aborted = False + self.current_text = "" + self.current_emotion = "neutral" + + # 音频处理相关 + self.audio_codec = None + + # 事件循环和线程 + self.loop = asyncio.new_event_loop() + self.loop_thread = None + self.running = False + + # 任务队列和锁 + self.main_tasks = [] + self.mutex = threading.Lock() + + # 协议实例 + self.protocol = None + + # 回调函数 + self.on_state_changed_callbacks = [] + + # 初始化事件对象 + self.events = { + EventType.SCHEDULE_EVENT: threading.Event(), + EventType.AUDIO_INPUT_READY_EVENT: threading.Event(), + EventType.AUDIO_OUTPUT_READY_EVENT: threading.Event(), + } + + # 创建显示界面 + self.display = None + + def run(self): + self.protocol = WebsocketProtocol() + + # 创建并启动事件循环线程 + self.loop_thread = threading.Thread(target=self._run_event_loop) + self.loop_thread.daemon = True + self.loop_thread.start() + + # 等待事件循环准备就绪 + time.sleep(0.1) + + # 初始化应用程序(移除自动连接) + asyncio.run_coroutine_threadsafe(XiaoAi.init_xiaoai(), self.loop) + asyncio.run_coroutine_threadsafe(self._initialize_without_connect(), self.loop) + + # 启动主循环线程 + main_loop_thread = threading.Thread(target=self._main_loop) + main_loop_thread.daemon = True + main_loop_thread.start() + + # 启动 GUI + self._initialize_display() + self.display.start() + + def _run_event_loop(self): + """运行事件循环的线程函数""" + asyncio.set_event_loop(self.loop) + self.loop.run_forever() + + async def _initialize_without_connect(self): + """初始化应用程序组件(不建立连接)""" + logger.info("正在初始化应用程序...") + + # 设置设备状态为待命 + self.set_device_state(DeviceState.IDLE) + + # 初始化音频编解码器 + self._initialize_audio() + + # 设置协议回调 + self.protocol.on_network_error = self._on_network_error + self.protocol.on_incoming_audio = self._on_incoming_audio + self.protocol.on_incoming_json = self._on_incoming_json + self.protocol.on_audio_channel_opened = self._on_audio_channel_opened + self.protocol.on_audio_channel_closed = self._on_audio_channel_closed + + logger.info("应用程序初始化完成") + + def _initialize_audio(self): + """初始化音频设备和编解码器""" + try: + from xiaozhi.services.audio.codec import AudioCodec + + self.audio_codec = AudioCodec() + logger.info("音频编解码器初始化成功") + except Exception as e: + logger.error(f"初始化音频设备失败: {e}") + self.alert("错误", f"初始化音频设备失败: {e}") + + def _initialize_display(self): + """初始化显示界面""" + self.display = gui_display.GuiDisplay() + + # 设置回调函数 + self.display.set_callbacks( + press_callback=self.start_listening, + release_callback=self.stop_listening, + status_callback=self._get_status_text, + text_callback=self._get_current_text, + emotion_callback=self._get_current_emotion, + mode_callback=self._on_mode_changed, + auto_callback=self.toggle_chat_state, + abort_callback=lambda: self.abort_speaking(AbortReason.WAKE_WORD_DETECTED), + ) + + def _main_loop(self): + """应用程序主循环""" + logger.info("主循环已启动") + self.running = True + + while self.running: + # 等待事件 + for event_type, event in self.events.items(): + if event.is_set(): + event.clear() + + if event_type == EventType.AUDIO_INPUT_READY_EVENT: + self._handle_input_audio() + elif event_type == EventType.AUDIO_OUTPUT_READY_EVENT: + self._handle_output_audio() + elif event_type == EventType.SCHEDULE_EVENT: + self._process_scheduled_tasks() + + # 短暂休眠以避免CPU占用过高 + time.sleep(0.01) + + def _process_scheduled_tasks(self): + """处理调度任务""" + with self.mutex: + tasks = self.main_tasks.copy() + self.main_tasks.clear() + + for task in tasks: + try: + task() + except Exception as e: + logger.error(f"执行调度任务时出错: {e}") + + def schedule(self, callback): + """调度任务到主循环""" + with self.mutex: + # 如果是中止语音的任务,检查是否已经存在相同类型的任务 + if "abort_speaking" in str(callback): + # 如果已经有中止任务在队列中,就不再添加 + if any("abort_speaking" in str(task) for task in self.main_tasks): + return + self.main_tasks.append(callback) + self.events[EventType.SCHEDULE_EVENT].set() + + def _handle_input_audio(self): + """处理音频输入""" + if self.device_state != DeviceState.LISTENING: + return + + encoded_data = self.audio_codec.read_audio() + if encoded_data and self.protocol and self.protocol.is_audio_channel_opened(): + asyncio.run_coroutine_threadsafe( + self.protocol.send_audio(encoded_data), self.loop + ) + + def _handle_output_audio(self): + """处理音频输出""" + if self.device_state != DeviceState.SPEAKING: + return + + self.audio_codec.play_audio() + + def _on_network_error(self, message): + """网络错误回调""" + self.keep_listening = False + self.set_device_state(DeviceState.IDLE) + if self.device_state != DeviceState.CONNECTING: + logger.info("检测到连接断开") + self.set_device_state(DeviceState.IDLE) + + # 关闭现有连接 + if self.protocol: + asyncio.run_coroutine_threadsafe( + self.protocol.close_audio_channel(), self.loop + ) + + def _attempt_reconnect(self): + """尝试重新连接服务器""" + if self.device_state != DeviceState.CONNECTING: + logger.info("检测到连接断开,尝试重新连接...") + self.set_device_state(DeviceState.CONNECTING) + + # 关闭现有连接 + if self.protocol: + asyncio.run_coroutine_threadsafe( + self.protocol.close_audio_channel(), self.loop + ) + + # 延迟一秒后尝试重新连接 + def delayed_reconnect(): + time.sleep(1) + asyncio.run_coroutine_threadsafe(self._reconnect(), self.loop) + + threading.Thread(target=delayed_reconnect, daemon=True).start() + + async def _reconnect(self): + """重新连接到服务器""" + + # 设置协议回调 + self.protocol.on_network_error = self._on_network_error + self.protocol.on_incoming_audio = self._on_incoming_audio + self.protocol.on_incoming_json = self._on_incoming_json + self.protocol.on_audio_channel_opened = self._on_audio_channel_opened + self.protocol.on_audio_channel_closed = self._on_audio_channel_closed + + # 连接到服务器 + retry_count = 0 + max_retries = 3 + + while retry_count < max_retries: + logger.info(f"尝试重新连接 (尝试 {retry_count + 1}/{max_retries})...") + if await self.protocol.connect(): + logger.info("重新连接成功") + self.set_device_state(DeviceState.IDLE) + return True + + retry_count += 1 + await asyncio.sleep(2) # 等待2秒后重试 + + logger.error(f"重新连接失败,已尝试 {max_retries} 次") + self.schedule(lambda: self.alert("连接错误", "无法重新连接到服务器")) + self.set_device_state(DeviceState.IDLE) + return False + + def _on_incoming_audio(self, data): + """接收音频数据回调""" + if self.device_state == DeviceState.SPEAKING: + self.audio_codec.write_audio(data) + self.events[EventType.AUDIO_OUTPUT_READY_EVENT].set() + + def _on_incoming_json(self, json_data): + """接收JSON数据回调""" + try: + if not json_data: + return + + # 解析JSON数据 + if isinstance(json_data, str): + data = json.loads(json_data) + else: + data = json_data + + # 处理不同类型的消息 + msg_type = data.get("type", "") + if msg_type == "tts": + self._handle_tts_message(data) + elif msg_type == "stt": + self._handle_stt_message(data) + elif msg_type == "llm": + self._handle_llm_message(data) + else: + logger.warning(f"收到未知类型的消息: {msg_type}") + except Exception as e: + logger.error(f"处理JSON消息时出错: {e}") + + def _handle_tts_message(self, data): + """处理TTS消息""" + state = data.get("state", "") + if state == "start": + self.schedule(lambda: self._handle_tts_start()) + elif state == "stop": + self.schedule(lambda: self._handle_tts_stop()) + elif state == "sentence_start": + text = data.get("text", "") + if text: + logger.info(f"<< {text}") + self.schedule(lambda: self.set_chat_message("assistant", text)) + + # 检查是否包含验证码信息 + if "请登录到控制面板添加设备,输入验证码" in text: + self.schedule(lambda: self._handle_verification_code(text)) + + def _handle_tts_start(self): + """处理TTS开始事件""" + self.aborted = False + + # 清空可能存在的旧音频数据 + self.audio_codec.clear_audio_queue() + + if ( + self.device_state == DeviceState.IDLE + or self.device_state == DeviceState.LISTENING + ): + self.set_device_state(DeviceState.SPEAKING) + + def _handle_tts_stop(self): + """处理TTS停止事件""" + if self.device_state == DeviceState.SPEAKING: + # 给音频播放一个缓冲时间,确保所有音频都播放完毕 + def delayed_state_change(): + # 等待音频队列清空 + self.audio_codec.wait_for_audio_complete() + + # 状态转换 + if self.keep_listening: + asyncio.run_coroutine_threadsafe( + self.protocol.send_start_listening(ListeningMode.AUTO_STOP), + self.loop, + ) + self.set_device_state(DeviceState.LISTENING) + else: + self.set_device_state(DeviceState.IDLE) + + # 安排延迟执行 + threading.Thread(target=delayed_state_change, daemon=True).start() + + def _handle_stt_message(self, data): + """处理STT消息""" + text = data.get("text", "") + if text: + logger.info(f">> {text}") + self.schedule(lambda: self.set_chat_message("user", text)) + + def _handle_llm_message(self, data): + """处理LLM消息""" + emotion = data.get("emotion", "") + if emotion: + self.schedule(lambda: self.set_emotion(emotion)) + + async def _on_audio_channel_opened(self): + """音频通道打开回调""" + logger.info("音频通道已打开") + self.schedule(lambda: self._start_audio_streams()) + + def _start_audio_streams(self): + """启动音频流""" + try: + # 确保流已关闭后再重新打开 + if ( + self.audio_codec.input_stream + and self.audio_codec.input_stream.is_active() + ): + self.audio_codec.input_stream.stop_stream() + + # 重新打开流 + self.audio_codec.input_stream.start_stream() + + if ( + self.audio_codec.output_stream + and self.audio_codec.output_stream.is_active() + ): + self.audio_codec.output_stream.stop_stream() + + # 重新打开流 + self.audio_codec.output_stream.start_stream() + + # 设置事件触发器 + threading.Thread( + target=self._audio_input_event_trigger, daemon=True + ).start() + threading.Thread( + target=self._audio_output_event_trigger, daemon=True + ).start() + + logger.info("音频流已启动") + except Exception as e: + logger.error(f"启动音频流失败: {e}") + + def _audio_input_event_trigger(self): + """音频输入事件触发器""" + while self.running: + try: + if ( + self.audio_codec.input_stream + and self.audio_codec.input_stream.is_active() + ): + self.events[EventType.AUDIO_INPUT_READY_EVENT].set() + except OSError as e: + logger.error(f"音频输入流错误: {e}") + # 如果流已关闭,尝试重新打开或者退出循环 + if "Stream not open" in str(e): + break + except Exception as e: + logger.error(f"音频输入事件触发器错误: {e}") + + time.sleep(AudioConfig.FRAME_DURATION / 1000) # 按帧时长触发 + + def _audio_output_event_trigger(self): + """音频输出事件触发器""" + while ( + self.running + and self.audio_codec.output_stream + and self.audio_codec.output_stream.is_active() + ): + # 当队列中有数据时才触发事件 + if ( + not self.audio_codec.audio_decode_queue.empty() + ): # 修改为使用 audio_codec 的队列 + self.events[EventType.AUDIO_OUTPUT_READY_EVENT].set() + time.sleep(0.02) # 稍微延长检查间隔 + + async def _on_audio_channel_closed(self): + """音频通道关闭回调""" + logger.info("音频通道已关闭") + self.set_device_state(DeviceState.IDLE) + self.keep_listening = False + self.schedule(lambda: self._stop_audio_streams()) + + def _stop_audio_streams(self): + """停止音频流""" + try: + if ( + self.audio_codec.input_stream + and self.audio_codec.input_stream.is_active() + ): + self.audio_codec.input_stream.stop_stream() + + if ( + self.audio_codec.output_stream + and self.audio_codec.output_stream.is_active() + ): + self.audio_codec.output_stream.stop_stream() + + logger.info("音频流已停止") + except Exception as e: + logger.error(f"停止音频流失败: {e}") + + def set_device_state(self, state): + """设置设备状态""" + if self.device_state == state: + return + + old_state = self.device_state + + # 如果从 SPEAKING 状态切换出去,确保音频播放完成 + if old_state == DeviceState.SPEAKING: + self.audio_codec.wait_for_audio_complete() + + self.device_state = state + logger.info(f"状态变更: {old_state} -> {state}") + + # 根据状态执行相应操作 + if state == DeviceState.IDLE: + self.display.update_status("待命") + self.display.update_emotion("😶") + # 停止输出流但不关闭它 + if ( + self.audio_codec.output_stream + and self.audio_codec.output_stream.is_active() + ): + try: + self.audio_codec.output_stream.stop_stream() + except Exception as e: + logger.warning(f"停止输出流时出错: {e}") + elif state == DeviceState.CONNECTING: + self.display.update_status("连接中...") + elif state == DeviceState.LISTENING: + self.display.update_status("聆听中...") + self.display.update_emotion("🙂") + if ( + self.audio_codec.input_stream + and not self.audio_codec.input_stream.is_active() + ): + try: + self.audio_codec.input_stream.start_stream() + except Exception as e: + logger.warning(f"启动输入流时出错: {e}") + # 使用 AudioCodec 类中的方法重新初始化 + self.audio_codec._reinitialize_input_stream() + elif state == DeviceState.SPEAKING: + self.display.update_status("说话中...") + # 确保输出流处于活跃状态 + if self.audio_codec.output_stream: + if not self.audio_codec.output_stream.is_active(): + try: + self.audio_codec.output_stream.start_stream() + except Exception as e: + logger.warning(f"启动输出流时出错: {e}") + # 使用 AudioCodec 类中的方法重新初始化 + self.audio_codec._reinitialize_output_stream() + # 停止输入流 + if ( + self.audio_codec.input_stream + and self.audio_codec.input_stream.is_active() + ): + try: + self.audio_codec.input_stream.stop_stream() + except Exception as e: + logger.warning(f"停止输入流时出错: {e}") + + # 通知状态变化 + for callback in self.on_state_changed_callbacks: + try: + callback(state) + except Exception as e: + logger.error(f"执行状态变化回调时出错: {e}") + + def _get_status_text(self): + """获取当前状态文本""" + states = { + DeviceState.IDLE: "待命", + DeviceState.CONNECTING: "连接中...", + DeviceState.LISTENING: "聆听中...", + DeviceState.SPEAKING: "说话中...", + } + return states.get(self.device_state, "未知") + + def _get_current_text(self): + """获取当前显示文本""" + return self.current_text + + def _get_current_emotion(self): + """获取当前表情""" + emotions = { + "neutral": "😶", + "happy": "🙂", + "laughing": "😆", + "funny": "😂", + "sad": "😔", + "angry": "😠", + "crying": "😭", + "loving": "😍", + "embarrassed": "😳", + "surprised": "😲", + "shocked": "😱", + "thinking": "🤔", + "winking": "😉", + "cool": "😎", + "relaxed": "😌", + "delicious": "🤤", + "kissy": "😘", + "confident": "😏", + "sleepy": "😴", + "silly": "😜", + "confused": "🙄", + } + return emotions.get(self.current_emotion, "😶") + + def set_chat_message(self, role, message): + """设置聊天消息""" + self.current_text = message + # 更新显示 + if self.display: + self.display.update_text(message) + + def set_emotion(self, emotion): + """设置表情""" + self.current_emotion = emotion + # 更新显示 + if self.display: + self.display.update_emotion(self._get_current_emotion()) + + def start_listening(self): + """开始监听""" + self.schedule(self._start_listening_impl) + + def _start_listening_impl(self): + """开始监听的实现""" + if not self.protocol: + logger.error("协议未初始化") + return + + self.keep_listening = False + + if self.device_state == DeviceState.IDLE: + self.set_device_state(DeviceState.CONNECTING) # 设置设备状态为连接中 + + # 尝试打开音频通道 + if not self.protocol.is_audio_channel_opened(): + try: + # 等待异步操作完成 + future = asyncio.run_coroutine_threadsafe( + self.protocol.open_audio_channel(), self.loop + ) + # 等待操作完成并获取结果 + success = future.result(timeout=10.0) # 添加超时时间 + + if not success: + self.alert("错误", "打开音频通道失败") # 弹出错误提示 + self.set_device_state(DeviceState.IDLE) # 设置设备状态为空闲 + return + + except Exception as e: + logger.error(f"打开音频通道时发生错误: {e}") + self.alert("错误", f"打开音频通道失败: {str(e)}") + self.set_device_state(DeviceState.IDLE) + return + + asyncio.run_coroutine_threadsafe( + self.protocol.send_start_listening(ListeningMode.MANUAL), self.loop + ) + self.set_device_state(DeviceState.LISTENING) # 设置设备状态为监听中 + elif self.device_state == DeviceState.SPEAKING: + if not self.aborted: + self.abort_speaking(AbortReason.WAKE_WORD_DETECTED) + + async def _open_audio_channel_and_start_manual_listening(self): + """打开音频通道并开始手动监听""" + if not await self.protocol.open_audio_channel(): + self.set_device_state(DeviceState.IDLE) + self.alert("错误", "打开音频通道失败") + return + + await self.protocol.send_start_listening(ListeningMode.MANUAL) + self.set_device_state(DeviceState.LISTENING) + + def toggle_chat_state(self): + """切换聊天状态""" + self.schedule(self._toggle_chat_state_impl) + + def _toggle_chat_state_impl(self): + """切换聊天状态的具体实现""" + # 检查协议是否已初始化 + if not self.protocol: + logger.error("协议未初始化") + return + + # 如果设备当前处于空闲状态,尝试连接并开始监听 + if self.device_state == DeviceState.IDLE: + self.set_device_state(DeviceState.CONNECTING) # 设置设备状态为连接中 + + # 尝试打开音频通道 + if not self.protocol.is_audio_channel_opened(): + try: + # 等待异步操作完成 + future = asyncio.run_coroutine_threadsafe( + self.protocol.open_audio_channel(), self.loop + ) + # 等待操作完成并获取结果 + success = future.result(timeout=10.0) # 添加超时时间 + + if not success: + self.alert("错误", "打开音频通道失败") # 弹出错误提示 + self.set_device_state(DeviceState.IDLE) # 设置设备状态为空闲 + return + + except Exception as e: + logger.error(f"打开音频通道时发生错误: {e}") + self.alert("错误", f"打开音频通道失败: {str(e)}") + self.set_device_state(DeviceState.IDLE) + return + + self.keep_listening = True # 开始监听 + # 启动自动停止的监听模式 + asyncio.run_coroutine_threadsafe( + self.protocol.send_start_listening(ListeningMode.AUTO_STOP), self.loop + ) + self.set_device_state(DeviceState.LISTENING) # 设置设备状态为监听中 + + # 如果设备正在说话,停止当前说话 + elif self.device_state == DeviceState.SPEAKING: + self.abort_speaking(AbortReason.NONE) # 中止说话 + + # 如果设备正在监听,关闭音频通道 + elif self.device_state == DeviceState.LISTENING: + asyncio.run_coroutine_threadsafe( + self.protocol.close_audio_channel(), self.loop + ) + + def stop_listening(self): + """停止监听""" + self.schedule(self._stop_listening_impl) + + def _stop_listening_impl(self): + """停止监听的实现""" + if self.device_state == DeviceState.LISTENING: + asyncio.run_coroutine_threadsafe( + self.protocol.send_stop_listening(), self.loop + ) + self.set_device_state(DeviceState.IDLE) + + def abort_speaking(self, reason): + """中止语音输出""" + logger.info(f"中止语音输出,原因: {reason}") + self.aborted = True + asyncio.run_coroutine_threadsafe( + self.protocol.send_abort_speaking(reason), self.loop + ) + self.set_device_state(DeviceState.IDLE) + + # 添加此代码:当用户主动打断时自动进入录音模式 + if reason == AbortReason.WAKE_WORD_DETECTED and self.keep_listening: + # 短暂延迟确保abort命令被处理 + def start_listening_after_abort(): + time.sleep(0.2) # 短暂延迟 + self.set_device_state(DeviceState.IDLE) + self.schedule(lambda: self.toggle_chat_state()) + + threading.Thread(target=start_listening_after_abort, daemon=True).start() + + def alert(self, title, message): + """显示警告信息""" + logger.warning(f"警告: {title}, {message}") + # 在GUI上显示警告 + if self.display: + self.display.update_text(f"{title}: {message}") + + def on_state_changed(self, callback): + """注册状态变化回调""" + self.on_state_changed_callbacks.append(callback) + + def shutdown(self): + """关闭应用程序""" + logger.info("正在关闭应用程序...") + self.running = False + + # 关闭音频编解码器 + if self.audio_codec: + self.audio_codec.close() + + # 关闭协议 + if self.protocol: + asyncio.run_coroutine_threadsafe( + self.protocol.close_audio_channel(), self.loop + ) + + # 停止事件循环 + if self.loop and self.loop.is_running(): + self.loop.call_soon_threadsafe(self.loop.stop) + + # 等待事件循环线程结束 + if self.loop_thread and self.loop_thread.is_alive(): + self.loop_thread.join(timeout=1.0) + + logger.info("应用程序已关闭") + + def _handle_verification_code(self, text): + """处理验证码信息""" + try: + # 提取验证码 + import re + + verification_code = re.search(r"验证码:(\d+)", text) + if verification_code: + code = verification_code.group(1) + + # 尝试打开浏览器 + try: + import webbrowser + + if webbrowser.open("https://xiaozhi.me/login"): + logger.info("已打开登录页面") + else: + logger.warning("无法打开浏览器") + except Exception as e: + logger.warning(f"打开浏览器时出错: {e}") + + # 无论如何都显示验证码 + self.alert("验证码", f"您的验证码是: {code}") + + except Exception as e: + logger.error(f"处理验证码时出错: {e}") + + def _on_mode_changed(self, auto_mode): + """处理对话模式变更""" + # 只有在IDLE状态下才允许切换模式 + if self.device_state != DeviceState.IDLE: + self.alert("提示", "只有在待命状态下才能切换对话模式") + return False + + self.keep_listening = auto_mode + logger.info(f"对话模式已切换为: {'自动' if auto_mode else '手动'}") + return True