diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ad7e2a9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo fmt --check + - run: cargo clippy --all-targets -- -D warnings + - run: cargo test + - run: cargo build --release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9a7254 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +*.log +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5e798ea --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# AGENTS.md + +Local kokoro-fastpi: http://127.0.0.1:8880/ +Local log file example: ~/Games/EverQuestLegends/Logs/eqlog_Pleb_oggok.txt + +## References + +* The [OpenAPI spec for Kokoro FastAPI](/docs/kokoro-fastapi.json) \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b2d0d2b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2875 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.1", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coreaudio-rs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +dependencies = [ + "bitflags 1.3.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" +dependencies = [ + "alsa", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mudmouth" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "crossterm", + "dirs", + "rand 0.9.5", + "ratatui", + "reqwest", + "rodio", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "objc2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[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 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.13.1", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rodio" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40ecf59e742e03336be6a3d53755e789fd05a059fa22dfa0ed624722319e183" +dependencies = [ + "cpal", + "dasp_sample", + "num-rational", + "symphonia", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8c7fdb8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "mudmouth" +version = "0.1.0" +edition = "2024" +description = "A classic MUD-inspired EverQuest log reader with Kokoro voices" +license = "MIT" +repository = "https://github.com/pleb/mudmouth" + +[dependencies] +anyhow = "1" +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["derive"] } +crossterm = "0.28" +dirs = "6" +rand = "0.9" +ratatui = "0.29" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rodio = { version = "0.21", default-features = true } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } +toml = "0.8" + +[dev-dependencies] +tempfile = "3" + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..eff91a6 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Mudmouth + +```text + _ _ _ + _ __ ___ _ _ __| |_ __ ___ ___ | | | |_ ____ +| '_ ` _ \| | | |/ _` | '_ ` _ \ / _ \| |_| | '_ / +| | | | | | |_| | (_| | | | | | | (_) | _ | | | | +|_| |_| |_|\__,_|\__,_|_| |_| |_|\___/|_| |_|_| |_| +``` + +Mudmouth is a cross-platform, classic-MUD-inspired TUI for EverQuest Legends. +It follows an EverQuest log, gives NPC dialogue a Kokoro-FastAPI voice, and +remembers the chosen voice for each NPC. It is entirely local: your log, +speaker mappings, generated audio, and TTS endpoint stay on your computer. + +## What it does + +- Watches an EverQuest log without replaying historical lines on startup. +- Speaks `Name says, '…'` NPC dialogue by default. +- Calls `GET /health`, shows live Kokoro connectivity in the status bar, and + refreshes its selectable voice catalog from `GET /v1/audio/voices`. +- Sends non-streaming WAV requests to `POST /v1/audio/speech` and plays clips + sequentially with native audio support. +- Picks a real voice from your Kokoro server for a newly seen NPC and saves it. +- Lets you pin an NPC to a particular voice, reroll a persistent random voice, + or set the NPC to get a new random voice for every line. +- Can optionally narrate zone entries (`You have entered Lake Rathetear.`). + This is disabled by default, as are future optional message categories. + +## Requirements + +- Rust stable (edition 2024) +- A terminal with ANSI support: Windows Terminal, iTerm2, Terminal.app, GNOME + Terminal, and similar terminals work well. +- A reachable [Kokoro-FastAPI](docs/kokoro-fastapi.json) instance. The default + is `http://127.0.0.1:8880`. +- An EverQuest log with `/log on`. + +No `ffplay`, `mpv`, PipeWire command, or other external audio player is +required; Mudmouth plays returned WAV audio itself. + +## Build and run + +On NixOS, enter the included development shell. It provides the ALSA +development metadata required by the native Linux audio backend: + +```bash +nix-shell --run 'cargo run --release -- --log "$HOME/Games/EverQuestLegends/Logs/eqlog_Pleb_oggok.txt"' +``` + +On other platforms, or when Rust and the native audio development packages are +already installed: + +```bash +cargo run --release -- --log "$HOME/Games/EverQuestLegends/Logs/eqlog_Pleb_oggok.txt" +``` + +Or persist the paths through the app: + +```bash +cargo run --release +``` + +Press `s`, select **EverQuest log**, and enter the file path. Change the +Kokoro endpoint from the same screen. Mudmouth validates the endpoint in the +background and the header reads `CONNECTED`, `CHECKING`, or `UNREACHABLE`. +Changing a log path starts watching the new file immediately. A command-line +endpoint override is also available: + +```bash +cargo run --release -- --kokoro-url http://127.0.0.1:8880 +``` + +## TUI controls + +| Key | Action | +| --- | --- | +| `d` | Dashboard / live tavern scroll | +| `s` | Settings | +| `v` | NPC voice manager | +| `?` | Help | +| `q` | Quit | +| `↑` / `↓`, `j` / `k` | Move selection | +| `Enter`, `Space` | Edit or toggle a setting | +| `←` / `→`, `h` / `l` | Choose a voice | +| `p` | Pin selected NPC to selected voice | +| `r` | Random voice every NPC line | +| `x` | Choose a fresh persistent random voice | +| `Esc` | Close a menu or edit box | + +## Data and privacy + +Mudmouth writes only two human-readable files in the operating system's +standard application-data directory: + +- `config.toml` — endpoint, log path, event toggles, and playback settings +- `speakers.json` — NPC names, voice modes, and last-seen context + +On Linux this is normally `~/.local/share/mudmouth`; the equivalent platform +directory is used on macOS and Windows. + +## Development + +```bash +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo test +cargo build --release +``` + +The parser tests cover the observed log formats, including NPC dialogue, +channel-chat exclusion, zone events, normalization, persistent/random speaker +voice policies, and log truncation. diff --git a/docs/kokoro-fastapi.json b/docs/kokoro-fastapi.json new file mode 100644 index 0000000..17cd467 --- /dev/null +++ b/docs/kokoro-fastapi.json @@ -0,0 +1,1002 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Kokoro TTS API", + "description": "API for text-to-speech generation using Kokoro", + "version": "0.6.0-rc1" + }, + "paths": { + "/v1/audio/speech": { + "post": { + "tags": [ + "OpenAI Compatible TTS" + ], + "summary": "Create Speech", + "description": "OpenAI-compatible endpoint for text-to-speech", + "operationId": "create_speech_v1_audio_speech_post", + "parameters": [ + { + "name": "x-raw-response", + "in": "header", + "required": false, + "schema": { + "type": "string", + "title": "X-Raw-Response" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAISpeechRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/download/{filename}": { + "get": { + "tags": [ + "OpenAI Compatible TTS" + ], + "summary": "Download Audio File", + "description": "Download a generated audio file from temp storage", + "operationId": "download_audio_file_v1_download__filename__get", + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Filename" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/models": { + "get": { + "tags": [ + "OpenAI Compatible TTS" + ], + "summary": "List Models", + "description": "List all available models", + "operationId": "list_models_v1_models_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/v1/models/{model}": { + "get": { + "tags": [ + "OpenAI Compatible TTS" + ], + "summary": "Retrieve Model", + "description": "Retrieve a specific model", + "operationId": "retrieve_model_v1_models__model__get", + "parameters": [ + { + "name": "model", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Model" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/audio/voices": { + "get": { + "tags": [ + "OpenAI Compatible TTS" + ], + "summary": "List Voices", + "description": "List all available voices for text-to-speech.\n\nReturns `[{\"id\": ..., \"name\": ...}, ...]` by default so OpenAI-compatible\nclients (Open WebUI in particular, which does `voice['id']` directly and\nsilently falls back to a hardcoded 6-voice list otherwise) can render the\nfull voice list. Pass `?legacy=true` for the pre-0.3.x plain-string shape.", + "operationId": "list_voices_v1_audio_voices_get", + "parameters": [ + { + "name": "legacy", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Legacy" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/audio/voices/combine": { + "post": { + "tags": [ + "OpenAI Compatible TTS" + ], + "summary": "Combine Voices", + "description": "Combine multiple voices into a new voice and return the .pt file.\n\nArgs:\n request: Either a string with voices separated by + (e.g. \"voice1+voice2\")\n or a list of voice names to combine\n\nReturns:\n FileResponse with the combined voice .pt file\n\nRaises:\n HTTPException:\n - 400: Invalid request (wrong number of voices, voice not found)\n - 500: Server error (file system issues, combination failed)", + "operationId": "combine_voices_v1_audio_voices_combine_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "title": "Request" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/dev/phonemize": { + "post": { + "tags": [ + "text processing" + ], + "summary": "Phonemize Text", + "description": "Convert text to phonemes using Kokoro's quiet mode.\n\nArgs:\n request: Request containing text and language\n\nReturns:\n Phonemes and token IDs", + "operationId": "phonemize_text_dev_phonemize_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PhonemeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PhonemeResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/dev/generate_from_phonemes": { + "post": { + "tags": [ + "text processing" + ], + "summary": "Generate From Phonemes", + "description": "Generate audio directly from phonemes using Kokoro's phoneme format", + "operationId": "generate_from_phonemes_dev_generate_from_phonemes_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateFromPhonemesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/dev/captioned_speech": { + "post": { + "tags": [ + "text processing" + ], + "summary": "Create Captioned Speech", + "description": "Generate audio with word-level timestamps using streaming approach", + "operationId": "create_captioned_speech_dev_captioned_speech_post", + "parameters": [ + { + "name": "x-raw-response", + "in": "header", + "required": false, + "schema": { + "type": "string", + "title": "X-Raw-Response" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptionedSpeechRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/dev/unload": { + "post": { + "tags": [ + "text processing" + ], + "summary": "Unload Model", + "description": "Release the model from GPU VRAM without stopping the container.\n\nThe model reloads automatically on the next inference request.\nUseful for homelab deployments where GPU memory is shared across services.", + "operationId": "unload_model_dev_unload_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/debug/threads": { + "get": { + "tags": [ + "debug" + ], + "summary": "Get Thread Info", + "operationId": "get_thread_info_debug_threads_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/debug/storage": { + "get": { + "tags": [ + "debug" + ], + "summary": "Get Storage Info", + "operationId": "get_storage_info_debug_storage_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/debug/system": { + "get": { + "tags": [ + "debug" + ], + "summary": "Get System Info", + "operationId": "get_system_info_debug_system_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/debug/session_pools": { + "get": { + "tags": [ + "debug" + ], + "summary": "Get Session Pool Info", + "description": "Get information about ONNX session pools.", + "operationId": "get_session_pool_info_debug_session_pools_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/web/config": { + "get": { + "tags": [ + "Web Player" + ], + "summary": "Get Web Config", + "description": "Get web player configuration including UVICORN_ROOT_PATH.", + "operationId": "get_web_config_web_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/web/{filename}": { + "get": { + "tags": [ + "Web Player" + ], + "summary": "Serve Web File", + "description": "Serve web player static files asynchronously.", + "operationId": "serve_web_file_web__filename__get", + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Filename" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/health": { + "get": { + "summary": "Health Check", + "description": "Health check endpoint", + "operationId": "health_check_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/v1/test": { + "get": { + "summary": "Test Endpoint", + "description": "Test endpoint to verify routing", + "operationId": "test_endpoint_v1_test_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CaptionedSpeechRequest": { + "properties": { + "model": { + "type": "string", + "title": "Model", + "description": "The model to use for generation. Supported models: tts-1, tts-1-hd, kokoro", + "default": "kokoro" + }, + "input": { + "type": "string", + "title": "Input", + "description": "The text to generate audio for" + }, + "voice": { + "type": "string", + "title": "Voice", + "description": "The voice to use for generation. Can be a base voice or a combined voice name.", + "default": "af_heart" + }, + "response_format": { + "type": "string", + "enum": [ + "mp3", + "opus", + "aac", + "flac", + "wav", + "pcm" + ], + "title": "Response Format", + "description": "The format to return audio in. Supported formats: mp3, opus, flac, wav, pcm. PCM format returns raw 16-bit samples without headers. AAC is not currently supported.", + "default": "mp3" + }, + "speed": { + "type": "number", + "maximum": 4.0, + "minimum": 0.25, + "title": "Speed", + "description": "The speed of the generated audio. Select a value from 0.25 to 4.0.", + "default": 1.0 + }, + "stream": { + "type": "boolean", + "title": "Stream", + "description": "If true (default), audio will be streamed as it's generated. Each chunk will be a complete sentence.", + "default": true + }, + "return_timestamps": { + "type": "boolean", + "title": "Return Timestamps", + "description": "If true (default), returns word-level timestamps in the response", + "default": true + }, + "return_download_link": { + "type": "boolean", + "title": "Return Download Link", + "description": "If true, returns a download link in X-Download-Path header after streaming completes", + "default": false + }, + "lang_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Lang Code", + "description": "Optional language code to use for text processing. If not provided, will use first letter of voice name." + }, + "volume_multiplier": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Volume Multiplier", + "description": "A volume multiplier to multiply the output audio by.", + "default": 1.0 + }, + "normalization_options": { + "anyOf": [ + { + "$ref": "#/components/schemas/NormalizationOptions" + }, + { + "type": "null" + } + ], + "description": "Options for the normalization system", + "default": { + "normalize": true, + "unit_normalization": false, + "url_normalization": true, + "email_normalization": true, + "optional_pluralization_normalization": true, + "phone_normalization": true, + "replace_remaining_symbols": true + } + } + }, + "type": "object", + "required": [ + "input" + ], + "title": "CaptionedSpeechRequest", + "description": "Request schema for captioned speech endpoint" + }, + "GenerateFromPhonemesRequest": { + "properties": { + "phonemes": { + "type": "string", + "title": "Phonemes", + "description": "Phoneme string to synthesize" + }, + "voice": { + "type": "string", + "title": "Voice", + "description": "Voice ID to use for generation" + } + }, + "type": "object", + "required": [ + "phonemes", + "voice" + ], + "title": "GenerateFromPhonemesRequest", + "description": "Simple request for phoneme-to-speech generation" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "NormalizationOptions": { + "properties": { + "normalize": { + "type": "boolean", + "title": "Normalize", + "description": "Normalizes input text to make it easier for the model to say", + "default": true + }, + "unit_normalization": { + "type": "boolean", + "title": "Unit Normalization", + "description": "Transforms units like 10KB to 10 kilobytes", + "default": false + }, + "url_normalization": { + "type": "boolean", + "title": "Url Normalization", + "description": "Changes urls so they can be properly pronounced by kokoro", + "default": true + }, + "email_normalization": { + "type": "boolean", + "title": "Email Normalization", + "description": "Changes emails so they can be properly pronouced by kokoro", + "default": true + }, + "optional_pluralization_normalization": { + "type": "boolean", + "title": "Optional Pluralization Normalization", + "description": "Replaces (s) with s so some words get pronounced correctly", + "default": true + }, + "phone_normalization": { + "type": "boolean", + "title": "Phone Normalization", + "description": "Changes phone numbers so they can be properly pronouced by kokoro", + "default": true + }, + "replace_remaining_symbols": { + "type": "boolean", + "title": "Replace Remaining Symbols", + "description": "Replaces the remaining symbols after normalization with their words", + "default": true + } + }, + "type": "object", + "title": "NormalizationOptions", + "description": "Options for the normalization system" + }, + "OpenAISpeechRequest": { + "properties": { + "model": { + "type": "string", + "title": "Model", + "description": "The model to use for generation. Supported models: tts-1, tts-1-hd, kokoro", + "default": "kokoro" + }, + "input": { + "type": "string", + "title": "Input", + "description": "The text to generate audio for" + }, + "voice": { + "type": "string", + "title": "Voice", + "description": "The voice to use for generation. Can be a base voice or a combined voice name.", + "default": "af_heart" + }, + "response_format": { + "type": "string", + "enum": [ + "mp3", + "opus", + "aac", + "flac", + "wav", + "pcm" + ], + "title": "Response Format", + "description": "The format to return audio in. Supported formats: mp3, opus, flac, wav, pcm. PCM format returns raw 16-bit samples without headers. AAC is not currently supported.", + "default": "mp3" + }, + "download_format": { + "anyOf": [ + { + "type": "string", + "enum": [ + "mp3", + "opus", + "aac", + "flac", + "wav", + "pcm" + ] + }, + { + "type": "null" + } + ], + "title": "Download Format", + "description": "Optional different format for the final download. If not provided, uses response_format." + }, + "speed": { + "type": "number", + "maximum": 4.0, + "minimum": 0.25, + "title": "Speed", + "description": "The speed of the generated audio. Select a value from 0.25 to 4.0.", + "default": 1.0 + }, + "stream": { + "type": "boolean", + "title": "Stream", + "description": "If true (default), audio will be streamed as it's generated. Each chunk will be a complete sentence.", + "default": true + }, + "return_download_link": { + "type": "boolean", + "title": "Return Download Link", + "description": "If true, returns a download link in X-Download-Path header after streaming completes", + "default": false + }, + "lang_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Lang Code", + "description": "Optional language code to use for text processing. If not provided, will use first letter of voice name." + }, + "volume_multiplier": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Volume Multiplier", + "description": "A volume multiplier to multiply the output audio by.", + "default": 1.0 + }, + "normalization_options": { + "anyOf": [ + { + "$ref": "#/components/schemas/NormalizationOptions" + }, + { + "type": "null" + } + ], + "description": "Options for the normalization system", + "default": { + "normalize": true, + "unit_normalization": false, + "url_normalization": true, + "email_normalization": true, + "optional_pluralization_normalization": true, + "phone_normalization": true, + "replace_remaining_symbols": true + } + } + }, + "type": "object", + "required": [ + "input" + ], + "title": "OpenAISpeechRequest", + "description": "Request schema for OpenAI-compatible speech endpoint" + }, + "PhonemeRequest": { + "properties": { + "text": { + "type": "string", + "title": "Text" + }, + "language": { + "type": "string", + "title": "Language", + "default": "a" + } + }, + "type": "object", + "required": [ + "text" + ], + "title": "PhonemeRequest" + }, + "PhonemeResponse": { + "properties": { + "phonemes": { + "type": "string", + "title": "Phonemes" + }, + "tokens": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Tokens" + } + }, + "type": "object", + "required": [ + "phonemes", + "tokens" + ], + "title": "PhonemeResponse" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + } +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..90df936 --- /dev/null +++ b/shell.nix @@ -0,0 +1,12 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + packages = with pkgs; [ + cargo + rustc + pkg-config + alsa-lib + ]; + + PKG_CONFIG_PATH = "${pkgs.alsa-lib.dev}/lib/pkgconfig"; +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..90cf175 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,111 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +pub const APP_DIRECTORY: &str = "mudmouth"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Config { + pub kokoro_url: String, + pub log_path: Option, + pub volume: f32, + pub max_queue_length: usize, + pub dedup_window_seconds: u64, + pub events: EventSettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct EventSettings { + pub npc_dialogue: bool, + pub zone_transitions: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + kokoro_url: "http://127.0.0.1:8880".into(), + log_path: None, + volume: 0.85, + max_queue_length: 10, + dedup_window_seconds: 5, + events: EventSettings::default(), + } + } +} + +impl Default for EventSettings { + fn default() -> Self { + Self { + npc_dialogue: true, + zone_transitions: false, + } + } +} + +impl Config { + pub fn app_dir() -> Result { + dirs::data_local_dir() + .or_else(dirs::data_dir) + .map(|path| path.join(APP_DIRECTORY)) + .context("could not determine a platform data directory") + } + + pub fn path() -> Result { + Ok(Self::app_dir()?.join("config.toml")) + } + + pub fn load() -> Result { + let path = Self::path()?; + if !path.exists() { + return Ok(Self::default()); + } + let contents = fs::read_to_string(&path) + .with_context(|| format!("could not read {}", path.display()))?; + toml::from_str(&contents).context("could not parse Mudmouth config") + } + + pub fn save(&self) -> Result<()> { + let path = Self::path()?; + atomic_write(&path, toml::to_string_pretty(self)?.as_bytes()) + } +} + +pub fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> { + let parent = path + .parent() + .context("cannot save a file without a parent directory")?; + fs::create_dir_all(parent)?; + let temporary = path.with_extension("tmp"); + fs::write(&temporary, contents) + .with_context(|| format!("could not write {}", temporary.display()))?; + fs::rename(&temporary, path) + .with_context(|| format!("could not replace {}", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn atomically_replaces_config_contents() { + let directory = tempdir().unwrap(); + let path = directory.path().join("config.toml"); + atomic_write(&path, b"volume = 0.5\n").unwrap(); + atomic_write(&path, b"volume = 0.8\n").unwrap(); + assert_eq!(std::fs::read_to_string(path).unwrap(), "volume = 0.8\n"); + } + + #[test] + fn config_defaults_enable_only_npc_dialogue() { + let config = Config::default(); + assert!(config.events.npc_dialogue); + assert!(!config.events.zone_transitions); + } +} diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..bba1b7e --- /dev/null +++ b/src/events.rs @@ -0,0 +1,143 @@ +use chrono::{DateTime, Local, NaiveDateTime}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LogEvent { + NpcDialogue { + at: DateTime, + speaker: String, + text: String, + }, + ZoneTransition { + at: DateTime, + zone: String, + }, +} + +impl LogEvent { + pub fn label(&self) -> &'static str { + match self { + Self::NpcDialogue { .. } => "NPC", + Self::ZoneTransition { .. } => "ZONE", + } + } + + pub fn text(&self) -> &str { + match self { + Self::NpcDialogue { text, .. } => text, + Self::ZoneTransition { zone, .. } => zone, + } + } + + pub fn occurred_at(&self) -> DateTime { + match self { + Self::NpcDialogue { at, .. } | Self::ZoneTransition { at, .. } => *at, + } + } +} + +pub fn parse_line(line: &str) -> Option { + let (timestamp, message) = split_timestamp(line)?; + + if let Some((speaker, text)) = parse_npc_says(message) { + return Some(LogEvent::NpcDialogue { + at: timestamp, + speaker, + text, + }); + } + + message + .strip_prefix("You have entered ") + .and_then(|zone| zone.strip_suffix('.')) + .filter(|zone| !zone.is_empty()) + .map(|zone| LogEvent::ZoneTransition { + at: timestamp, + zone: zone.to_string(), + }) +} + +fn split_timestamp(line: &str) -> Option<(DateTime, &str)> { + let end = line.find("] ")?; + let raw_timestamp = line.strip_prefix('[')?.get(..end - 1)?; + let timestamp = NaiveDateTime::parse_from_str(raw_timestamp, "%a %b %d %H:%M:%S %Y") + .ok()? + .and_local_timezone(Local) + .single()?; + Some((timestamp, &line[end + 2..])) +} + +fn parse_npc_says(message: &str) -> Option<(String, String)> { + let (speaker, quote) = message.split_once(" says, '")?; + let text = quote.strip_suffix('\'')?; + if speaker.is_empty() || text.is_empty() || speaker == "You" { + return None; + } + Some((speaker.to_string(), text.to_string())) +} + +pub fn speaker_key(name: &str) -> String { + let normalized = name + .chars() + .flat_map(char::to_lowercase) + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '_' + } + }) + .collect::(); + let normalized = normalized.trim_matches('_'); + let compact = normalized + .split('_') + .filter(|part| !part.is_empty()) + .collect::>() + .join("_"); + format!("npc:{compact}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_npc_dialogue_with_apostrophe() { + let event = + parse_line("[Fri Jul 17 20:10:29 2026] Turga says, 'I've got a Rusty Long Sword.'") + .unwrap(); + assert_eq!( + event, + LogEvent::NpcDialogue { + at: NaiveDateTime::parse_from_str( + "Fri Jul 17 20:10:29 2026", + "%a %b %d %H:%M:%S %Y" + ) + .unwrap() + .and_local_timezone(Local) + .single() + .unwrap(), + speaker: "Turga".into(), + text: "I've got a Rusty Long Sword.".into(), + } + ); + } + + #[test] + fn ignores_player_dialogue_and_channel_tells() { + assert!(parse_line("[Fri Jul 17 20:10:29 2026] You say, 'Hail'").is_none()); + assert!(parse_line("[Fri Jul 17 20:10:29 2026] Rath tells General:2, 'hello'").is_none()); + } + + #[test] + fn parses_zone_message() { + let event = + parse_line("[Fri Jul 17 20:09:32 2026] You have entered Lake Rathetear.").unwrap(); + assert!(matches!(event, LogEvent::ZoneTransition { zone, .. } if zone == "Lake Rathetear")); + } + + #[test] + fn normalizes_speaker_keys() { + assert_eq!(speaker_key("A lizardman healer"), "npc:a_lizardman_healer"); + assert_eq!(speaker_key("Zok Caropni!"), "npc:zok_caropni"); + } +} diff --git a/src/kokoro.rs b/src/kokoro.rs new file mode 100644 index 0000000..5b8b973 --- /dev/null +++ b/src/kokoro.rs @@ -0,0 +1,176 @@ +use anyhow::{Context, Result}; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +#[derive(Clone)] +pub struct KokoroClient { + client: reqwest::Client, + base_url: Url, +} + +#[derive(Debug, Deserialize)] +pub struct Voice { + pub id: String, + #[allow(dead_code)] + pub name: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum VoiceResponse { + Wrapped { voices: Vec }, + Bare(Vec), +} + +impl VoiceResponse { + fn into_voices(self) -> Vec { + match self { + Self::Wrapped { voices } | Self::Bare(voices) => voices, + } + } +} + +#[derive(Serialize)] +struct SpeechRequest<'a> { + input: &'a str, + voice: &'a str, + model: &'static str, + response_format: &'static str, + stream: bool, +} + +impl KokoroClient { + pub fn new(base_url: &str) -> Result { + let normalized = format!("{}/", base_url.trim_end_matches('/')); + Ok(Self { + client: reqwest::Client::builder() + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(20)) + .build()?, + base_url: Url::parse(&normalized).context("Kokoro endpoint is not a valid URL")?, + }) + } + + fn endpoint(&self, path: &str) -> Result { + self.base_url + .join(path) + .context("could not build Kokoro URL") + } + + pub async fn health(&self) -> Result<()> { + self.client + .get(self.endpoint("health")?) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn voices(&self) -> Result> { + let response = self + .client + .get(self.endpoint("v1/audio/voices")?) + .send() + .await? + .error_for_status()?; + let response: VoiceResponse = response.json().await?; + Ok(response + .into_voices() + .into_iter() + .map(|voice| voice.id) + .collect()) + } + + pub async fn synthesize_wav(&self, text: &str, voice: &str) -> Result> { + let response = self + .client + .post(self.endpoint("v1/audio/speech")?) + .json(&SpeechRequest { + input: text, + voice, + model: "kokoro", + response_format: "wav", + stream: false, + }) + .send() + .await? + .error_for_status()?; + Ok(response.bytes().await?.to_vec()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + }; + + fn serve_once(status: &str, content_type: &str, body: &'static [u8]) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let status = status.to_owned(); + let content_type = content_type.to_owned(); + thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let count = stream.read(&mut request).unwrap(); + assert!(count > 0); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(body).unwrap(); + }); + format!("http://{address}") + } + + #[tokio::test] + async fn reads_voice_catalog_from_mock_server() { + let url = serve_once( + "200 OK", + "application/json", + br#"[{"id":"af_heart","name":"Heart"},{"id":"am_adam","name":"Adam"}]"#, + ); + let voices = KokoroClient::new(&url).unwrap().voices().await.unwrap(); + assert_eq!(voices, vec!["af_heart", "am_adam"]); + } + + #[tokio::test] + async fn reads_wrapped_voice_catalog_from_mock_server() { + let url = serve_once( + "200 OK", + "application/json", + br#"{"voices":[{"id":"af_heart","name":"Heart"}]}"#, + ); + let voices = KokoroClient::new(&url).unwrap().voices().await.unwrap(); + assert_eq!(voices, vec!["af_heart"]); + } + + #[tokio::test] + async fn returns_wav_body_from_mock_server() { + let url = serve_once("200 OK", "audio/wav", b"RIFFmock"); + let wav = KokoroClient::new(&url) + .unwrap() + .synthesize_wav("hello", "af_heart") + .await + .unwrap(); + assert_eq!(wav, b"RIFFmock"); + } + + #[tokio::test] + async fn treats_non_success_as_failure() { + let url = serve_once("503 Service Unavailable", "application/json", b"{}"); + assert!(KokoroClient::new(&url).unwrap().health().await.is_err()); + } + + #[tokio::test] + async fn rejects_malformed_voice_catalog() { + let url = serve_once("200 OK", "application/json", br#"{"voices":"not a list"}"#); + assert!(KokoroClient::new(&url).unwrap().voices().await.is_err()); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..83ee00a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,218 @@ +mod config; +mod events; +mod kokoro; +mod speakers; +mod tui; +mod workers; + +use anyhow::{Context, Result}; +use clap::Parser; +use config::Config; +use crossterm::{ + event::{self, Event}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use events::LogEvent; +use ratatui::{Terminal, backend::CrosstermBackend}; +use std::{ + io::{self, stdout}, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + time::Duration, +}; +use tokio::sync::mpsc as tokio_mpsc; +use tui::{App, UiAction}; +use workers::{ + AudioCommand, SynthesisJob, WorkerEvent, spawn_audio_worker, spawn_kokoro_worker, + spawn_log_tailer, +}; + +#[derive(Debug, Parser)] +#[command(name = "mudmouth", version, about = "Give your EverQuest log a voice.")] +struct Cli { + /// Override the EverQuest log location for this run. + #[arg(long)] + log: Option, + + /// Override the Kokoro-FastAPI endpoint for this run. + #[arg(long)] + kokoro_url: Option, +} + +struct RuntimeChannels { + worker_receiver: mpsc::Receiver, + worker_sender: mpsc::Sender, + synthesis_sender: tokio_mpsc::Sender, + worker_control: tokio_mpsc::Sender, + audio_control: mpsc::Sender, + tailer_generation: Arc, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + let mut config = Config::load()?; + if let Some(path) = cli.log { + config.log_path = Some(path); + } + if let Some(url) = cli.kokoro_url { + config.kokoro_url = url; + } + + let speakers = speakers::SpeakerStore::load()?; + let (worker_sender, worker_receiver) = mpsc::channel::(); + let audio_sender = spawn_audio_worker(config.volume, worker_sender.clone()); + let (synthesis_sender, synthesis_receiver) = tokio_mpsc::channel(32); + let worker_control = spawn_kokoro_worker( + config.clone(), + synthesis_receiver, + audio_sender.clone(), + worker_sender.clone(), + ); + + let tailer_generation = Arc::new(AtomicUsize::new(0)); + if let Some(path) = config.log_path.clone() { + spawn_log_tailer(path, worker_sender.clone(), tailer_generation.clone(), 0); + } + + let mut app = App::new(config, speakers); + if app.config.log_path.is_none() { + app.activity + .push_front("First run: press [s] and configure your EverQuest log path.".into()); + } + let channels = RuntimeChannels { + worker_receiver, + worker_sender, + synthesis_sender, + worker_control, + audio_control: audio_sender, + tailer_generation, + }; + run_terminal(&mut app, channels).await +} + +async fn run_terminal(app: &mut App, channels: RuntimeChannels) -> Result<()> { + let mut terminal = setup_terminal()?; + let result = run_loop(app, &mut terminal, channels).await; + restore_terminal(&mut terminal)?; + result +} + +async fn run_loop( + app: &mut App, + terminal: &mut Terminal>, + channels: RuntimeChannels, +) -> Result<()> { + loop { + while let Ok(event) = channels.worker_receiver.try_recv() { + process_worker_event(app, event, &channels.synthesis_sender)?; + } + + terminal.draw(|frame| tui::draw(frame, app))?; + if event::poll(Duration::from_millis(80))? + && let Event::Key(key) = event::read()? + && let Some(action) = app.handle_key(key) + { + match action { + UiAction::Quit => return Ok(()), + UiAction::ConfigChanged => { + app.config.save()?; + app.speakers.save()?; + let _ = channels.worker_control.try_send(app.config.clone()); + let _ = channels + .audio_control + .send(AudioCommand::SetVolume(app.config.volume)); + let generation = channels.tailer_generation.fetch_add(1, Ordering::Relaxed) + 1; + if let Some(path) = app.config.log_path.clone() { + spawn_log_tailer( + path, + channels.worker_sender.clone(), + channels.tailer_generation.clone(), + generation, + ); + } + app.activity.push_front("settings saved".into()); + } + } + } + } +} + +fn process_worker_event( + app: &mut App, + event: WorkerEvent, + synthesis_sender: &tokio_mpsc::Sender, +) -> Result<()> { + match &event { + WorkerEvent::Log(LogEvent::ZoneTransition { zone, .. }) => { + app.current_zone = Some(zone.clone()); + if app.config.events.zone_transitions { + enqueue_random_event(app, synthesis_sender, format!("You have entered {zone}."))?; + } + } + WorkerEvent::Log(LogEvent::NpcDialogue { speaker, text, .. }) + if app.config.events.npc_dialogue => + { + if app.voices.is_empty() { + app.activity + .push_front("NPC line seen, waiting for Kokoro voice catalog…".into()); + } else { + let key = events::speaker_key(speaker); + if let Some(voice) = app.speakers.resolve_voice( + key, + speaker.clone(), + &app.voices, + app.current_zone.clone(), + ) { + app.speakers.save()?; + if synthesis_sender + .try_send(SynthesisJob { + text: text.clone(), + voice, + }) + .is_err() + { + app.activity + .push_front("speech queue is busy; line dropped".into()); + } + } + } + } + _ => {} + } + app.receive(event); + Ok(()) +} + +fn enqueue_random_event( + app: &mut App, + synthesis_sender: &tokio_mpsc::Sender, + text: String, +) -> Result<()> { + use rand::prelude::IndexedRandom; + if let Some(voice) = app.voices.choose(&mut rand::rng()).cloned() { + synthesis_sender + .try_send(SynthesisJob { text, voice }) + .context("speech queue is busy")?; + } + Ok(()) +} + +fn setup_terminal() -> Result>> { + enable_raw_mode()?; + let mut output = stdout(); + execute!(output, EnterAlternateScreen)?; + Terminal::new(CrosstermBackend::new(output)).context("could not start terminal UI") +} + +fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} diff --git a/src/speakers.rs b/src/speakers.rs new file mode 100644 index 0000000..ca22e7d --- /dev/null +++ b/src/speakers.rs @@ -0,0 +1,141 @@ +use crate::config::{Config, atomic_write}; +use anyhow::{Context, Result}; +use chrono::{DateTime, Local}; +use rand::prelude::IndexedRandom; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, fs, path::PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum VoiceMode { + PersistentRandom { voice: String }, + Pinned { voice: String }, + RandomEachUtterance, +} + +impl VoiceMode { + pub fn display(&self) -> String { + match self { + Self::PersistentRandom { voice } => format!("random → {voice}"), + Self::Pinned { voice } => format!("pinned → {voice}"), + Self::RandomEachUtterance => "random every line".into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Speaker { + pub name: String, + pub mode: VoiceMode, + pub last_seen: DateTime, + pub last_zone: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct SpeakerStore { + speakers: BTreeMap, +} + +impl SpeakerStore { + fn path() -> Result { + Ok(Config::app_dir()?.join("speakers.json")) + } + + pub fn load() -> Result { + let path = Self::path()?; + if !path.exists() { + return Ok(Self::default()); + } + let contents = fs::read_to_string(&path) + .with_context(|| format!("could not read {}", path.display()))?; + serde_json::from_str(&contents).context("could not parse speaker database") + } + + pub fn save(&self) -> Result<()> { + atomic_write(&Self::path()?, serde_json::to_vec_pretty(self)?.as_slice()) + } + + pub fn resolve_voice( + &mut self, + key: String, + name: String, + voices: &[String], + zone: Option, + ) -> Option { + let now = Local::now(); + let speaker = self.speakers.entry(key).or_insert_with(|| { + let voice = choose_voice(voices).unwrap_or_else(|| "af_heart".into()); + Speaker { + name, + mode: VoiceMode::PersistentRandom { voice }, + last_seen: now, + last_zone: zone.clone(), + } + }); + + speaker.last_seen = now; + speaker.last_zone = zone; + match &speaker.mode { + VoiceMode::PersistentRandom { voice } | VoiceMode::Pinned { voice } => { + Some(voice.clone()) + } + VoiceMode::RandomEachUtterance => choose_voice(voices), + } + } + + pub fn entries(&self) -> impl Iterator { + self.speakers.iter() + } + + pub fn get(&self, key: &str) -> Option<&Speaker> { + self.speakers.get(key) + } + + pub fn pin(&mut self, key: &str, voice: String) { + if let Some(speaker) = self.speakers.get_mut(key) { + speaker.mode = VoiceMode::Pinned { voice }; + } + } + + pub fn set_random_each_utterance(&mut self, key: &str) { + if let Some(speaker) = self.speakers.get_mut(key) { + speaker.mode = VoiceMode::RandomEachUtterance; + } + } + + pub fn reset_persistent_random(&mut self, key: &str, voices: &[String]) { + if let (Some(speaker), Some(voice)) = (self.speakers.get_mut(key), choose_voice(voices)) { + speaker.mode = VoiceMode::PersistentRandom { voice }; + } + } +} + +fn choose_voice(voices: &[String]) -> Option { + voices.choose(&mut rand::rng()).cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn random_assignment_is_persisted() { + let mut store = SpeakerStore::default(); + let voices = vec!["af_heart".into(), "am_adam".into()]; + let first = store.resolve_voice("npc:turga".into(), "Turga".into(), &voices, None); + let next = store.resolve_voice("npc:turga".into(), "Turga".into(), &voices, None); + assert_eq!(first, next); + } + + #[test] + fn random_each_utterance_remains_configurable() { + let mut store = SpeakerStore::default(); + let voices = vec!["af_heart".into()]; + store.resolve_voice("npc:turga".into(), "Turga".into(), &voices, None); + store.set_random_each_utterance("npc:turga"); + assert_eq!( + store.get("npc:turga").unwrap().mode, + VoiceMode::RandomEachUtterance + ); + } +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..a9d2019 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,552 @@ +use crate::{ + config::Config, + speakers::SpeakerStore, + workers::{KokoroStatus, WorkerEvent}, +}; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, +}; +use std::collections::VecDeque; + +const LOGO: &str = r#" +███╗ ███╗██╗ ██╗██████╗ ███╗ ███╗ ██████╗ ██╗ ██╗████████╗██╗ ██╗ +████╗ ████║██║ ██║██╔══██╗████╗ ████║██╔═══██╗██║ ██║╚══██╔══╝██║ ██║ +██╔████╔██║██║ ██║██║ ██║██╔████╔██║██║ ██║██║ ██║ ██║ ███████║ +██║╚██╔╝██║██║ ██║██║ ██║██║╚██╔╝██║██║ ██║██║ ██║ ██║ ██╔══██║ +██║ ╚═╝ ██║╚██████╔╝██████╔╝██║ ╚═╝ ██║╚██████╔╝╚██████╔╝ ██║ ██║ ██║ +╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ +"#; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Screen { + Dashboard, + Settings, + Speakers, + Help, +} + +#[derive(Debug)] +pub enum UiAction { + Quit, + ConfigChanged, +} + +pub struct App { + pub config: Config, + pub speakers: SpeakerStore, + pub screen: Screen, + pub kokoro_status: KokoroStatus, + pub voices: Vec, + pub current_zone: Option, + pub queue_depth: usize, + pub activity: VecDeque, + pub selected_speaker: usize, + pub selected_voice: usize, + pub settings_cursor: usize, + pub speaker_filter: String, + pub splash_until: std::time::Instant, + pub input: Option, +} + +#[derive(Debug)] +pub enum Input { + Endpoint(String), + LogPath(String), + SpeakerSearch(String), +} + +impl App { + pub fn new(config: Config, speakers: SpeakerStore) -> Self { + Self { + config, + speakers, + screen: Screen::Dashboard, + kokoro_status: KokoroStatus::Checking, + voices: Vec::new(), + current_zone: None, + queue_depth: 0, + activity: VecDeque::from(["The tavern is quiet. Waiting for log lines…".into()]), + selected_speaker: 0, + selected_voice: 0, + settings_cursor: 0, + speaker_filter: String::new(), + splash_until: std::time::Instant::now() + std::time::Duration::from_millis(1700), + input: None, + } + } + + pub fn receive(&mut self, event: WorkerEvent) { + match event { + WorkerEvent::KokoroStatus(status) => self.kokoro_status = status, + WorkerEvent::Voices(voices) => { + self.voices = voices; + self.activity + .push_front(format!("{} Kokoro voices loaded", self.voices.len())); + } + WorkerEvent::QueueDepth(depth) => self.queue_depth = depth, + WorkerEvent::Activity(message) => self.activity.push_front(message), + WorkerEvent::Log(event) => self.activity.push_front(format!( + "[{} {}] {}", + event.occurred_at().format("%H:%M:%S"), + event.label(), + event.text() + )), + } + self.activity.truncate(80); + } + + pub fn handle_key(&mut self, key: KeyEvent) -> Option { + if self.input.is_some() { + return self.handle_input(key); + } + match key.code { + KeyCode::Char('q') => Some(UiAction::Quit), + KeyCode::Char('?') => { + self.screen = Screen::Help; + None + } + KeyCode::Esc => { + self.screen = Screen::Dashboard; + None + } + KeyCode::Char('d') => { + self.screen = Screen::Dashboard; + None + } + KeyCode::Char('s') => { + self.screen = Screen::Settings; + None + } + KeyCode::Char('v') => { + self.screen = Screen::Speakers; + None + } + _ => match self.screen { + Screen::Settings => self.handle_settings_key(key), + Screen::Speakers => self.handle_speakers_key(key), + _ => None, + }, + } + } + + fn handle_input(&mut self, key: KeyEvent) -> Option { + let mut input = self.input.take().expect("input state was checked"); + match key.code { + KeyCode::Esc => {} + KeyCode::Enter => { + let mut close = false; + let changed = match &input { + Input::Endpoint(value) => { + self.config.kokoro_url = value.trim().trim_end_matches('/').to_string(); + true + } + Input::LogPath(value) => { + self.config.log_path = (!value.trim().is_empty()) + .then(|| std::path::PathBuf::from(value.trim())); + true + } + Input::SpeakerSearch(value) => { + self.speaker_filter = value.trim().to_lowercase(); + self.selected_speaker = 0; + close = true; + false + } + }; + if changed { + return Some(UiAction::ConfigChanged); + } + if close { + return None; + } + } + KeyCode::Backspace => match &mut input { + Input::Endpoint(value) | Input::LogPath(value) | Input::SpeakerSearch(value) => { + value.pop(); + } + }, + KeyCode::Char(character) => match &mut input { + Input::Endpoint(value) | Input::LogPath(value) | Input::SpeakerSearch(value) => { + value.push(character); + } + }, + _ => {} + } + self.input = Some(input); + None + } + + fn handle_settings_key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.settings_cursor = self.settings_cursor.saturating_sub(1) + } + KeyCode::Down | KeyCode::Char('j') => { + self.settings_cursor = (self.settings_cursor + 1).min(4) + } + KeyCode::Enter | KeyCode::Char(' ') => match self.settings_cursor { + 0 => self.input = Some(Input::Endpoint(self.config.kokoro_url.clone())), + 1 => { + self.input = Some(Input::LogPath( + self.config + .log_path + .as_ref() + .map_or_else(String::new, |path| path.display().to_string()), + )) + } + 2 => { + self.config.events.npc_dialogue = !self.config.events.npc_dialogue; + return Some(UiAction::ConfigChanged); + } + 3 => { + self.config.events.zone_transitions = !self.config.events.zone_transitions; + return Some(UiAction::ConfigChanged); + } + 4 => { + self.config.volume = if self.config.volume >= 1.0 { + 0.2 + } else { + (self.config.volume + 0.2).min(1.0) + }; + return Some(UiAction::ConfigChanged); + } + _ => {} + }, + _ => {} + } + None + } + + fn handle_speakers_key(&mut self, key: KeyEvent) -> Option { + let keys = self.speaker_keys(); + match key.code { + KeyCode::Char('f') => { + self.input = Some(Input::SpeakerSearch(self.speaker_filter.clone())); + } + KeyCode::Up | KeyCode::Char('k') => { + self.selected_speaker = self.selected_speaker.saturating_sub(1) + } + KeyCode::Down | KeyCode::Char('j') => { + self.selected_speaker = + (self.selected_speaker + 1).min(keys.len().saturating_sub(1)) + } + KeyCode::Left | KeyCode::Char('h') => { + self.selected_voice = self.selected_voice.saturating_sub(1) + } + KeyCode::Right | KeyCode::Char('l') => { + self.selected_voice = + (self.selected_voice + 1).min(self.voices.len().saturating_sub(1)) + } + KeyCode::Char('p') | KeyCode::Enter => { + if let (Some(key), Some(voice)) = ( + keys.get(self.selected_speaker), + self.voices.get(self.selected_voice), + ) { + self.speakers.pin(key, voice.clone()); + return Some(UiAction::ConfigChanged); + } + } + KeyCode::Char('r') => { + if let Some(key) = keys.get(self.selected_speaker) { + self.speakers.set_random_each_utterance(key); + return Some(UiAction::ConfigChanged); + } + } + KeyCode::Char('x') => { + if let Some(key) = keys.get(self.selected_speaker) { + self.speakers.reset_persistent_random(key, &self.voices); + return Some(UiAction::ConfigChanged); + } + } + _ => {} + } + None + } + + pub fn speaker_keys(&self) -> Vec { + self.speakers + .entries() + .filter(|(_, speaker)| { + self.speaker_filter.is_empty() + || speaker.name.to_lowercase().contains(&self.speaker_filter) + }) + .map(|(key, _)| key.clone()) + .collect() + } +} + +pub fn draw(frame: &mut Frame, app: &App) { + if std::time::Instant::now() < app.splash_until { + draw_splash(frame); + return; + } + let area = frame.area(); + let sections = Layout::vertical([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(2), + ]) + .split(area); + draw_header(frame, app, sections[0]); + match app.screen { + Screen::Dashboard => draw_dashboard(frame, app, sections[1]), + Screen::Settings => draw_settings(frame, app, sections[1]), + Screen::Speakers => draw_speakers(frame, app, sections[1]), + Screen::Help => draw_help(frame, sections[1]), + } + frame.render_widget( + Paragraph::new(" [d] dashboard [s] settings [v] voices [?] help [q] quit ") + .style(Style::default().fg(Color::DarkGray)), + sections[2], + ); + if let Some(input) = &app.input { + draw_input(frame, input); + } +} + +fn draw_splash(frame: &mut Frame) { + let layout = Layout::vertical([ + Constraint::Percentage(25), + Constraint::Length(7), + Constraint::Length(2), + Constraint::Percentage(25), + ]) + .split(frame.area()); + frame.render_widget( + Paragraph::new(LOGO) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::LightMagenta)), + layout[1], + ); + frame.render_widget( + Paragraph::new("THE LOG AWAKENS • THE DUNGEON SPEAKS") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Yellow)), + layout[2], + ); +} + +fn draw_header(frame: &mut Frame, app: &App, area: Rect) { + let status = match app.kokoro_status { + KokoroStatus::Checking => "◌ KOKORO: CHECKING", + KokoroStatus::Connected => "● KOKORO: CONNECTED", + KokoroStatus::Unreachable => "× KOKORO: UNREACHABLE", + }; + let color = match app.kokoro_status { + KokoroStatus::Checking => Color::Yellow, + KokoroStatus::Connected => Color::Green, + KokoroStatus::Unreachable => Color::Red, + }; + let title = Line::from(vec![ + Span::styled( + " MUDMOUTH ", + Style::default().fg(Color::LightMagenta).bold(), + ), + Span::raw(" // "), + Span::styled(status, Style::default().fg(color).bold()), + Span::raw(format!(" Queue: {}", app.queue_depth)), + ]); + frame.render_widget( + Paragraph::new(title).block(Block::default().borders(Borders::ALL)), + area, + ); +} + +fn draw_dashboard(frame: &mut Frame, app: &App, area: Rect) { + let columns = + Layout::horizontal([Constraint::Percentage(68), Constraint::Percentage(32)]).split(area); + let items = app + .activity + .iter() + .map(|line| ListItem::new(line.as_str())) + .collect::>(); + frame.render_widget( + List::new(items) + .block( + Block::default() + .title(" LIVE TAVERN SCROLL ") + .borders(Borders::ALL), + ) + .style(Style::default().fg(Color::Gray)), + columns[0], + ); + let log = app.config.log_path.as_ref().map_or_else( + || "not configured".into(), + |path| path.display().to_string(), + ); + let details = format!( + "LOG\n{log}\n\nZONE\n{}\n\nVOICE CATALOG\n{} voices\n\nDEFAULTS\nNPC dialogue: {}\nZone transitions: {}", + app.current_zone.as_deref().unwrap_or("unknown"), + app.voices.len(), + on_off(app.config.events.npc_dialogue), + on_off(app.config.events.zone_transitions) + ); + frame.render_widget( + Paragraph::new(details) + .block( + Block::default() + .title(" WORLD STATE ") + .borders(Borders::ALL), + ) + .wrap(Wrap { trim: true }), + columns[1], + ); +} + +fn draw_settings(frame: &mut Frame, app: &App, area: Rect) { + let fields = [ + format!("Kokoro endpoint: {}", app.config.kokoro_url), + format!( + "EverQuest log: {}", + app.config.log_path.as_ref().map_or_else( + || "not configured".into(), + |path| path.display().to_string() + ) + ), + format!( + "Speak NPC dialogue: {}", + on_off(app.config.events.npc_dialogue) + ), + format!( + "Speak zone transitions: {}", + on_off(app.config.events.zone_transitions) + ), + format!("Playback volume: {:.0}%", app.config.volume * 100.0), + ]; + let items = fields + .iter() + .enumerate() + .map(|(index, field)| { + ListItem::new(field.clone()).style(if index == app.settings_cursor { + Style::default().fg(Color::Yellow).bold() + } else { + Style::default() + }) + }) + .collect::>(); + frame.render_widget( + List::new(items).block( + Block::default() + .title(" SETTINGS — ↑↓ select, Enter edit/toggle ") + .borders(Borders::ALL), + ), + area, + ); +} + +fn draw_speakers(frame: &mut Frame, app: &App, area: Rect) { + let columns = + Layout::horizontal([Constraint::Percentage(52), Constraint::Percentage(48)]).split(area); + let keys = app.speaker_keys(); + let speakers = keys + .iter() + .enumerate() + .filter_map(|(index, key)| { + app.speakers.get(key).map(|speaker| { + ListItem::new(format!("{} {}", speaker.name, speaker.mode.display())).style( + if index == app.selected_speaker { + Style::default().fg(Color::Yellow).bold() + } else { + Style::default() + }, + ) + }) + }) + .collect::>(); + frame.render_widget( + List::new(speakers).block( + Block::default() + .title(format!( + " NPCS — ↑↓ choose; [f] filter{} ", + if app.speaker_filter.is_empty() { + String::new() + } else { + format!(": {}", app.speaker_filter) + } + )) + .borders(Borders::ALL), + ), + columns[0], + ); + let voices = app + .voices + .iter() + .enumerate() + .map(|(index, voice)| { + ListItem::new(voice.as_str()).style(if index == app.selected_voice { + Style::default().fg(Color::LightCyan).bold() + } else { + Style::default() + }) + }) + .collect::>(); + frame.render_widget( + List::new(voices).block( + Block::default() + .title(" VOICES — ←→ choose; [p] pin [r] random/line [x] reset ") + .borders(Borders::ALL), + ), + columns[1], + ); +} + +fn draw_help(frame: &mut Frame, area: Rect) { + frame.render_widget( + Paragraph::new( + "Mudmouth gives EverQuest’s NPCs a voice.\n\n\ + [d] dashboard [s] settings [v] NPC voice manager\n\ + [f] filter NPCs in the voice manager\n\ + [p] pin the selected NPC to the selected Kokoro voice\n\ + [r] change selected NPC to random every utterance\n\ + [x] assign a fresh persistent random voice\n\ + [q] quit\n\n\ + NPC dialogue is on by default. Zone announcements are off by default.\n\ + The KOKORO status line tells you whether voice generation is reachable.", + ) + .block( + Block::default() + .title(" GRIMOIRE / HELP ") + .borders(Borders::ALL), + ) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn draw_input(frame: &mut Frame, input: &Input) { + let area = centered_rect(80, 20, frame.area()); + let (label, value) = match input { + Input::Endpoint(value) => ("Kokoro endpoint", value), + Input::LogPath(value) => ("EverQuest log path", value), + Input::SpeakerSearch(value) => ("Search NPC", value), + }; + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new(format!("{value}_")).block( + Block::default() + .title(format!(" {label} — Enter save, Esc cancel ")) + .borders(Borders::ALL), + ), + area, + ); +} + +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let vertical = Layout::vertical([ + Constraint::Percentage((100 - height) / 2), + Constraint::Percentage(height), + Constraint::Percentage((100 - height) / 2), + ]) + .split(area); + Layout::horizontal([ + Constraint::Percentage((100 - width) / 2), + Constraint::Percentage(width), + Constraint::Percentage((100 - width) / 2), + ]) + .split(vertical[1])[1] +} + +fn on_off(value: bool) -> &'static str { + if value { "ON" } else { "OFF" } +} diff --git a/src/workers.rs b/src/workers.rs new file mode 100644 index 0000000..70190c4 --- /dev/null +++ b/src/workers.rs @@ -0,0 +1,259 @@ +use crate::{ + config::Config, + events::{LogEvent, parse_line}, + kokoro::KokoroClient, +}; +use anyhow::Result; +use rodio::{Decoder, OutputStreamBuilder, Sink}; +use std::{ + collections::{HashMap, VecDeque}, + fs::File, + io::{BufRead, BufReader, Cursor, Seek, SeekFrom}, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc::{self, Receiver, Sender}, + }, + thread, + time::{Duration, Instant}, +}; +use tokio::sync::mpsc as tokio_mpsc; + +#[derive(Debug, Clone)] +pub enum WorkerEvent { + Log(LogEvent), + Activity(String), + KokoroStatus(KokoroStatus), + Voices(Vec), + QueueDepth(usize), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KokoroStatus { + Checking, + Connected, + Unreachable, +} + +#[derive(Debug)] +pub struct SynthesisJob { + pub text: String, + pub voice: String, +} + +#[derive(Debug)] +pub enum AudioCommand { + Play(Vec), + SetVolume(f32), +} + +pub fn spawn_log_tailer( + path: PathBuf, + sender: Sender, + generation: Arc, + expected_generation: usize, +) { + thread::spawn(move || { + let mut position = None; + while generation.load(Ordering::Relaxed) == expected_generation { + match read_new_lines(&path, &mut position) { + Ok(lines) => { + for line in lines { + if let Some(event) = parse_line(&line) { + let _ = sender.send(WorkerEvent::Log(event)); + } + } + } + Err(error) => { + let _ = sender.send(WorkerEvent::Activity(format!("log watch: {error}"))); + } + } + thread::sleep(Duration::from_millis(350)); + } + }); +} + +fn read_new_lines(path: &Path, position: &mut Option) -> Result> { + let mut file = File::open(path)?; + let length = file.metadata()?.len(); + let offset = position.unwrap_or(length); + let offset = if length < offset { 0 } else { offset }; + file.seek(SeekFrom::Start(offset))?; + let reader = BufReader::new(&file); + let lines = reader.lines().collect::>>()?; + *position = Some(length); + Ok(lines) +} + +pub fn spawn_audio_worker(volume: f32, event_sender: Sender) -> Sender { + let (sender, receiver): (Sender, Receiver) = mpsc::channel(); + thread::spawn(move || { + let stream = match OutputStreamBuilder::open_default_stream() { + Ok(stream) => stream, + Err(error) => { + let _ = event_sender.send(WorkerEvent::Activity(format!( + "audio output unavailable: {error}" + ))); + return; + } + }; + let mut volume = volume; + while let Ok(command) = receiver.recv() { + let wav = match command { + AudioCommand::Play(wav) => wav, + AudioCommand::SetVolume(updated_volume) => { + volume = updated_volume; + continue; + } + }; + let _ = event_sender.send(WorkerEvent::QueueDepth(1)); + match Decoder::try_from(Cursor::new(wav)) { + Ok(source) => { + let sink = Sink::connect_new(stream.mixer()); + sink.set_volume(volume); + sink.append(source); + sink.sleep_until_end(); + } + Err(error) => { + let _ = event_sender.send(WorkerEvent::Activity(format!( + "audio decode failed: {error}" + ))); + } + } + let _ = event_sender.send(WorkerEvent::QueueDepth(0)); + } + }); + sender +} + +pub fn spawn_kokoro_worker( + config: Config, + mut jobs: tokio_mpsc::Receiver, + audio_sender: Sender, + event_sender: Sender, +) -> tokio_mpsc::Sender { + let (control_sender, mut control_receiver) = tokio_mpsc::channel::(4); + tokio::spawn(async move { + let mut config = config; + let mut client = match KokoroClient::new(&config.kokoro_url) { + Ok(client) => client, + Err(error) => { + let _ = event_sender.send(WorkerEvent::Activity(format!("Kokoro config: {error}"))); + return; + } + }; + let mut voices = Vec::new(); + let mut last_health_check = Instant::now() - Duration::from_secs(30); + let mut recent = HashMap::::new(); + let mut queued = VecDeque::new(); + + loop { + while let Ok(updated_config) = control_receiver.try_recv() { + match KokoroClient::new(&updated_config.kokoro_url) { + Ok(updated_client) => { + config = updated_config; + client = updated_client; + voices.clear(); + last_health_check = Instant::now() - Duration::from_secs(30); + let _ = event_sender.send(WorkerEvent::Activity( + "Kokoro settings updated; validating endpoint…".into(), + )); + } + Err(error) => { + let _ = event_sender.send(WorkerEvent::Activity(format!( + "Kokoro endpoint rejected: {error}" + ))); + } + } + } + + if last_health_check.elapsed() >= Duration::from_secs(5) { + last_health_check = Instant::now(); + let _ = event_sender.send(WorkerEvent::KokoroStatus(KokoroStatus::Checking)); + match client.health().await { + Ok(()) => match client.voices().await { + Ok(found) => { + if found != voices { + voices = found.clone(); + let _ = event_sender.send(WorkerEvent::Voices(found)); + } + let _ = event_sender + .send(WorkerEvent::KokoroStatus(KokoroStatus::Connected)); + } + Err(error) => { + let _ = event_sender + .send(WorkerEvent::Activity(format!("voice list failed: {error}"))); + let _ = event_sender + .send(WorkerEvent::KokoroStatus(KokoroStatus::Unreachable)); + } + }, + Err(_) => { + let _ = + event_sender.send(WorkerEvent::KokoroStatus(KokoroStatus::Unreachable)); + } + } + } + + while let Ok(job) = jobs.try_recv() { + let key = format!("{}:{}", job.voice, job.text); + let is_duplicate = recent.get(&key).is_some_and(|last| { + last.elapsed() < Duration::from_secs(config.dedup_window_seconds) + }); + if is_duplicate { + let _ = + event_sender.send(WorkerEvent::Activity("duplicate line skipped".into())); + } else if queued.len() >= config.max_queue_length { + let _ = event_sender.send(WorkerEvent::Activity( + "speech queue full; line dropped".into(), + )); + } else { + recent.insert(key, Instant::now()); + queued.push_back(job); + } + } + + if let Some(job) = queued.pop_front() { + let _ = event_sender.send(WorkerEvent::QueueDepth(queued.len() + 1)); + match client.synthesize_wav(&job.text, &job.voice).await { + Ok(wav) => { + if audio_sender.send(AudioCommand::Play(wav)).is_err() { + let _ = event_sender.send(WorkerEvent::Activity( + "audio worker stopped; unable to play speech".into(), + )); + } + } + Err(error) => { + let _ = event_sender.send(WorkerEvent::Activity(format!( + "Kokoro speech failed: {error}" + ))); + } + } + let _ = event_sender.send(WorkerEvent::QueueDepth(queued.len())); + } + + tokio::time::sleep(Duration::from_millis(100)).await; + } + }); + control_sender +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn tailer_starts_at_end_and_recovers_after_truncation() { + let directory = tempdir().unwrap(); + let path = directory.path().join("eqlog.txt"); + fs::write(&path, "old\n").unwrap(); + let mut position = None; + assert!(read_new_lines(&path, &mut position).unwrap().is_empty()); + fs::write(&path, "old\nnew\n").unwrap(); + assert_eq!(read_new_lines(&path, &mut position).unwrap(), vec!["new"]); + fs::write(&path, "fresh\n").unwrap(); + assert_eq!(read_new_lines(&path, &mut position).unwrap(), vec!["fresh"]); + } +}