Merge pull request #34 from cjpais/audio-backend-refactor
Refactor the Audio Backend
This commit is contained in:
commit
57f07c180c
34 changed files with 2399 additions and 647 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -23,4 +23,5 @@ dist-ssr
|
|||
*.sln
|
||||
*.sw?
|
||||
|
||||
src-tauri/resources/models
|
||||
/target/
|
||||
recording_*
|
||||
|
|
|
|||
335
src-tauri/Cargo.lock
generated
335
src-tauri/Cargo.lock
generated
|
|
@ -392,7 +392,7 @@ dependencies = [
|
|||
"bitflags 2.8.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.12.1",
|
||||
"itertools",
|
||||
"lazy_static",
|
||||
"lazycell",
|
||||
"log",
|
||||
|
|
@ -406,24 +406,6 @@ dependencies = [
|
|||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f"
|
||||
dependencies = [
|
||||
"bitflags 2.8.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.13.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash 1.1.0",
|
||||
"shlex",
|
||||
"syn 2.0.96",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
|
|
@ -612,8 +594,6 @@ version = "1.2.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4730490333d58093109dc02c23174c3f4d490998c3fed3cc8e82d57afedb9cf"
|
||||
dependencies = [
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
|
|
@ -655,9 +635,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
|
|
@ -689,12 +669,6 @@ dependencies = [
|
|||
"libloading 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "claxon"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688"
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.0"
|
||||
|
|
@ -886,41 +860,38 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.11.3"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
|
||||
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-sys"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ce857aa0b77d77287acc1ac3e37a05a8c95a2af3647d23b15f263bdaeb7562b"
|
||||
dependencies = [
|
||||
"bindgen 0.70.1",
|
||||
"libc",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||
checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"jni",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"ndk 0.8.0",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
|
|
@ -1435,6 +1406,12 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extended"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||
|
||||
[[package]]
|
||||
name = "eyre"
|
||||
version = "0.6.12"
|
||||
|
|
@ -2033,8 +2010,10 @@ dependencies = [
|
|||
"enigo",
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"hound",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ort-sys",
|
||||
"rdev",
|
||||
"reqwest 0.11.27",
|
||||
"rodio",
|
||||
|
|
@ -2576,15 +2555,6 @@ dependencies = [
|
|||
"either",
|
||||
]
|
||||
|
||||
[[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.14"
|
||||
|
|
@ -2636,15 +2606,6 @@ version = "0.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jpeg-decoder"
|
||||
version = "0.3.1"
|
||||
|
|
@ -2718,17 +2679,6 @@ version = "1.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
||||
|
||||
[[package]]
|
||||
name = "lewton"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"ogg",
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libappindicator"
|
||||
version = "0.9.0"
|
||||
|
|
@ -3036,20 +2986,6 @@ dependencies = [
|
|||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.8.0",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"ndk-sys 0.5.0+25.2.9519653",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
|
|
@ -3059,7 +2995,7 @@ dependencies = [
|
|||
"bitflags 2.8.0",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"ndk-sys",
|
||||
"num_enum",
|
||||
"raw-window-handle",
|
||||
"thiserror 1.0.69",
|
||||
|
|
@ -3071,15 +3007,6 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.5.0+25.2.9519653"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||
dependencies = [
|
||||
"jni-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.6.0+11769913"
|
||||
|
|
@ -3133,6 +3060,16 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
|
|
@ -3168,6 +3105,17 @@ 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"
|
||||
|
|
@ -3268,6 +3216,21 @@ dependencies = [
|
|||
"objc2-quartz-core 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-audio-toolbox"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cbe18d879e20a4aea544f8befe38bcf52255eb63d3f23eca2842f3319e4c07"
|
||||
dependencies = [
|
||||
"bitflags 2.8.0",
|
||||
"libc",
|
||||
"objc2 0.6.1",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.3.1"
|
||||
|
|
@ -3279,6 +3242,28 @@ dependencies = [
|
|||
"objc2-foundation 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"objc2 0.6.1",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio-types"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f1cc99bb07ad2ddb6527ddf83db6a15271bb036b3eb94b801cd44fdc666ee1"
|
||||
dependencies = [
|
||||
"bitflags 2.8.0",
|
||||
"objc2 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.2.2"
|
||||
|
|
@ -3482,38 +3467,6 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
|
||||
dependencies = [
|
||||
"jni",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"oboe-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe-sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ogg"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.20.2"
|
||||
|
|
@ -4401,21 +4354,19 @@ checksum = "3df6368f71f205ff9c33c076d170dd56ebf68e8161c733c0caa07a7a5509ed53"
|
|||
[[package]]
|
||||
name = "rodio"
|
||||
version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1"
|
||||
source = "git+https://github.com/cjpais/rodio.git#fed30292db417cb95305c118c0e1d804fb74cbff"
|
||||
dependencies = [
|
||||
"claxon",
|
||||
"cpal",
|
||||
"hound",
|
||||
"lewton",
|
||||
"dasp_sample",
|
||||
"num-rational",
|
||||
"symphonia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rubato"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdd96992d7e24b3d7f35fdfe02af037a356ac90d41b466945cf3333525a86eea"
|
||||
checksum = "5258099699851cfd0082aeb645feb9c084d9a5e1f1b8d5372086b989fc5e56a1"
|
||||
dependencies = [
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
|
|
@ -5041,9 +4992,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9"
|
||||
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.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72e34f34298a7308d4397a6c7fbf5b84c5d491231ce3dd379707ba673ab3bd97"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5058,6 +5028,38 @@ dependencies = [
|
|||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-aac"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdbf25b545ad0d3ee3e891ea643ad115aff4ca92f6aec472086b957a58522f70"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-pcm"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f395a67057c2ebc5e84d7bb1be71cce1a7ba99f64e0f0f0e303a03f79116f89b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-vorbis"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a98765fb46a0a6732b007f7e2870c2129b6f78d87db7987e6533c8f164a9f30"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-core"
|
||||
version = "0.5.4"
|
||||
|
|
@ -5071,6 +5073,43 @@ dependencies = [
|
|||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-isomp4"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abfdf178d697e50ce1e5d9b982ba1b94c47218e03ec35022d9f0e071a16dc844"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-ogg"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ada3505789516bcf00fc1157c67729eded428b455c27ca370e41f4d785bfa931"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-riff"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f7be232f962f937f4b7115cbe62c330929345434c834359425e043bfd15f50"
|
||||
dependencies = [
|
||||
"extended",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-metadata"
|
||||
version = "0.5.4"
|
||||
|
|
@ -5083,6 +5122,16 @@ dependencies = [
|
|||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-utils-xiph"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "484472580fa49991afda5f6550ece662237b00c6f562c7d9638d1b086ed010fe"
|
||||
dependencies = [
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
|
|
@ -5194,9 +5243,9 @@ dependencies = [
|
|||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk 0.9.0",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"ndk-sys",
|
||||
"objc2 0.6.1",
|
||||
"objc2-app-kit 0.3.1",
|
||||
"objc2-foundation 0.3.1",
|
||||
|
|
@ -6459,7 +6508,7 @@ version = "0.11.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76bab42b2c319e3a1e0280137c59368072348d3277873c7588b6466a127dca58"
|
||||
dependencies = [
|
||||
"bindgen 0.69.5",
|
||||
"bindgen",
|
||||
"cfg-if",
|
||||
"cmake",
|
||||
"fs_extra",
|
||||
|
|
@ -7125,7 +7174,7 @@ dependencies = [
|
|||
"jni",
|
||||
"kuchikiki",
|
||||
"libc",
|
||||
"ndk 0.9.0",
|
||||
"ndk",
|
||||
"objc2 0.6.1",
|
||||
"objc2-app-kit 0.3.1",
|
||||
"objc2-core-foundation",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ description = "Handy"
|
|||
authors = ["cjpais"]
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
default-run = "handy"
|
||||
|
||||
[profile.dev]
|
||||
incremental = true # Compile your binary in smaller steps.
|
||||
|
|
@ -18,6 +19,10 @@ incremental = true # Compile your binary in smaller steps.
|
|||
name = "handy_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
# [[bin]]
|
||||
# name = "cli"
|
||||
# path = "src/audio_toolkit/bin/cli.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
|
|
@ -30,10 +35,11 @@ serde_json = "1"
|
|||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-macos-permissions = "2.0.4"
|
||||
rdev = { git = "https://github.com/rustdesk-org/rdev" }
|
||||
cpal = "0.15.3"
|
||||
cpal = "0.16.0"
|
||||
whisper-rs = { version = "0.13.2", features = ["whisper-cpp-log"] }
|
||||
anyhow = "1.0.95"
|
||||
rubato = "0.16.1"
|
||||
rubato = "0.16.2"
|
||||
hound = "3.5.1"
|
||||
env_logger = "0.11.6"
|
||||
log = "0.4.25"
|
||||
tokio = "1.43.0"
|
||||
|
|
@ -42,10 +48,13 @@ tauri-plugin-store = "2"
|
|||
tauri-plugin-os = "2"
|
||||
enigo = "0.5.0"
|
||||
tauri-plugin-process = "2"
|
||||
rodio = "0.20.1"
|
||||
rodio = { git = "https://github.com/cjpais/rodio.git" }
|
||||
reqwest = { version = "0.11", features = ["json", "stream"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
[dependencies.ort-sys]
|
||||
version = "=2.0.0-rc.9"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
whisper-rs = { version = "0.13.2", features = ["metal"] }
|
||||
|
||||
|
|
|
|||
BIN
src-tauri/resources/models/silero_vad_v4.onnx
Normal file
BIN
src-tauri/resources/models/silero_vad_v4.onnx
Normal file
Binary file not shown.
52
src-tauri/src/audio_toolkit/audio/device.rs
Normal file
52
src-tauri/src/audio_toolkit/audio/device.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
pub struct CpalDeviceInfo {
|
||||
pub index: String,
|
||||
pub name: String,
|
||||
pub is_default: bool,
|
||||
pub device: cpal::Device,
|
||||
}
|
||||
|
||||
pub fn list_input_devices() -> Result<Vec<CpalDeviceInfo>, Box<dyn std::error::Error>> {
|
||||
let host = cpal::default_host();
|
||||
let default_name = host.default_input_device().and_then(|d| d.name().ok());
|
||||
|
||||
let mut out = Vec::<CpalDeviceInfo>::new();
|
||||
|
||||
for (index, device) in host.input_devices()?.enumerate() {
|
||||
let name = device.name().unwrap_or_else(|_| "Unknown".into());
|
||||
|
||||
let is_default = Some(name.clone()) == default_name;
|
||||
|
||||
out.push(CpalDeviceInfo {
|
||||
index: index.to_string(),
|
||||
name,
|
||||
is_default,
|
||||
device,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn list_output_devices() -> Result<Vec<CpalDeviceInfo>, Box<dyn std::error::Error>> {
|
||||
let host = cpal::default_host();
|
||||
let default_name = host.default_output_device().and_then(|d| d.name().ok());
|
||||
|
||||
let mut out = Vec::<CpalDeviceInfo>::new();
|
||||
|
||||
for (index, device) in host.output_devices()?.enumerate() {
|
||||
let name = device.name().unwrap_or_else(|_| "Unknown".into());
|
||||
|
||||
let is_default = Some(name.clone()) == default_name;
|
||||
|
||||
out.push(CpalDeviceInfo {
|
||||
index: index.to_string(),
|
||||
name,
|
||||
is_default,
|
||||
device,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
8
src-tauri/src/audio_toolkit/audio/mod.rs
Normal file
8
src-tauri/src/audio_toolkit/audio/mod.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Re-export all audio components
|
||||
mod device;
|
||||
mod recorder;
|
||||
mod resampler;
|
||||
|
||||
pub use device::{list_input_devices, list_output_devices, CpalDeviceInfo};
|
||||
pub use recorder::AudioRecorder;
|
||||
pub use resampler::FrameResampler;
|
||||
279
src-tauri/src/audio_toolkit/audio/recorder.rs
Normal file
279
src-tauri/src/audio_toolkit/audio/recorder.rs
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
use std::{
|
||||
io::Error,
|
||||
sync::{mpsc, Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use cpal::{
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
Device, Sample, SizedSample,
|
||||
};
|
||||
|
||||
use crate::audio_toolkit::{
|
||||
audio::FrameResampler,
|
||||
constants,
|
||||
vad::{self, VadFrame},
|
||||
VoiceActivityDetector,
|
||||
};
|
||||
|
||||
enum Cmd {
|
||||
Start,
|
||||
Stop(mpsc::Sender<Vec<f32>>),
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
pub struct AudioRecorder {
|
||||
device: Option<Device>,
|
||||
cmd_tx: Option<mpsc::Sender<Cmd>>,
|
||||
worker_handle: Option<std::thread::JoinHandle<()>>,
|
||||
vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,
|
||||
}
|
||||
|
||||
impl AudioRecorder {
|
||||
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
Ok(AudioRecorder {
|
||||
device: None,
|
||||
cmd_tx: None,
|
||||
worker_handle: None,
|
||||
vad: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_vad(mut self, vad: Box<dyn VoiceActivityDetector>) -> Self {
|
||||
self.vad = Some(Arc::new(Mutex::new(vad)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn open(&mut self, device: Option<Device>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.worker_handle.is_some() {
|
||||
return Ok(()); // already open
|
||||
}
|
||||
|
||||
let (sample_tx, sample_rx) = mpsc::channel::<Vec<f32>>();
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
|
||||
|
||||
let host = cpal::default_host();
|
||||
let device = match device {
|
||||
Some(dev) => dev,
|
||||
None => host
|
||||
.default_input_device()
|
||||
.ok_or_else(|| Error::new(std::io::ErrorKind::NotFound, "No input device found"))?,
|
||||
};
|
||||
|
||||
let thread_device = device.clone();
|
||||
let vad = self.vad.clone();
|
||||
|
||||
let worker = std::thread::spawn(move || {
|
||||
let config = AudioRecorder::get_preferred_config(&thread_device)
|
||||
.expect("failed to fetch preferred config");
|
||||
|
||||
let sample_rate = config.sample_rate().0;
|
||||
let channels = config.channels() as usize;
|
||||
|
||||
println!(
|
||||
"Using device: {:?}\nSample rate: {}\nChannels: {}\nFormat: {:?}",
|
||||
thread_device.name(),
|
||||
sample_rate,
|
||||
channels,
|
||||
config.sample_format()
|
||||
);
|
||||
|
||||
let stream = match config.sample_format() {
|
||||
cpal::SampleFormat::I8 => {
|
||||
AudioRecorder::build_stream::<i8>(&thread_device, &config, sample_tx, channels)
|
||||
.unwrap()
|
||||
}
|
||||
cpal::SampleFormat::I16 => {
|
||||
AudioRecorder::build_stream::<i16>(&thread_device, &config, sample_tx, channels)
|
||||
.unwrap()
|
||||
}
|
||||
cpal::SampleFormat::I32 => {
|
||||
AudioRecorder::build_stream::<i32>(&thread_device, &config, sample_tx, channels)
|
||||
.unwrap()
|
||||
}
|
||||
cpal::SampleFormat::F32 => {
|
||||
AudioRecorder::build_stream::<f32>(&thread_device, &config, sample_tx, channels)
|
||||
.unwrap()
|
||||
}
|
||||
_ => panic!("unsupported sample format"),
|
||||
};
|
||||
|
||||
stream.play().expect("failed to start stream");
|
||||
|
||||
// keep the stream alive while we process samples
|
||||
run_consumer(sample_rate, vad, sample_rx, cmd_rx);
|
||||
// stream is dropped here, after run_consumer returns
|
||||
});
|
||||
|
||||
self.device = Some(device);
|
||||
self.cmd_tx = Some(cmd_tx);
|
||||
self.worker_handle = Some(worker);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn start(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(tx) = &self.cmd_tx {
|
||||
tx.send(Cmd::Start)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
|
||||
let (resp_tx, resp_rx) = mpsc::channel();
|
||||
if let Some(tx) = &self.cmd_tx {
|
||||
tx.send(Cmd::Stop(resp_tx))?;
|
||||
}
|
||||
Ok(resp_rx.recv()?) // wait for the samples
|
||||
}
|
||||
|
||||
pub fn close(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(tx) = self.cmd_tx.take() {
|
||||
let _ = tx.send(Cmd::Shutdown);
|
||||
}
|
||||
if let Some(h) = self.worker_handle.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
self.device = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_stream<T>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::SupportedStreamConfig,
|
||||
sample_tx: mpsc::Sender<Vec<f32>>,
|
||||
channels: usize,
|
||||
) -> Result<cpal::Stream, cpal::BuildStreamError>
|
||||
where
|
||||
T: Sample + SizedSample + Send + 'static,
|
||||
f32: cpal::FromSample<T>,
|
||||
{
|
||||
let mut output_buffer = Vec::new();
|
||||
|
||||
let stream_cb = move |data: &[T], _: &cpal::InputCallbackInfo| {
|
||||
output_buffer.clear();
|
||||
|
||||
if channels == 1 {
|
||||
// Direct conversion without intermediate Vec
|
||||
output_buffer.extend(data.iter().map(|&sample| sample.to_sample::<f32>()));
|
||||
} else {
|
||||
// Convert to mono directly
|
||||
let frame_count = data.len() / channels;
|
||||
output_buffer.reserve(frame_count);
|
||||
|
||||
for frame in data.chunks_exact(channels) {
|
||||
let mono_sample = frame
|
||||
.iter()
|
||||
.map(|&sample| sample.to_sample::<f32>())
|
||||
.sum::<f32>()
|
||||
/ channels as f32;
|
||||
output_buffer.push(mono_sample);
|
||||
}
|
||||
}
|
||||
|
||||
if sample_tx.send(output_buffer.clone()).is_err() {
|
||||
eprintln!("Failed to send samples");
|
||||
}
|
||||
};
|
||||
|
||||
device.build_input_stream(
|
||||
&config.clone().into(),
|
||||
stream_cb,
|
||||
|err| eprintln!("Stream error: {}", err),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn get_preferred_config(
|
||||
device: &cpal::Device,
|
||||
) -> Result<cpal::SupportedStreamConfig, Box<dyn std::error::Error>> {
|
||||
let supported_configs = device.supported_input_configs()?;
|
||||
|
||||
// Try to find a config that supports 16kHz
|
||||
for config_range in supported_configs {
|
||||
if config_range.min_sample_rate().0 <= constants::WHISPER_SAMPLE_RATE
|
||||
&& config_range.max_sample_rate().0 >= constants::WHISPER_SAMPLE_RATE
|
||||
{
|
||||
// Found a config that supports 16kHz, use it
|
||||
return Ok(
|
||||
config_range.with_sample_rate(cpal::SampleRate(constants::WHISPER_SAMPLE_RATE))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If no config supports 16kHz, fall back to default
|
||||
Ok(device.default_input_config()?)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_consumer(
|
||||
in_sample_rate: u32,
|
||||
vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,
|
||||
sample_rx: mpsc::Receiver<Vec<f32>>,
|
||||
cmd_rx: mpsc::Receiver<Cmd>,
|
||||
) {
|
||||
let mut frame_resampler = FrameResampler::new(
|
||||
in_sample_rate as usize,
|
||||
constants::WHISPER_SAMPLE_RATE as usize,
|
||||
Duration::from_millis(30),
|
||||
);
|
||||
|
||||
let mut processed_samples = Vec::<f32>::new();
|
||||
let mut recording = false;
|
||||
|
||||
fn handle_frame(
|
||||
samples: &[f32],
|
||||
recording: bool,
|
||||
vad: &Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,
|
||||
out_buf: &mut Vec<f32>,
|
||||
) {
|
||||
if !recording {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(vad_arc) = vad {
|
||||
let mut det = vad_arc.lock().unwrap();
|
||||
match det.push_frame(samples).unwrap_or(VadFrame::Speech(samples)) {
|
||||
VadFrame::Speech(buf) => out_buf.extend_from_slice(buf),
|
||||
VadFrame::Noise => {}
|
||||
}
|
||||
} else {
|
||||
out_buf.extend_from_slice(samples);
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
let raw = match sample_rx.recv() {
|
||||
Ok(s) => s,
|
||||
Err(_) => break, // stream closed
|
||||
};
|
||||
|
||||
frame_resampler.push(&raw, &mut |frame: &[f32]| {
|
||||
handle_frame(frame, recording, &vad, &mut processed_samples)
|
||||
});
|
||||
|
||||
// non-blocking check for a command
|
||||
while let Ok(cmd) = cmd_rx.try_recv() {
|
||||
match cmd {
|
||||
Cmd::Start => {
|
||||
processed_samples.clear();
|
||||
recording = true;
|
||||
if let Some(v) = &vad {
|
||||
v.lock().unwrap().reset();
|
||||
}
|
||||
}
|
||||
Cmd::Stop(reply_tx) => {
|
||||
recording = false;
|
||||
|
||||
frame_resampler.finish(&mut |frame: &[f32]| {
|
||||
// we still want to process the last few frames
|
||||
handle_frame(frame, true, &vad, &mut processed_samples)
|
||||
});
|
||||
|
||||
let _ = reply_tx.send(std::mem::take(&mut processed_samples));
|
||||
}
|
||||
Cmd::Shutdown => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
99
src-tauri/src/audio_toolkit/audio/resampler.rs
Normal file
99
src-tauri/src/audio_toolkit/audio/resampler.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
use rubato::{FftFixedIn, Resampler};
|
||||
use std::time::Duration;
|
||||
|
||||
// Make this a constant you can tweak
|
||||
const RESAMPLER_CHUNK_SIZE: usize = 1024;
|
||||
|
||||
pub struct FrameResampler {
|
||||
resampler: Option<FftFixedIn<f32>>,
|
||||
chunk_in: usize,
|
||||
in_buf: Vec<f32>,
|
||||
frame_samples: usize,
|
||||
pending: Vec<f32>,
|
||||
}
|
||||
|
||||
impl FrameResampler {
|
||||
pub fn new(in_hz: usize, out_hz: usize, frame_dur: Duration) -> Self {
|
||||
let frame_samples = ((out_hz as f64 * frame_dur.as_secs_f64()).round()) as usize;
|
||||
assert!(frame_samples > 0, "frame duration too short");
|
||||
|
||||
// Use fixed chunk size instead of GCD-based
|
||||
let chunk_in = RESAMPLER_CHUNK_SIZE;
|
||||
|
||||
let resampler = (in_hz != out_hz).then(|| {
|
||||
FftFixedIn::<f32>::new(in_hz, out_hz, chunk_in, 1, 1)
|
||||
.expect("Failed to create resampler")
|
||||
});
|
||||
|
||||
Self {
|
||||
resampler,
|
||||
chunk_in,
|
||||
in_buf: Vec::with_capacity(chunk_in),
|
||||
frame_samples,
|
||||
pending: Vec::with_capacity(frame_samples),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, mut src: &[f32], mut emit: impl FnMut(&[f32])) {
|
||||
if self.resampler.is_none() {
|
||||
self.emit_frames(src, &mut emit);
|
||||
return;
|
||||
}
|
||||
|
||||
while !src.is_empty() {
|
||||
let space = self.chunk_in - self.in_buf.len();
|
||||
let take = space.min(src.len());
|
||||
self.in_buf.extend_from_slice(&src[..take]);
|
||||
src = &src[take..];
|
||||
|
||||
if self.in_buf.len() == self.chunk_in {
|
||||
// let start = std::time::Instant::now();
|
||||
if let Ok(out) = self
|
||||
.resampler
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.process(&[&self.in_buf[..]], None)
|
||||
{
|
||||
// let duration = start.elapsed();
|
||||
// println!("Resampler took: {:?}", duration);
|
||||
self.emit_frames(&out[0], &mut emit);
|
||||
}
|
||||
self.in_buf.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, mut emit: impl FnMut(&[f32])) {
|
||||
// Process any remaining input samples
|
||||
if let Some(ref mut resampler) = self.resampler {
|
||||
if !self.in_buf.is_empty() {
|
||||
// Pad with zeros to reach chunk size
|
||||
self.in_buf.resize(self.chunk_in, 0.0);
|
||||
if let Ok(out) = resampler.process(&[&self.in_buf[..]], None) {
|
||||
self.emit_frames(&out[0], &mut emit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit any remaining pending frame (padded with zeros)
|
||||
if !self.pending.is_empty() {
|
||||
self.pending.resize(self.frame_samples, 0.0);
|
||||
emit(&self.pending);
|
||||
self.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_frames(&mut self, mut data: &[f32], emit: &mut impl FnMut(&[f32])) {
|
||||
while !data.is_empty() {
|
||||
let space = self.frame_samples - self.pending.len();
|
||||
let take = space.min(data.len());
|
||||
self.pending.extend_from_slice(&data[..take]);
|
||||
data = &data[take..];
|
||||
|
||||
if self.pending.len() == self.frame_samples {
|
||||
emit(&self.pending);
|
||||
self.pending.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
357
src-tauri/src/audio_toolkit/bin/cli.rs
Normal file
357
src-tauri/src/audio_toolkit/bin/cli.rs
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
use hound::WavWriter;
|
||||
use std::io::{self, Write};
|
||||
|
||||
use handy_app_lib::audio_toolkit::{
|
||||
audio::{list_input_devices, CpalDeviceInfo},
|
||||
vad::SmoothedVad,
|
||||
AudioRecorder, SileroVad,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum RecorderMode {
|
||||
AlwaysOn,
|
||||
OnDemand,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RecorderMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RecorderMode::AlwaysOn => write!(f, "Always-On"),
|
||||
RecorderMode::OnDemand => write!(f, "On-Demand"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RecorderState {
|
||||
recorder: AudioRecorder,
|
||||
mode: RecorderMode,
|
||||
is_recording: bool,
|
||||
is_open: bool,
|
||||
current_device_index: Option<usize>,
|
||||
recording_index: u32,
|
||||
}
|
||||
|
||||
impl RecorderState {
|
||||
fn new(recorder: AudioRecorder) -> Self {
|
||||
Self {
|
||||
recorder,
|
||||
mode: RecorderMode::AlwaysOn,
|
||||
is_recording: false,
|
||||
is_open: false,
|
||||
current_device_index: None,
|
||||
recording_index: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn switch_mode(&mut self, new_mode: RecorderMode) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.mode == new_mode {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// If we're currently recording, stop first
|
||||
if self.is_recording {
|
||||
println!("Stopping current recording to switch modes...");
|
||||
self.stop_recording()?;
|
||||
}
|
||||
|
||||
// Close if open and switching to on-demand, or if switching from on-demand to always-on
|
||||
if self.is_open {
|
||||
match (&self.mode, &new_mode) {
|
||||
(RecorderMode::AlwaysOn, RecorderMode::OnDemand) => {
|
||||
self.recorder.close()?;
|
||||
self.is_open = false;
|
||||
println!("Closed recorder for On-Demand mode");
|
||||
}
|
||||
(RecorderMode::OnDemand, RecorderMode::AlwaysOn) => {
|
||||
// For switching from on-demand to always-on, we need to reopen
|
||||
// This will be handled when the user starts recording
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
self.mode = new_mode;
|
||||
println!("Switched to {} mode", self.mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_recording(
|
||||
&mut self,
|
||||
device_index: Option<usize>,
|
||||
devices: &[CpalDeviceInfo],
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.is_recording {
|
||||
return Err("Already recording! Stop the current recording first.".into());
|
||||
}
|
||||
|
||||
let device = if let Some(idx) = device_index {
|
||||
if idx >= devices.len() {
|
||||
return Err(format!(
|
||||
"Invalid device index: {}. Available devices: 0-{}",
|
||||
idx,
|
||||
devices.len() - 1
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Some(devices[idx].device.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match self.mode {
|
||||
RecorderMode::AlwaysOn => {
|
||||
// In always-on mode, open once and keep open
|
||||
if !self.is_open || self.current_device_index != device_index {
|
||||
if self.is_open {
|
||||
self.recorder.close()?;
|
||||
}
|
||||
self.recorder.open(device)?;
|
||||
self.is_open = true;
|
||||
self.current_device_index = device_index;
|
||||
println!("Opened recorder in Always-On mode");
|
||||
}
|
||||
self.recorder.start()?;
|
||||
}
|
||||
RecorderMode::OnDemand => {
|
||||
// In on-demand mode, open for each recording
|
||||
if self.is_open {
|
||||
self.recorder.close()?;
|
||||
}
|
||||
self.recorder.open(device)?;
|
||||
self.is_open = true;
|
||||
self.current_device_index = device_index;
|
||||
self.recorder.start()?;
|
||||
println!("Opened and started recorder in On-Demand mode");
|
||||
}
|
||||
}
|
||||
|
||||
self.is_recording = true;
|
||||
println!(
|
||||
"Recording started with device: {}",
|
||||
device_index.map_or("default".to_string(), |i| i.to_string())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop_recording(&mut self) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
|
||||
if !self.is_recording {
|
||||
return Err("No recording in progress.".into());
|
||||
}
|
||||
|
||||
let samples = self.recorder.stop()?;
|
||||
self.is_recording = false;
|
||||
|
||||
match self.mode {
|
||||
RecorderMode::AlwaysOn => {
|
||||
// Keep the recorder open for next recording
|
||||
println!("Recording stopped. Recorder remains open for next recording.");
|
||||
}
|
||||
RecorderMode::OnDemand => {
|
||||
// Close the recorder after each recording
|
||||
self.recorder.close()?;
|
||||
self.is_open = false;
|
||||
self.current_device_index = None;
|
||||
println!("Recording stopped and recorder closed.");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(samples)
|
||||
}
|
||||
|
||||
fn close(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.is_recording {
|
||||
self.stop_recording()?;
|
||||
}
|
||||
if self.is_open {
|
||||
self.recorder.close()?;
|
||||
self.is_open = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Advanced Audio Recorder CLI");
|
||||
println!("=========================");
|
||||
print_help();
|
||||
|
||||
let silero = SileroVad::new("./resources/models/silero_vad_v4.onnx", 0.5)?;
|
||||
let smoothed_vad = SmoothedVad::new(Box::new(silero), 15, 15);
|
||||
let recorder = AudioRecorder::new()?.with_vad(Box::new(smoothed_vad));
|
||||
let mut state = RecorderState::new(recorder);
|
||||
|
||||
let mut devices = list_input_devices()?;
|
||||
print_devices(&devices);
|
||||
|
||||
loop {
|
||||
print!("[{}] > ", state.mode);
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
let parts: Vec<&str> = input.trim().split_whitespace().collect();
|
||||
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let command = parts[0].to_lowercase();
|
||||
|
||||
match command.as_str() {
|
||||
"start" | "s" => {
|
||||
let device_index = if parts.len() > 1 {
|
||||
match parts[1].parse::<usize>() {
|
||||
Ok(idx) => Some(idx),
|
||||
Err(_) => {
|
||||
println!("Invalid device index format. Usage: start [device_index]");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match state.start_recording(device_index, &devices) {
|
||||
Ok(_) => println!("Recording started successfully!"),
|
||||
Err(e) => println!("Error starting recording: {}", e),
|
||||
}
|
||||
}
|
||||
"stop" => match state.stop_recording() {
|
||||
Ok(samples) => {
|
||||
if !samples.is_empty() {
|
||||
let filename = format!("recording_{}.wav", state.recording_index);
|
||||
match save_audio(&samples, &filename) {
|
||||
Ok(_) => {
|
||||
println!("Recording saved as: {}", filename);
|
||||
state.recording_index += 1;
|
||||
}
|
||||
Err(e) => println!("Error saving recording: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!("No audio data captured.");
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Error stopping recording: {}", e),
|
||||
},
|
||||
"mode" => {
|
||||
if parts.len() > 1 {
|
||||
let new_mode = match parts[1].to_lowercase().as_str() {
|
||||
"always" | "alwayson" | "always-on" | "a" => RecorderMode::AlwaysOn,
|
||||
"demand" | "ondemand" | "on-demand" | "d" => RecorderMode::OnDemand,
|
||||
_ => {
|
||||
println!("Invalid mode. Use 'always' or 'demand'");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match state.switch_mode(new_mode) {
|
||||
Ok(_) => {}
|
||||
Err(e) => println!("Error switching modes: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!("Current mode: {}", state.mode);
|
||||
println!("Usage: mode [always|demand]");
|
||||
}
|
||||
}
|
||||
"devices" | "dev" => {
|
||||
devices = list_input_devices()?;
|
||||
print_devices(&devices);
|
||||
}
|
||||
"status" => {
|
||||
println!("Status:");
|
||||
println!(" Mode: {}", state.mode);
|
||||
println!(
|
||||
" Recording: {}",
|
||||
if state.is_recording { "Yes" } else { "No" }
|
||||
);
|
||||
println!(
|
||||
" Recorder Open: {}",
|
||||
if state.is_open { "Yes" } else { "No" }
|
||||
);
|
||||
println!(
|
||||
" Current Device: {}",
|
||||
state
|
||||
.current_device_index
|
||||
.map_or("None".to_string(), |i| i.to_string())
|
||||
);
|
||||
println!(" Next Recording: recording_{}.wav", state.recording_index);
|
||||
}
|
||||
"help" | "h" => {
|
||||
print_help();
|
||||
}
|
||||
"quit" | "exit" | "q" => {
|
||||
println!("Shutting down...");
|
||||
match state.close() {
|
||||
Ok(_) => {
|
||||
if state.is_recording {
|
||||
println!(
|
||||
"Final recording saved as: recording_{}.wav",
|
||||
state.recording_index
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Error during shutdown: {}", e),
|
||||
}
|
||||
println!("Goodbye!");
|
||||
break;
|
||||
}
|
||||
"" => {
|
||||
// Empty input, continue
|
||||
}
|
||||
_ => {
|
||||
println!(
|
||||
"Unknown command: '{}'. Type 'help' for available commands.",
|
||||
command
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!("Commands:");
|
||||
println!(
|
||||
" start [device_index] | s [device_index] - Start recording (optionally with device)"
|
||||
);
|
||||
println!(" stop - Stop recording and save");
|
||||
println!(
|
||||
" mode [always|demand] - Switch recording mode or show current mode"
|
||||
);
|
||||
println!(" devices | dev - List available audio devices");
|
||||
println!(" status - Show current recorder status");
|
||||
println!(" help | h - Show this help message");
|
||||
println!(" quit | exit | q - Exit the program");
|
||||
println!();
|
||||
println!("Modes:");
|
||||
println!(" Always-On: Keeps recorder open for quick start/stop cycles");
|
||||
println!(" On-Demand: Opens/closes recorder for each recording session");
|
||||
println!();
|
||||
}
|
||||
|
||||
fn print_devices(devices: &[CpalDeviceInfo]) {
|
||||
println!("Available audio devices:");
|
||||
for (index, device) in devices.iter().enumerate() {
|
||||
println!(" {}: {}", index, device.name);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
fn save_audio(samples: &[f32], filename: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let spec = hound::WavSpec {
|
||||
channels: 1,
|
||||
sample_rate: 16000,
|
||||
bits_per_sample: 16,
|
||||
sample_format: hound::SampleFormat::Int,
|
||||
};
|
||||
|
||||
let mut writer = WavWriter::create(filename, spec)?;
|
||||
|
||||
for &sample in samples {
|
||||
let sample_i16 = (sample * i16::MAX as f32) as i16;
|
||||
writer.write_sample(sample_i16)?;
|
||||
}
|
||||
|
||||
writer.finalize()?;
|
||||
Ok(())
|
||||
}
|
||||
1
src-tauri/src/audio_toolkit/constants.rs
Normal file
1
src-tauri/src/audio_toolkit/constants.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub const WHISPER_SAMPLE_RATE: u32 = 16000;
|
||||
6
src-tauri/src/audio_toolkit/mod.rs
Normal file
6
src-tauri/src/audio_toolkit/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub mod audio;
|
||||
pub mod constants;
|
||||
pub mod vad;
|
||||
|
||||
pub use audio::{list_input_devices, list_output_devices, AudioRecorder, CpalDeviceInfo};
|
||||
pub use vad::{SileroVad, VoiceActivityDetector};
|
||||
32
src-tauri/src/audio_toolkit/vad/mod.rs
Normal file
32
src-tauri/src/audio_toolkit/vad/mod.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use anyhow::Result;
|
||||
|
||||
pub enum VadFrame<'a> {
|
||||
/// Speech – may aggregate several frames (prefill + current + hangover)
|
||||
Speech(&'a [f32]),
|
||||
/// Non-speech (silence, noise). Down-stream code can ignore it.
|
||||
Noise,
|
||||
}
|
||||
|
||||
impl<'a> VadFrame<'a> {
|
||||
#[inline]
|
||||
pub fn is_speech(&self) -> bool {
|
||||
matches!(self, VadFrame::Speech(_))
|
||||
}
|
||||
}
|
||||
|
||||
pub trait VoiceActivityDetector: Send + Sync {
|
||||
/// Primary streaming API: feed one 30-ms frame, get keep/drop decision.
|
||||
fn push_frame<'a>(&'a mut self, frame: &'a [f32]) -> Result<VadFrame<'a>>;
|
||||
|
||||
fn is_voice(&mut self, frame: &[f32]) -> Result<bool> {
|
||||
Ok(self.push_frame(frame)?.is_speech())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {}
|
||||
}
|
||||
|
||||
mod silero;
|
||||
mod smoothed;
|
||||
|
||||
pub use silero::SileroVad;
|
||||
pub use smoothed::SmoothedVad;
|
||||
54
src-tauri/src/audio_toolkit/vad/silero.rs
Normal file
54
src-tauri/src/audio_toolkit/vad/silero.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
|
||||
use vad_rs::Vad;
|
||||
|
||||
use super::{VadFrame, VoiceActivityDetector};
|
||||
use crate::audio_toolkit::constants;
|
||||
|
||||
const SILERO_FRAME_MS: u32 = 30;
|
||||
const SILERO_FRAME_SAMPLES: usize =
|
||||
(constants::WHISPER_SAMPLE_RATE * SILERO_FRAME_MS / 1000) as usize;
|
||||
|
||||
pub struct SileroVad {
|
||||
engine: Vad,
|
||||
threshold: f32,
|
||||
}
|
||||
|
||||
impl SileroVad {
|
||||
pub fn new<P: AsRef<Path>>(model_path: P, threshold: f32) -> Result<Self> {
|
||||
if !(0.0..=1.0).contains(&threshold) {
|
||||
anyhow::bail!("threshold must be between 0.0 and 1.0");
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
engine: Vad::new(&model_path, constants::WHISPER_SAMPLE_RATE as usize)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create VAD: {e}"))?,
|
||||
threshold,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl VoiceActivityDetector for SileroVad {
|
||||
fn push_frame<'a>(&'a mut self, frame: &'a [f32]) -> Result<VadFrame<'a>> {
|
||||
if frame.len() != SILERO_FRAME_SAMPLES {
|
||||
anyhow::bail!(
|
||||
"expected {SILERO_FRAME_SAMPLES} samples, got {}",
|
||||
frame.len()
|
||||
);
|
||||
}
|
||||
|
||||
let result = self
|
||||
.engine
|
||||
.compute(frame)
|
||||
.map_err(|e| anyhow::anyhow!("Silero VAD error: {e}"))?;
|
||||
|
||||
// println!("Silero VAD result: prob = {}", result.prob);
|
||||
|
||||
if result.prob > self.threshold {
|
||||
Ok(VadFrame::Speech(frame))
|
||||
} else {
|
||||
Ok(VadFrame::Noise)
|
||||
}
|
||||
}
|
||||
}
|
||||
106
src-tauri/src/audio_toolkit/vad/smoothed.rs
Normal file
106
src-tauri/src/audio_toolkit/vad/smoothed.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
use super::{VadFrame, VoiceActivityDetector};
|
||||
use anyhow::Result;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
pub struct SmoothedVad {
|
||||
inner_vad: Box<dyn VoiceActivityDetector>,
|
||||
prefill_frames: usize,
|
||||
hangover_frames: usize,
|
||||
onset_frames: usize,
|
||||
|
||||
frame_buffer: VecDeque<Vec<f32>>,
|
||||
hangover_counter: usize,
|
||||
onset_counter: usize,
|
||||
in_speech: bool,
|
||||
|
||||
temp_out: Vec<f32>,
|
||||
}
|
||||
|
||||
impl SmoothedVad {
|
||||
pub fn new(
|
||||
inner_vad: Box<dyn VoiceActivityDetector>,
|
||||
prefill_frames: usize,
|
||||
hangover_frames: usize,
|
||||
onset_frames: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner_vad,
|
||||
prefill_frames,
|
||||
hangover_frames,
|
||||
onset_frames,
|
||||
frame_buffer: VecDeque::new(),
|
||||
hangover_counter: 0,
|
||||
onset_counter: 0,
|
||||
in_speech: false,
|
||||
temp_out: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VoiceActivityDetector for SmoothedVad {
|
||||
fn push_frame<'a>(&'a mut self, frame: &'a [f32]) -> Result<VadFrame<'a>> {
|
||||
// 1. Buffer every incoming frame for possible pre-roll
|
||||
self.frame_buffer.push_back(frame.to_vec());
|
||||
while self.frame_buffer.len() > self.prefill_frames + 1 {
|
||||
self.frame_buffer.pop_front();
|
||||
}
|
||||
|
||||
// 2. Delegate to the wrapped boolean VAD
|
||||
let is_voice = self.inner_vad.is_voice(frame)?;
|
||||
// println!("Is Voice: {}", is_voice);
|
||||
|
||||
match (self.in_speech, is_voice) {
|
||||
// Potential start of speech - need to accumulate onset frames
|
||||
(false, true) => {
|
||||
self.onset_counter += 1;
|
||||
if self.onset_counter >= self.onset_frames {
|
||||
// We have enough consecutive voice frames to trigger speech
|
||||
self.in_speech = true;
|
||||
self.hangover_counter = self.hangover_frames;
|
||||
self.onset_counter = 0; // Reset for next time
|
||||
|
||||
// Collect prefill + current frame
|
||||
self.temp_out.clear();
|
||||
for buf in &self.frame_buffer {
|
||||
self.temp_out.extend(buf);
|
||||
}
|
||||
Ok(VadFrame::Speech(&self.temp_out))
|
||||
} else {
|
||||
// Not enough frames yet, still silence
|
||||
Ok(VadFrame::Noise)
|
||||
}
|
||||
}
|
||||
|
||||
// Ongoing Speech
|
||||
(true, true) => {
|
||||
self.hangover_counter = self.hangover_frames;
|
||||
Ok(VadFrame::Speech(frame))
|
||||
}
|
||||
|
||||
// End of Speech or interruption during onset phase
|
||||
(true, false) => {
|
||||
if self.hangover_counter > 0 {
|
||||
self.hangover_counter -= 1;
|
||||
Ok(VadFrame::Speech(frame))
|
||||
} else {
|
||||
self.in_speech = false;
|
||||
Ok(VadFrame::Noise)
|
||||
}
|
||||
}
|
||||
|
||||
// Silence or broken onset sequence
|
||||
(false, false) => {
|
||||
self.onset_counter = 0; // Reset onset counter on silence
|
||||
Ok(VadFrame::Noise)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.frame_buffer.clear();
|
||||
self.hangover_counter = 0;
|
||||
self.onset_counter = 0;
|
||||
self.in_speech = false;
|
||||
self.temp_out.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,17 @@
|
|||
use crate::audio_toolkit::audio::{list_input_devices, list_output_devices};
|
||||
use crate::managers::audio::{AudioRecordingManager, MicrophoneMode};
|
||||
use crate::settings::{get_settings, write_settings};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AudioDevice {
|
||||
pub index: String,
|
||||
pub name: String,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> {
|
||||
// Update settings
|
||||
|
|
@ -27,3 +36,89 @@ pub fn get_microphone_mode(app: AppHandle) -> Result<bool, String> {
|
|||
let settings = get_settings(&app);
|
||||
Ok(settings.always_on_microphone)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_available_microphones() -> Result<Vec<AudioDevice>, String> {
|
||||
let devices =
|
||||
list_input_devices().map_err(|e| format!("Failed to list audio devices: {}", e))?;
|
||||
|
||||
let mut result = vec![AudioDevice {
|
||||
index: "default".to_string(),
|
||||
name: "Default".to_string(),
|
||||
is_default: true,
|
||||
}];
|
||||
|
||||
result.extend(devices.into_iter().map(|d| AudioDevice {
|
||||
index: d.index,
|
||||
name: d.name,
|
||||
is_default: false, // The explicit default is handled separately
|
||||
}));
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<(), String> {
|
||||
let mut settings = get_settings(&app);
|
||||
settings.selected_microphone = if device_name == "default" {
|
||||
None
|
||||
} else {
|
||||
Some(device_name)
|
||||
};
|
||||
write_settings(&app, settings);
|
||||
|
||||
// Update the audio manager to use the new device
|
||||
let rm = app.state::<Arc<AudioRecordingManager>>();
|
||||
rm.update_selected_device()
|
||||
.map_err(|e| format!("Failed to update selected device: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_selected_microphone(app: AppHandle) -> Result<String, String> {
|
||||
let settings = get_settings(&app);
|
||||
Ok(settings
|
||||
.selected_microphone
|
||||
.unwrap_or_else(|| "default".to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_available_output_devices() -> Result<Vec<AudioDevice>, String> {
|
||||
let devices =
|
||||
list_output_devices().map_err(|e| format!("Failed to list output devices: {}", e))?;
|
||||
|
||||
let mut result = vec![AudioDevice {
|
||||
index: "default".to_string(),
|
||||
name: "Default".to_string(),
|
||||
is_default: true,
|
||||
}];
|
||||
|
||||
result.extend(devices.into_iter().map(|d| AudioDevice {
|
||||
index: d.index,
|
||||
name: d.name,
|
||||
is_default: false, // The explicit default is handled separately
|
||||
}));
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_selected_output_device(app: AppHandle, device_name: String) -> Result<(), String> {
|
||||
let mut settings = get_settings(&app);
|
||||
settings.selected_output_device = if device_name == "default" {
|
||||
None
|
||||
} else {
|
||||
Some(device_name)
|
||||
};
|
||||
write_settings(&app, settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_selected_output_device(app: AppHandle) -> Result<String, String> {
|
||||
let settings = get_settings(&app);
|
||||
Ok(settings
|
||||
.selected_output_device
|
||||
.unwrap_or_else(|| "default".to_string()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
mod actions;
|
||||
pub mod audio_toolkit;
|
||||
mod commands;
|
||||
mod managers;
|
||||
mod settings;
|
||||
|
|
@ -187,7 +188,13 @@ pub fn run() {
|
|||
commands::models::has_any_models_or_downloads,
|
||||
commands::models::get_recommended_first_model,
|
||||
commands::audio::update_microphone_mode,
|
||||
commands::audio::get_microphone_mode
|
||||
commands::audio::get_microphone_mode,
|
||||
commands::audio::get_available_microphones,
|
||||
commands::audio::set_selected_microphone,
|
||||
commands::audio::get_selected_microphone,
|
||||
commands::audio::get_available_output_devices,
|
||||
commands::audio::set_selected_output_device,
|
||||
commands::audio::get_selected_output_device
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
use crate::audio_toolkit::{list_input_devices, vad::SmoothedVad, AudioRecorder, SileroVad};
|
||||
use crate::settings::get_settings;
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::SampleFormat;
|
||||
use log::{debug, info};
|
||||
use rubato::{FftFixedIn, Resampler};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
use std::vec::Vec;
|
||||
use tauri::{App, Manager};
|
||||
use vad_rs::Vad;
|
||||
|
||||
const WHISPER_SAMPLE_RATE: usize = 16000;
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RecordingState {
|
||||
Idle,
|
||||
|
|
@ -23,46 +21,34 @@ pub enum MicrophoneMode {
|
|||
OnDemand,
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
fn create_audio_recorder(vad_path: &str) -> Result<AudioRecorder, anyhow::Error> {
|
||||
let silero = SileroVad::new(vad_path, 0.3)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create SileroVad: {}", e))?;
|
||||
let smoothed_vad = SmoothedVad::new(Box::new(silero), 15, 15, 2);
|
||||
let recorder = AudioRecorder::new()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create AudioRecorder: {}", e))?
|
||||
.with_vad(Box::new(smoothed_vad));
|
||||
Ok(recorder)
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AudioRecordingManager {
|
||||
state: Arc<Mutex<RecordingState>>,
|
||||
buffer: Arc<Mutex<Vec<f32>>>,
|
||||
mode: Arc<Mutex<MicrophoneMode>>,
|
||||
app_handle: tauri::AppHandle,
|
||||
// For on-demand mode
|
||||
stream_handle: Arc<Mutex<Option<std::thread::JoinHandle<()>>>>,
|
||||
stream_active: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
trait SampleToF32 {
|
||||
fn to_f32(&self) -> f32;
|
||||
}
|
||||
|
||||
impl SampleToF32 for i8 {
|
||||
fn to_f32(&self) -> f32 {
|
||||
*self as f32 / 128.0
|
||||
}
|
||||
}
|
||||
|
||||
impl SampleToF32 for i16 {
|
||||
fn to_f32(&self) -> f32 {
|
||||
*self as f32 / 32768.0
|
||||
}
|
||||
}
|
||||
|
||||
impl SampleToF32 for i32 {
|
||||
fn to_f32(&self) -> f32 {
|
||||
*self as f32 / 2147483648.0
|
||||
}
|
||||
}
|
||||
|
||||
impl SampleToF32 for f32 {
|
||||
fn to_f32(&self) -> f32 {
|
||||
*self
|
||||
}
|
||||
recorder: Arc<Mutex<Option<AudioRecorder>>>,
|
||||
is_open: Arc<Mutex<bool>>,
|
||||
is_recording: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl AudioRecordingManager {
|
||||
/* ---------- construction ------------------------------------------------ */
|
||||
|
||||
pub fn new(app: &App) -> Result<Self, anyhow::Error> {
|
||||
let settings = get_settings(&app.handle());
|
||||
let mode = if settings.always_on_microphone {
|
||||
|
|
@ -71,23 +57,17 @@ impl AudioRecordingManager {
|
|||
MicrophoneMode::OnDemand
|
||||
};
|
||||
|
||||
let state = Arc::new(Mutex::new(RecordingState::Idle));
|
||||
let buffer = Arc::new(Mutex::new(Vec::new()));
|
||||
let mode_arc = Arc::new(Mutex::new(mode.clone()));
|
||||
let app_handle = app.handle().clone();
|
||||
let stream_handle = Arc::new(Mutex::new(None));
|
||||
let stream_active = Arc::new(Mutex::new(false));
|
||||
|
||||
let manager = Self {
|
||||
state: state.clone(),
|
||||
buffer: buffer.clone(),
|
||||
mode: mode_arc,
|
||||
app_handle,
|
||||
stream_handle,
|
||||
stream_active,
|
||||
state: Arc::new(Mutex::new(RecordingState::Idle)),
|
||||
mode: Arc::new(Mutex::new(mode.clone())),
|
||||
app_handle: app.handle().clone(),
|
||||
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
is_open: Arc::new(Mutex::new(false)),
|
||||
is_recording: Arc::new(Mutex::new(false)),
|
||||
};
|
||||
|
||||
// If always-on mode, start the stream immediately
|
||||
// Always-on? Open immediately.
|
||||
if matches!(mode, MicrophoneMode::AlwaysOn) {
|
||||
manager.start_microphone_stream()?;
|
||||
}
|
||||
|
|
@ -95,393 +75,187 @@ impl AudioRecordingManager {
|
|||
Ok(manager)
|
||||
}
|
||||
|
||||
/* ---------- microphone life-cycle -------------------------------------- */
|
||||
|
||||
pub fn start_microphone_stream(&self) -> Result<(), anyhow::Error> {
|
||||
let mut open_flag = self.is_open.lock().unwrap();
|
||||
if *open_flag {
|
||||
debug!("Microphone stream already active");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let start_time = Instant::now();
|
||||
debug!("Starting microphone stream initialization...");
|
||||
|
||||
let mut stream_active = self.stream_active.lock().unwrap();
|
||||
if *stream_active {
|
||||
debug!("Microphone stream already active, skipping initialization");
|
||||
return Ok(()); // Stream already active
|
||||
let vad_path = self
|
||||
.app_handle
|
||||
.path()
|
||||
.resolve(
|
||||
"resources/models/silero_vad_v4.onnx",
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to resolve VAD path: {}", e))?;
|
||||
let mut recorder_opt = self.recorder.lock().unwrap();
|
||||
|
||||
if recorder_opt.is_none() {
|
||||
*recorder_opt = Some(create_audio_recorder(vad_path.to_str().unwrap())?);
|
||||
}
|
||||
|
||||
let vad_path = self.app_handle.path().resolve(
|
||||
"resources/models/silero_vad_v4.onnx",
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
)?;
|
||||
debug!("VAD model path resolved: {:?}", vad_path);
|
||||
|
||||
let host = cpal::default_host();
|
||||
debug!("Audio host initialized");
|
||||
|
||||
let device = host
|
||||
.default_input_device()
|
||||
.ok_or_else(|| anyhow::Error::msg("No input device available"))?;
|
||||
debug!("Default input device acquired: {:?}", device.name());
|
||||
|
||||
let config = device.default_input_config()?;
|
||||
let sample_rate = config.sample_rate().0;
|
||||
debug!(
|
||||
"Audio config - Sample rate: {}, Channels: {}, Format: {:?}",
|
||||
sample_rate,
|
||||
config.channels(),
|
||||
config.sample_format()
|
||||
);
|
||||
|
||||
// Configure the resampler - keeping 1024 as input size for FFT efficiency
|
||||
let resampler_start = Instant::now();
|
||||
let resampler = FftFixedIn::new(sample_rate as usize, WHISPER_SAMPLE_RATE, 1024, 2, 1)?;
|
||||
debug!("Resampler initialized in {:?}", resampler_start.elapsed());
|
||||
|
||||
let vad_start = Instant::now();
|
||||
let vad = Arc::new(Mutex::new(Vad::new(vad_path, WHISPER_SAMPLE_RATE).unwrap()));
|
||||
debug!("VAD initialized in {:?}", vad_start.elapsed());
|
||||
let vad_clone = Arc::clone(&vad);
|
||||
|
||||
let state_clone = Arc::clone(&self.state);
|
||||
let buffer_clone = Arc::clone(&self.buffer);
|
||||
let resampler = Arc::new(Mutex::new(resampler));
|
||||
let resampler_clone = Arc::clone(&resampler);
|
||||
let stream_active_clone = Arc::clone(&self.stream_active);
|
||||
|
||||
// Create a temporary buffer to accumulate samples
|
||||
let temp_buffer = Arc::new(Mutex::new(Vec::new()));
|
||||
let temp_buffer_clone = Arc::clone(&temp_buffer);
|
||||
|
||||
// Create a buffer for resampled chunks waiting for VAD processing
|
||||
let vad_buffer = Arc::new(Mutex::new(Vec::new()));
|
||||
let vad_buffer_clone = Arc::clone(&vad_buffer);
|
||||
|
||||
// Generic function to process audio data
|
||||
fn process_audio<T: SampleToF32 + Send + 'static>(
|
||||
data: &[T],
|
||||
channels: usize,
|
||||
state_clone: Arc<Mutex<RecordingState>>,
|
||||
temp_buffer_clone: Arc<Mutex<Vec<f32>>>,
|
||||
resampler_clone: Arc<Mutex<FftFixedIn<f32>>>,
|
||||
vad_buffer_clone: Arc<Mutex<Vec<f32>>>,
|
||||
buffer_clone: Arc<Mutex<Vec<f32>>>,
|
||||
vad_clone: Arc<Mutex<Vad>>,
|
||||
stream_active_clone: Arc<Mutex<bool>>,
|
||||
) {
|
||||
// Check if stream should still be active
|
||||
let stream_active = stream_active_clone.lock().unwrap();
|
||||
if !*stream_active {
|
||||
return;
|
||||
}
|
||||
drop(stream_active);
|
||||
|
||||
let state_guard = state_clone.lock().unwrap();
|
||||
if let RecordingState::Recording { .. } = *state_guard {
|
||||
let mut temp_buffer = temp_buffer_clone.lock().unwrap();
|
||||
|
||||
// Handle multichannel audio by mixing down to mono
|
||||
if channels > 1 {
|
||||
// Process chunks of `channels` samples at a time (each chunk is one audio frame)
|
||||
for chunk in data.chunks(channels) {
|
||||
// Average the channels to create a mono sample
|
||||
let mono_sample: f32 =
|
||||
chunk.iter().map(|s| s.to_f32()).sum::<f32>() / channels as f32;
|
||||
temp_buffer.push(mono_sample);
|
||||
}
|
||||
} else {
|
||||
// Single channel processing
|
||||
let f32_data: Vec<f32> = data.iter().map(|sample| sample.to_f32()).collect();
|
||||
temp_buffer.extend_from_slice(&f32_data);
|
||||
}
|
||||
|
||||
// Process when we have enough samples
|
||||
while temp_buffer.len() >= 1024 {
|
||||
// Take the first 1024 samples for processing
|
||||
let chunk: Vec<f32> = temp_buffer.drain(..1024).collect();
|
||||
|
||||
// Convert input data to the format expected by Rubato
|
||||
let input_frames = vec![chunk];
|
||||
|
||||
// Process the audio chunk through the resampler
|
||||
let mut resampler = resampler_clone.lock().unwrap();
|
||||
if let Ok(resampled) = resampler.process(&input_frames, None) {
|
||||
// Add resampled data to VAD buffer
|
||||
let mut vad_buffer = vad_buffer_clone.lock().unwrap();
|
||||
vad_buffer.extend_from_slice(&resampled[0]);
|
||||
|
||||
// Process 30ms chunks (480 samples) for VAD
|
||||
while vad_buffer.len() >= 480 {
|
||||
let chunk = vad_buffer.drain(..480).collect::<Vec<f32>>();
|
||||
|
||||
// Use VAD to detect speech
|
||||
if let Ok(mut vad) = vad_clone.lock() {
|
||||
match vad.compute(&chunk) {
|
||||
Ok(result) => {
|
||||
if result.prob > 0.3 {
|
||||
let mut buffer = buffer_clone.lock().unwrap();
|
||||
buffer.extend_from_slice(&chunk);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Error computing VAD: {:?}", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Get the selected device from settings
|
||||
let settings = get_settings(&self.app_handle);
|
||||
let selected_device = if let Some(device_name) = settings.selected_microphone {
|
||||
// Find the device by name
|
||||
match list_input_devices() {
|
||||
Ok(devices) => devices
|
||||
.into_iter()
|
||||
.find(|d| d.name == device_name)
|
||||
.map(|d| d.device),
|
||||
Err(e) => {
|
||||
debug!("Failed to list devices, using default: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(rec) = recorder_opt.as_mut() {
|
||||
rec.open(selected_device)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to open recorder: {}", e))?;
|
||||
}
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
let err_fn = |err| eprintln!("Error in stream: {}", err);
|
||||
|
||||
// Build the appropriate stream based on the sample format
|
||||
// Store the number of channels for use in the closure
|
||||
let channels = config.channels() as usize;
|
||||
debug!(
|
||||
"Audio stream thread started - Using {} channel(s) for recording",
|
||||
channels
|
||||
);
|
||||
|
||||
let stream = match config.sample_format() {
|
||||
SampleFormat::I8 => {
|
||||
let stream_active_i8 = Arc::clone(&stream_active_clone);
|
||||
device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[i8], _| {
|
||||
process_audio(
|
||||
data,
|
||||
channels,
|
||||
Arc::clone(&state_clone),
|
||||
Arc::clone(&temp_buffer_clone),
|
||||
Arc::clone(&resampler_clone),
|
||||
Arc::clone(&vad_buffer_clone),
|
||||
Arc::clone(&buffer_clone),
|
||||
Arc::clone(&vad_clone),
|
||||
Arc::clone(&stream_active_i8),
|
||||
)
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
SampleFormat::I16 => {
|
||||
let stream_active_i16 = Arc::clone(&stream_active_clone);
|
||||
device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[i16], _| {
|
||||
process_audio(
|
||||
data,
|
||||
channels,
|
||||
Arc::clone(&state_clone),
|
||||
Arc::clone(&temp_buffer_clone),
|
||||
Arc::clone(&resampler_clone),
|
||||
Arc::clone(&vad_buffer_clone),
|
||||
Arc::clone(&buffer_clone),
|
||||
Arc::clone(&vad_clone),
|
||||
Arc::clone(&stream_active_i16),
|
||||
)
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
SampleFormat::I32 => {
|
||||
let stream_active_i32 = Arc::clone(&stream_active_clone);
|
||||
device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[i32], _| {
|
||||
process_audio(
|
||||
data,
|
||||
channels,
|
||||
Arc::clone(&state_clone),
|
||||
Arc::clone(&temp_buffer_clone),
|
||||
Arc::clone(&resampler_clone),
|
||||
Arc::clone(&vad_buffer_clone),
|
||||
Arc::clone(&buffer_clone),
|
||||
Arc::clone(&vad_clone),
|
||||
Arc::clone(&stream_active_i32),
|
||||
)
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
SampleFormat::F32 => {
|
||||
let stream_active_f32 = Arc::clone(&stream_active_clone);
|
||||
device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[f32], _| {
|
||||
process_audio(
|
||||
data,
|
||||
channels,
|
||||
Arc::clone(&state_clone),
|
||||
Arc::clone(&temp_buffer_clone),
|
||||
Arc::clone(&resampler_clone),
|
||||
Arc::clone(&vad_buffer_clone),
|
||||
Arc::clone(&buffer_clone),
|
||||
Arc::clone(&vad_clone),
|
||||
Arc::clone(&stream_active_f32),
|
||||
)
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
sample_format => {
|
||||
panic!("Unsupported sample format: {:?}", sample_format);
|
||||
}
|
||||
}
|
||||
.expect("Failed to build input stream");
|
||||
|
||||
debug!("Audio input stream built successfully");
|
||||
stream.play().expect("Failed to play stream");
|
||||
debug!("Audio stream started playing");
|
||||
|
||||
// Keep the thread alive until stream_active becomes false
|
||||
let stream_active_for_loop = Arc::clone(&stream_active_clone);
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
let active = {
|
||||
let guard = stream_active_for_loop.lock().unwrap();
|
||||
*guard
|
||||
};
|
||||
if !active {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
*self.stream_handle.lock().unwrap() = Some(handle);
|
||||
*stream_active = true;
|
||||
|
||||
let total_time = start_time.elapsed();
|
||||
*open_flag = true;
|
||||
info!(
|
||||
"Microphone stream initialization completed in {:?}",
|
||||
total_time
|
||||
"Microphone stream initialized in {:?}",
|
||||
start_time.elapsed()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop_microphone_stream(&self) {
|
||||
let mut stream_active = self.stream_active.lock().unwrap();
|
||||
if !*stream_active {
|
||||
return; // Stream already stopped
|
||||
let mut open_flag = self.is_open.lock().unwrap();
|
||||
if !*open_flag {
|
||||
return;
|
||||
}
|
||||
|
||||
*stream_active = false;
|
||||
drop(stream_active);
|
||||
|
||||
// Wait for the stream thread to finish
|
||||
if let Some(handle) = self.stream_handle.lock().unwrap().take() {
|
||||
let _ = handle.join();
|
||||
if let Some(rec) = self.recorder.lock().unwrap().as_mut() {
|
||||
// If still recording, stop first.
|
||||
if *self.is_recording.lock().unwrap() {
|
||||
let _ = rec.stop();
|
||||
*self.is_recording.lock().unwrap() = false;
|
||||
}
|
||||
let _ = rec.close();
|
||||
}
|
||||
|
||||
*open_flag = false;
|
||||
debug!("Microphone stream stopped");
|
||||
}
|
||||
|
||||
pub fn update_mode(&self, new_mode: MicrophoneMode) -> Result<(), anyhow::Error> {
|
||||
let mut mode = self.mode.lock().unwrap();
|
||||
let current_mode = mode.clone();
|
||||
/* ---------- mode switching --------------------------------------------- */
|
||||
|
||||
if matches!(current_mode, MicrophoneMode::AlwaysOn)
|
||||
&& matches!(new_mode, MicrophoneMode::OnDemand)
|
||||
{
|
||||
// Switching from always-on to on-demand
|
||||
// Stop the stream if not currently recording
|
||||
let state = self.state.lock().unwrap();
|
||||
if matches!(*state, RecordingState::Idle) {
|
||||
drop(state);
|
||||
drop(mode);
|
||||
self.stop_microphone_stream();
|
||||
mode = self.mode.lock().unwrap();
|
||||
pub fn update_mode(&self, new_mode: MicrophoneMode) -> Result<(), anyhow::Error> {
|
||||
let mode_guard = self.mode.lock().unwrap();
|
||||
let cur_mode = mode_guard.clone();
|
||||
|
||||
match (cur_mode, &new_mode) {
|
||||
(MicrophoneMode::AlwaysOn, MicrophoneMode::OnDemand) => {
|
||||
if matches!(*self.state.lock().unwrap(), RecordingState::Idle) {
|
||||
drop(mode_guard);
|
||||
self.stop_microphone_stream();
|
||||
}
|
||||
}
|
||||
} else if matches!(current_mode, MicrophoneMode::OnDemand)
|
||||
&& matches!(new_mode, MicrophoneMode::AlwaysOn)
|
||||
{
|
||||
// Switching from on-demand to always-on
|
||||
drop(mode);
|
||||
self.start_microphone_stream()?;
|
||||
mode = self.mode.lock().unwrap();
|
||||
(MicrophoneMode::OnDemand, MicrophoneMode::AlwaysOn) => {
|
||||
drop(mode_guard);
|
||||
self.start_microphone_stream()?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
*mode = new_mode;
|
||||
*self.mode.lock().unwrap() = new_mode;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ---------- recording --------------------------------------------------- */
|
||||
|
||||
pub fn try_start_recording(&self, binding_id: &str) -> bool {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
match *state {
|
||||
RecordingState::Idle => {
|
||||
// For on-demand mode, start the microphone stream now
|
||||
let mode = self.mode.lock().unwrap();
|
||||
if matches!(*mode, MicrophoneMode::OnDemand) {
|
||||
debug!("On-demand mode: Starting microphone stream for recording");
|
||||
drop(mode);
|
||||
drop(state);
|
||||
if let Err(e) = self.start_microphone_stream() {
|
||||
eprintln!("Failed to start microphone stream: {}", e);
|
||||
return false;
|
||||
}
|
||||
state = self.state.lock().unwrap();
|
||||
}
|
||||
|
||||
// Clear the buffer before starting new recording
|
||||
self.buffer.lock().unwrap().clear();
|
||||
*state = RecordingState::Recording {
|
||||
binding_id: binding_id.to_string(),
|
||||
};
|
||||
println!("Started recording for binding {}", binding_id);
|
||||
true
|
||||
if let RecordingState::Idle = *state {
|
||||
// Ensure microphone is open in on-demand mode
|
||||
if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) {
|
||||
if let Err(e) = self.start_microphone_stream() {
|
||||
eprintln!("Failed to open microphone stream: {e}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
RecordingState::Recording {
|
||||
binding_id: ref active_id,
|
||||
} => {
|
||||
println!(
|
||||
"Cannot start recording: already recording for binding {}",
|
||||
active_id
|
||||
);
|
||||
false
|
||||
|
||||
if let Some(rec) = self.recorder.lock().unwrap().as_ref() {
|
||||
if rec.start().is_ok() {
|
||||
*self.is_recording.lock().unwrap() = true;
|
||||
*state = RecordingState::Recording {
|
||||
binding_id: binding_id.to_string(),
|
||||
};
|
||||
debug!("Recording started for binding {binding_id}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
eprintln!("Recorder not available");
|
||||
false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_selected_device(&self) -> Result<(), anyhow::Error> {
|
||||
// If currently open, restart the microphone stream to use the new device
|
||||
if *self.is_open.lock().unwrap() {
|
||||
self.stop_microphone_stream();
|
||||
self.start_microphone_stream()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop_recording(&self, binding_id: &str) -> Option<Vec<f32>> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
println!("Stop recording called from binding {}", binding_id);
|
||||
|
||||
match *state {
|
||||
RecordingState::Recording {
|
||||
binding_id: ref active_id,
|
||||
} if active_id == binding_id => {
|
||||
binding_id: ref active,
|
||||
} if active == binding_id => {
|
||||
*state = RecordingState::Idle;
|
||||
println!("Stopped recording for binding {}", binding_id);
|
||||
drop(state);
|
||||
|
||||
// For on-demand mode, stop the microphone stream
|
||||
let mode = self.mode.lock().unwrap();
|
||||
if matches!(*mode, MicrophoneMode::OnDemand) {
|
||||
debug!("On-demand mode: Stopping microphone stream after recording");
|
||||
drop(mode);
|
||||
drop(state);
|
||||
let samples = if let Some(rec) = self.recorder.lock().unwrap().as_ref() {
|
||||
match rec.stop() {
|
||||
Ok(buf) => buf,
|
||||
Err(e) => {
|
||||
eprintln!("stop() failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Recorder not available");
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
*self.is_recording.lock().unwrap() = false;
|
||||
|
||||
// In on-demand mode turn the mic off again
|
||||
if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) {
|
||||
self.stop_microphone_stream();
|
||||
}
|
||||
|
||||
let mut buffer = self.buffer.lock().unwrap();
|
||||
let audio_data: Vec<f32> = buffer.drain(..).collect();
|
||||
|
||||
let samples = audio_data.len();
|
||||
|
||||
if samples < WHISPER_SAMPLE_RATE && samples > 1000 {
|
||||
let target_samples = WHISPER_SAMPLE_RATE * 5 / 4; // sample rate * 1.25
|
||||
let mut padded_audio = audio_data;
|
||||
padded_audio.resize(target_samples, 0.0); // Pad with silence (zeros)
|
||||
Some(padded_audio)
|
||||
// Pad if very short
|
||||
let s_len = samples.len();
|
||||
// println!("Got {} samples", { s_len });
|
||||
if s_len < WHISPER_SAMPLE_RATE && s_len > 0 {
|
||||
let mut padded = samples;
|
||||
padded.resize(WHISPER_SAMPLE_RATE * 5 / 4, 0.0);
|
||||
Some(padded)
|
||||
} else {
|
||||
Some(audio_data)
|
||||
Some(samples)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// println!("Cannot stop recording: not recording or wrong binding");
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ pub struct AppSettings {
|
|||
pub selected_model: String,
|
||||
#[serde(default = "default_always_on_microphone")]
|
||||
pub always_on_microphone: bool,
|
||||
#[serde(default)]
|
||||
pub selected_microphone: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_output_device: Option<String>,
|
||||
}
|
||||
|
||||
fn default_model() -> String {
|
||||
|
|
@ -77,6 +81,8 @@ pub fn get_default_settings() -> AppSettings {
|
|||
audio_feedback: false,
|
||||
selected_model: "".to_string(),
|
||||
always_on_microphone: false,
|
||||
selected_microphone: None,
|
||||
selected_output_device: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ use enigo::Key;
|
|||
use enigo::Keyboard;
|
||||
use enigo::Settings;
|
||||
|
||||
use rodio::{Decoder, OutputStream, Sink};
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
use rodio::OutputStreamBuilder;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::thread;
|
||||
|
|
@ -121,8 +122,12 @@ pub fn play_sound(app: &AppHandle, resource_path: &str) {
|
|||
}
|
||||
};
|
||||
|
||||
// Get the selected output device from settings
|
||||
let settings = settings::get_settings(&app_handle);
|
||||
let selected_device = settings.selected_output_device.clone();
|
||||
|
||||
// Try to play the audio file
|
||||
if let Err(e) = play_audio_file(&audio_path) {
|
||||
if let Err(e) = play_audio_file(&audio_path, selected_device) {
|
||||
eprintln!("Failed to play sound '{}': {}", resource_path, e);
|
||||
}
|
||||
});
|
||||
|
|
@ -138,20 +143,50 @@ pub fn play_recording_stop_sound(app: &AppHandle) {
|
|||
play_sound(app, "resources/rec_stop.wav");
|
||||
}
|
||||
|
||||
fn play_audio_file(path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Get a output stream handle to the default physical sound device
|
||||
let (_stream, stream_handle) = OutputStream::try_default()?;
|
||||
fn play_audio_file(
|
||||
path: &std::path::Path,
|
||||
selected_device: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let stream_builder = if let Some(device_name) = selected_device {
|
||||
if device_name == "Default" {
|
||||
println!("Using default device");
|
||||
// Use default device
|
||||
OutputStreamBuilder::from_default_device()?
|
||||
} else {
|
||||
// Try to find the device by name
|
||||
let host = cpal::default_host();
|
||||
let devices = host.output_devices()?;
|
||||
|
||||
let mut found_device = None;
|
||||
for device in devices {
|
||||
if device.name()? == device_name {
|
||||
found_device = Some(device);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match found_device {
|
||||
Some(device) => OutputStreamBuilder::from_device(device)?,
|
||||
None => {
|
||||
eprintln!("Device '{}' not found, using default device", device_name);
|
||||
OutputStreamBuilder::from_default_device()?
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Using default device");
|
||||
// Use default device
|
||||
OutputStreamBuilder::from_default_device()?
|
||||
};
|
||||
|
||||
let stream_handle = stream_builder.open_stream()?;
|
||||
let mixer = stream_handle.mixer();
|
||||
|
||||
// Load the audio file
|
||||
let file = File::open(path)?;
|
||||
let buf_reader = BufReader::new(file);
|
||||
let source = Decoder::new(buf_reader)?;
|
||||
|
||||
// Create a sink to play the audio
|
||||
let sink = Sink::try_new(&stream_handle)?;
|
||||
sink.append(source);
|
||||
|
||||
// Wait for the audio to finish playing
|
||||
let sink = rodio::play(mixer, buf_reader)?;
|
||||
sink.sleep_until_end();
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@
|
|||
{
|
||||
"title": "Handy",
|
||||
"width": 540,
|
||||
"height": 510,
|
||||
"height": 590,
|
||||
"minWidth": 540,
|
||||
"minHeight": 510,
|
||||
"minHeight": 590,
|
||||
"resizable": true,
|
||||
"maximizable": false
|
||||
}
|
||||
|
|
|
|||
60
src/components/settings/AlwaysOnMicrophone.tsx
Normal file
60
src/components/settings/AlwaysOnMicrophone.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||
|
||||
interface AlwaysOnMicrophoneProps {
|
||||
descriptionMode?: "inline" | "tooltip";
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export const AlwaysOnMicrophone: React.FC<AlwaysOnMicrophoneProps> = ({
|
||||
descriptionMode = "tooltip",
|
||||
grouped = false,
|
||||
}) => {
|
||||
const [alwaysOnMode, setAlwaysOnMode] = useState<boolean>(false);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const alwaysOn: boolean = await invoke("get_microphone_mode");
|
||||
setAlwaysOnMode(alwaysOn);
|
||||
} catch (error) {
|
||||
console.error("Failed to load always-on microphone setting:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAlwaysOnToggle = async (enabled: boolean) => {
|
||||
if (isUpdating) return;
|
||||
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await invoke("update_microphone_mode", { alwaysOn: enabled });
|
||||
setAlwaysOnMode(enabled);
|
||||
|
||||
// Provide user feedback about the change
|
||||
console.log(`Always-on microphone ${enabled ? "enabled" : "disabled"}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to update microphone mode:", error);
|
||||
// Revert the toggle if the update failed
|
||||
setAlwaysOnMode(!enabled);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleSwitch
|
||||
checked={alwaysOnMode}
|
||||
onChange={handleAlwaysOnToggle}
|
||||
isUpdating={isUpdating}
|
||||
label="Always-On Microphone"
|
||||
description="Keep microphone active for low latency recording. This may prevent your computer from sleeping."
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
/>
|
||||
);
|
||||
};
|
||||
69
src/components/settings/AudioFeedback.tsx
Normal file
69
src/components/settings/AudioFeedback.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||
|
||||
interface AudioFeedbackProps {
|
||||
descriptionMode?: "inline" | "tooltip";
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export const AudioFeedback: React.FC<AudioFeedbackProps> = ({
|
||||
descriptionMode = "tooltip",
|
||||
grouped = false,
|
||||
}) => {
|
||||
const [audioFeedbackEnabled, setAudioFeedbackEnabled] =
|
||||
useState<boolean>(false);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
// Load from the store since this setting is handled differently
|
||||
const { load } = await import("@tauri-apps/plugin-store");
|
||||
const store = await load("settings_store.json", { autoSave: false });
|
||||
const settings = await store.get("settings");
|
||||
|
||||
if (
|
||||
settings &&
|
||||
typeof settings === "object" &&
|
||||
"audio_feedback" in settings
|
||||
) {
|
||||
setAudioFeedbackEnabled(settings.audio_feedback as boolean);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load audio feedback setting:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAudioFeedbackToggle = async (enabled: boolean) => {
|
||||
if (isUpdating) return;
|
||||
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await invoke("change_audio_feedback_setting", { enabled });
|
||||
setAudioFeedbackEnabled(enabled);
|
||||
console.log(`Audio feedback ${enabled ? "enabled" : "disabled"}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to update audio feedback setting:", error);
|
||||
// Revert the toggle if the update failed
|
||||
setAudioFeedbackEnabled(!enabled);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleSwitch
|
||||
checked={audioFeedbackEnabled}
|
||||
onChange={handleAudioFeedbackToggle}
|
||||
isUpdating={isUpdating}
|
||||
label="Audio Feedback"
|
||||
description="Play sound when recording starts and stops"
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,21 +1,25 @@
|
|||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { load } from "@tauri-apps/plugin-store";
|
||||
import {
|
||||
BindingResponseSchema,
|
||||
SettingsSchema,
|
||||
ShortcutBindingSchema,
|
||||
ShortcutBindingsMap,
|
||||
} from "../../lib/types";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { type } from "@tauri-apps/plugin-os";
|
||||
import { getKeyName } from "../../lib/utils/keyboard";
|
||||
import ResetIcon from "../icons/ResetIcon";
|
||||
import { SettingContainer } from "../ui/SettingContainer";
|
||||
|
||||
export const KeyboardShortcuts: React.FC = () => {
|
||||
interface HandyShortcutProps {
|
||||
descriptionMode?: "inline" | "tooltip";
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||
descriptionMode = "tooltip",
|
||||
grouped = false,
|
||||
}) => {
|
||||
const [bindings, setBindings] = React.useState<ShortcutBindingsMap>({});
|
||||
const [pttEnabled, setPttEnabled] = React.useState<boolean>(false);
|
||||
const [audioFeedbackEnabled, setAudioFeedbackEnabled] =
|
||||
React.useState<boolean>(false);
|
||||
const [keyPressed, setKeyPressed] = useState<string[]>([]);
|
||||
const [recordedKeys, setRecordedKeys] = useState<string[]>([]);
|
||||
const [editingShortcutId, setEditingShortcutId] = useState<string | null>(
|
||||
|
|
@ -23,13 +27,14 @@ export const KeyboardShortcuts: React.FC = () => {
|
|||
);
|
||||
const [originalBinding, setOriginalBinding] = useState<string>("");
|
||||
const [isMacOS, setIsMacOS] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const shortcutRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
|
||||
// Check if running on macOS
|
||||
useEffect(() => {
|
||||
const checkOsType = async () => {
|
||||
try {
|
||||
const osType = type();
|
||||
const osType = await type();
|
||||
setIsMacOS(osType === "macos");
|
||||
} catch (error) {
|
||||
console.error("Error detecting OS type:", error);
|
||||
|
|
@ -72,18 +77,27 @@ export const KeyboardShortcuts: React.FC = () => {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
load("settings_store.json", { autoSave: false }).then((r) => {
|
||||
console.log("loaded store", r);
|
||||
|
||||
r.get("settings").then((s) => {
|
||||
const settings = SettingsSchema.parse(s);
|
||||
setBindings(settings.bindings);
|
||||
setPttEnabled(settings.push_to_talk);
|
||||
setAudioFeedbackEnabled(settings.audio_feedback);
|
||||
});
|
||||
});
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const { load } = await import("@tauri-apps/plugin-store");
|
||||
const store = await load("settings_store.json", { autoSave: false });
|
||||
const settings = await store.get("settings");
|
||||
|
||||
if (settings) {
|
||||
const parsedSettings = SettingsSchema.parse(settings);
|
||||
setBindings(parsedSettings.bindings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Only add event listeners when we're in editing mode
|
||||
if (editingShortcutId === null) return;
|
||||
|
|
@ -91,7 +105,7 @@ export const KeyboardShortcuts: React.FC = () => {
|
|||
console.log("keyPressed", keyPressed);
|
||||
|
||||
// Keyboard event listeners
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Get the key and normalize it (unify left/right modifiers)
|
||||
|
|
@ -109,7 +123,7 @@ export const KeyboardShortcuts: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
const handleKeyUp = async (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Get the key and normalize it
|
||||
|
|
@ -120,7 +134,8 @@ export const KeyboardShortcuts: React.FC = () => {
|
|||
setKeyPressed((prev) => prev.filter((k) => k !== key));
|
||||
|
||||
// If no keys are pressed anymore, commit the shortcut
|
||||
if (keyPressed.length === 1 && keyPressed[0] === key) {
|
||||
const updatedKeyPressed = keyPressed.filter((k) => k !== key);
|
||||
if (updatedKeyPressed.length === 0 && recordedKeys.length > 0) {
|
||||
// Create the shortcut string from all recorded keys
|
||||
const newShortcut = recordedKeys.join("+");
|
||||
|
||||
|
|
@ -135,10 +150,22 @@ export const KeyboardShortcuts: React.FC = () => {
|
|||
[editingShortcutId]: updatedBinding,
|
||||
}));
|
||||
|
||||
invoke("change_binding", {
|
||||
id: editingShortcutId,
|
||||
binding: newShortcut,
|
||||
});
|
||||
try {
|
||||
await invoke("change_binding", {
|
||||
id: editingShortcutId,
|
||||
binding: newShortcut,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to change binding:", error);
|
||||
// Restore original binding on error
|
||||
setBindings((prev) => ({
|
||||
...prev,
|
||||
[editingShortcutId]: {
|
||||
...prev[editingShortcutId],
|
||||
current_binding: originalBinding,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// Exit editing mode and reset states
|
||||
setEditingShortcutId(null);
|
||||
|
|
@ -174,17 +201,19 @@ export const KeyboardShortcuts: React.FC = () => {
|
|||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("mousedown", handleClickOutside);
|
||||
window.addEventListener("click", handleClickOutside);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("mousedown", handleClickOutside);
|
||||
window.removeEventListener("click", handleClickOutside);
|
||||
};
|
||||
}, [keyPressed, recordedKeys, editingShortcutId, bindings, originalBinding]);
|
||||
|
||||
// Start recording a new shortcut
|
||||
const startRecording = (id: string) => {
|
||||
if (editingShortcutId === id) return; // Already editing this shortcut
|
||||
|
||||
// Store the original binding to restore if canceled
|
||||
setOriginalBinding(bindings[id]?.current_binding || "");
|
||||
setEditingShortcutId(id);
|
||||
|
|
@ -209,69 +238,56 @@ export const KeyboardShortcuts: React.FC = () => {
|
|||
shortcutRefs.current.set(id, ref);
|
||||
};
|
||||
|
||||
// If still loading, show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingContainer
|
||||
title="Handy Shortcuts"
|
||||
description="Configure keyboard shortcuts to trigger speech-to-text recording"
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
>
|
||||
<div className="text-sm text-mid-gray">Loading shortcuts...</div>
|
||||
</SettingContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// If no bindings are loaded, show empty state
|
||||
if (Object.keys(bindings).length === 0) {
|
||||
return (
|
||||
<SettingContainer
|
||||
title="Handy Shortcuts"
|
||||
description="Configure keyboard shortcuts to trigger speech-to-text recording"
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
>
|
||||
<div className="text-sm text-mid-gray">No shortcuts configured</div>
|
||||
</SettingContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-mid-gray/20 ">
|
||||
<div className="max-w-2/3">
|
||||
<h3 className="text-sm font-medium ">Push To Talk</h3>
|
||||
<p className="text-sm">Hold to record, release to stop</p>
|
||||
</div>
|
||||
<label className="inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
value=""
|
||||
className="sr-only peer"
|
||||
checked={pttEnabled}
|
||||
onChange={(e) => {
|
||||
console.log("change ptt setting", e.target.checked);
|
||||
const newValue = e.target.checked;
|
||||
setPttEnabled(newValue);
|
||||
<SettingContainer
|
||||
title="Handy Shortcut"
|
||||
description="Set the keyboard shortcut to start and stop speech-to-text recording"
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
>
|
||||
{(() => {
|
||||
const primaryBinding = Object.values(bindings)[0];
|
||||
const primaryId = Object.keys(bindings)[0];
|
||||
|
||||
invoke("change_ptt_setting", {
|
||||
enabled: newValue,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="relative w-11 h-6 bg-mid-gray/20 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-logo-primary rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-logo-primary"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-mid-gray/20 ">
|
||||
<div className="max-w-2/3">
|
||||
<h3 className="text-sm font-medium ">Audio Feedback</h3>
|
||||
<p className="text-sm">Play sound when recording starts and stops</p>
|
||||
</div>
|
||||
<label className="inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
value=""
|
||||
className="sr-only peer"
|
||||
checked={audioFeedbackEnabled}
|
||||
onChange={(e) => {
|
||||
console.log("change audio feedback setting", e.target.checked);
|
||||
const newValue = e.target.checked;
|
||||
setAudioFeedbackEnabled(newValue);
|
||||
if (!primaryBinding) {
|
||||
return (
|
||||
<div className="text-sm text-mid-gray">No shortcuts configured</div>
|
||||
);
|
||||
}
|
||||
|
||||
invoke("change_audio_feedback_setting", {
|
||||
enabled: newValue,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="relative w-11 h-6 bg-mid-gray/20 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-logo-primary rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-logo-primary"></div>
|
||||
</label>
|
||||
</div>
|
||||
{Object.entries(bindings).map(([id, binding]) => (
|
||||
<div
|
||||
key={id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border border-mid-gray/20 "
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium ">{binding.name}</h3>
|
||||
<p className="text-sm">{binding.description}</p>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
{editingShortcutId === id ? (
|
||||
{editingShortcutId === primaryId ? (
|
||||
<div
|
||||
ref={(ref) => setShortcutRef(id, ref)}
|
||||
ref={(ref) => setShortcutRef(primaryId, ref)}
|
||||
className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded min-w-[120px] text-center"
|
||||
>
|
||||
{formatCurrentKeys()}
|
||||
|
|
@ -279,15 +295,16 @@ export const KeyboardShortcuts: React.FC = () => {
|
|||
) : (
|
||||
<div
|
||||
className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary"
|
||||
onClick={() => startRecording(id)}
|
||||
onClick={() => startRecording(primaryId)}
|
||||
>
|
||||
{formatKeyCombination(binding.current_binding)}
|
||||
{formatKeyCombination(primaryBinding.current_binding)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
|
||||
onClick={() => {
|
||||
invoke("reset_binding", { id }).then((b) => {
|
||||
onClick={async () => {
|
||||
try {
|
||||
const b = await invoke("reset_binding", { id: primaryId });
|
||||
console.log("reset");
|
||||
const newBinding = BindingResponseSchema.parse(b);
|
||||
|
||||
|
|
@ -299,14 +316,16 @@ export const KeyboardShortcuts: React.FC = () => {
|
|||
const binding = newBinding.binding!;
|
||||
|
||||
setBindings({ ...bindings, [binding.id]: binding });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to reset binding:", error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ResetIcon className="" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</SettingContainer>
|
||||
);
|
||||
};
|
||||
116
src/components/settings/MicrophoneSelector.tsx
Normal file
116
src/components/settings/MicrophoneSelector.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { AudioDevice } from "../../lib/types";
|
||||
import { Dropdown } from "../ui/Dropdown";
|
||||
import { SettingContainer } from "../ui/SettingContainer";
|
||||
import ResetIcon from "../icons/ResetIcon";
|
||||
|
||||
interface MicrophoneSelectorProps {
|
||||
descriptionMode?: "inline" | "tooltip";
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = ({
|
||||
descriptionMode = "tooltip",
|
||||
grouped = false,
|
||||
}) => {
|
||||
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([]);
|
||||
const [selectedMicrophone, setSelectedMicrophone] =
|
||||
useState<string>("default");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadAudioDevices();
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadAudioDevices = async () => {
|
||||
try {
|
||||
const devices: AudioDevice[] = await invoke("get_available_microphones");
|
||||
setAudioDevices(devices);
|
||||
} catch (error) {
|
||||
console.error("Failed to load audio devices:", error);
|
||||
setAudioDevices([]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const selectedMic: string = await invoke("get_selected_microphone");
|
||||
setSelectedMicrophone(selectedMic);
|
||||
} catch (error) {
|
||||
console.error("Failed to load microphone settings:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMicrophoneSelect = async (deviceName: string) => {
|
||||
if (isUpdating) return;
|
||||
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await invoke("set_selected_microphone", { deviceName });
|
||||
setSelectedMicrophone(deviceName);
|
||||
console.log(
|
||||
`Microphone changed to: ${audioDevices.find((d) => d.name === deviceName)?.name}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to set microphone:", error);
|
||||
// Revert selection if update failed
|
||||
loadSettings();
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (isUpdating) return;
|
||||
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await invoke("set_selected_microphone", { deviceName: "default" });
|
||||
setSelectedMicrophone("default");
|
||||
console.log("Microphone reset to default");
|
||||
} catch (error) {
|
||||
console.error("Failed to reset microphone:", error);
|
||||
// Revert selection if reset failed
|
||||
loadSettings();
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingContainer
|
||||
title="Microphone"
|
||||
description="Select your preferred microphone device"
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Dropdown
|
||||
devices={audioDevices}
|
||||
selectedDevice={selectedMicrophone}
|
||||
onSelect={handleMicrophoneSelect}
|
||||
placeholder={isLoading ? "Loading..." : "Select microphone..."}
|
||||
disabled={isUpdating || isLoading}
|
||||
/>
|
||||
<button
|
||||
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
|
||||
onClick={handleReset}
|
||||
disabled={isUpdating || isLoading}
|
||||
>
|
||||
<ResetIcon className="" />
|
||||
</button>
|
||||
</div>
|
||||
{isUpdating && (
|
||||
<div className="absolute inset-0 bg-mid-gray/10 rounded flex items-center justify-center">
|
||||
<div className="w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</SettingContainer>
|
||||
);
|
||||
};
|
||||
118
src/components/settings/OutputDeviceSelector.tsx
Normal file
118
src/components/settings/OutputDeviceSelector.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { AudioDevice } from "../../lib/types";
|
||||
import { Dropdown } from "../ui/Dropdown";
|
||||
import { SettingContainer } from "../ui/SettingContainer";
|
||||
import ResetIcon from "../icons/ResetIcon";
|
||||
|
||||
interface OutputDeviceSelectorProps {
|
||||
descriptionMode?: "inline" | "tooltip";
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = ({
|
||||
descriptionMode = "tooltip",
|
||||
grouped = false,
|
||||
}) => {
|
||||
const [outputDevices, setOutputDevices] = useState<AudioDevice[]>([]);
|
||||
const [selectedOutputDevice, setSelectedOutputDevice] =
|
||||
useState<string>("default");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadOutputDevices();
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadOutputDevices = async () => {
|
||||
try {
|
||||
const devices: AudioDevice[] = await invoke(
|
||||
"get_available_output_devices",
|
||||
);
|
||||
setOutputDevices(devices);
|
||||
} catch (error) {
|
||||
console.error("Failed to load output devices:", error);
|
||||
setOutputDevices([]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const selectedDevice: string = await invoke("get_selected_output_device");
|
||||
setSelectedOutputDevice(selectedDevice);
|
||||
} catch (error) {
|
||||
console.error("Failed to load output device settings:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOutputDeviceSelect = async (deviceName: string) => {
|
||||
if (isUpdating) return;
|
||||
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await invoke("set_selected_output_device", { deviceName });
|
||||
setSelectedOutputDevice(deviceName);
|
||||
console.log(
|
||||
`Output device changed to: ${outputDevices.find((d) => d.name === deviceName)?.name}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to set output device:", error);
|
||||
// Revert selection if update failed
|
||||
loadSettings();
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (isUpdating) return;
|
||||
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await invoke("set_selected_output_device", { deviceName: "default" });
|
||||
setSelectedOutputDevice("default");
|
||||
console.log("Output device reset to default");
|
||||
} catch (error) {
|
||||
console.error("Failed to reset output device:", error);
|
||||
// Revert selection if reset failed
|
||||
loadSettings();
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingContainer
|
||||
title="Output Device"
|
||||
description="Select your preferred audio output device for feedback sounds"
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Dropdown
|
||||
devices={outputDevices}
|
||||
selectedDevice={selectedOutputDevice}
|
||||
onSelect={handleOutputDeviceSelect}
|
||||
placeholder={isLoading ? "Loading..." : "Select output device..."}
|
||||
disabled={isUpdating || isLoading}
|
||||
/>
|
||||
<button
|
||||
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
|
||||
onClick={handleReset}
|
||||
disabled={isUpdating || isLoading}
|
||||
>
|
||||
<ResetIcon className="" />
|
||||
</button>
|
||||
</div>
|
||||
{isUpdating && (
|
||||
<div className="absolute inset-0 bg-mid-gray/10 rounded flex items-center justify-center">
|
||||
<div className="w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</SettingContainer>
|
||||
);
|
||||
};
|
||||
68
src/components/settings/PushToTalk.tsx
Normal file
68
src/components/settings/PushToTalk.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||
|
||||
interface PushToTalkProps {
|
||||
descriptionMode?: "inline" | "tooltip";
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export const PushToTalk: React.FC<PushToTalkProps> = ({
|
||||
descriptionMode = "tooltip",
|
||||
grouped = false,
|
||||
}) => {
|
||||
const [pttEnabled, setPttEnabled] = useState<boolean>(false);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
// Load from the store since this setting is handled differently
|
||||
const { load } = await import("@tauri-apps/plugin-store");
|
||||
const store = await load("settings_store.json", { autoSave: false });
|
||||
const settings = await store.get("settings");
|
||||
|
||||
if (
|
||||
settings &&
|
||||
typeof settings === "object" &&
|
||||
"push_to_talk" in settings
|
||||
) {
|
||||
setPttEnabled(settings.push_to_talk as boolean);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load push-to-talk setting:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePttToggle = async (enabled: boolean) => {
|
||||
if (isUpdating) return;
|
||||
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await invoke("change_ptt_setting", { enabled });
|
||||
setPttEnabled(enabled);
|
||||
console.log(`Push-to-talk ${enabled ? "enabled" : "disabled"}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to update push-to-talk setting:", error);
|
||||
// Revert the toggle if the update failed
|
||||
setPttEnabled(!enabled);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleSwitch
|
||||
checked={pttEnabled}
|
||||
onChange={handlePttToggle}
|
||||
isUpdating={isUpdating}
|
||||
label="Push To Talk"
|
||||
description="Hold to record, release to stop"
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,11 +1,26 @@
|
|||
import React from "react";
|
||||
// import { AIConfig } from "./AIConfig";
|
||||
import { KeyboardShortcuts } from "./KeyboardShortcuts";
|
||||
import { MicrophoneSelector } from "./MicrophoneSelector";
|
||||
import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
|
||||
import { PushToTalk } from "./PushToTalk";
|
||||
import { AudioFeedback } from "./AudioFeedback";
|
||||
import { OutputDeviceSelector } from "./OutputDeviceSelector";
|
||||
import { HandyShortcut } from "./HandyShortcut";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
|
||||
export const Settings: React.FC = () => {
|
||||
return (
|
||||
<div className="max-w-3xl w-full mx-auto space-y-4">
|
||||
<KeyboardShortcuts />
|
||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
||||
<SettingsGroup>
|
||||
<HandyShortcut descriptionMode="tooltip" grouped={true} />
|
||||
<MicrophoneSelector descriptionMode="tooltip" grouped={true} />
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Advanced">
|
||||
<PushToTalk descriptionMode="tooltip" grouped={true} />
|
||||
<AudioFeedback descriptionMode="tooltip" grouped={true} />
|
||||
<OutputDeviceSelector descriptionMode="tooltip" grouped={true} />
|
||||
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
7
src/components/settings/index.ts
Normal file
7
src/components/settings/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { Settings } from "./Settings";
|
||||
export { MicrophoneSelector } from "./MicrophoneSelector";
|
||||
export { OutputDeviceSelector } from "./OutputDeviceSelector";
|
||||
export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
|
||||
export { PushToTalk } from "./PushToTalk";
|
||||
export { AudioFeedback } from "./AudioFeedback";
|
||||
export { HandyShortcut } from "./HandyShortcut";
|
||||
107
src/components/ui/Dropdown.tsx
Normal file
107
src/components/ui/Dropdown.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { AudioDevice } from "../../lib/types";
|
||||
|
||||
interface DropdownProps {
|
||||
devices: AudioDevice[];
|
||||
selectedDevice: string | null;
|
||||
onSelect: (deviceName: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Dropdown: React.FC<DropdownProps> = ({
|
||||
devices,
|
||||
selectedDevice,
|
||||
onSelect,
|
||||
placeholder = "Select a microphone...",
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Find the selected device name
|
||||
const selectedDeviceName = selectedDevice
|
||||
? devices.find((d) => d.name === selectedDevice)?.name || "Unknown Device"
|
||||
: null;
|
||||
|
||||
const handleSelect = (deviceName: string) => {
|
||||
onSelect(deviceName);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded min-w-[200px] text-left flex items-center justify-between transition-all duration-150 ${
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary"
|
||||
}`}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="truncate">{selectedDeviceName || placeholder}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 ml-2 transition-transform duration-200 ${
|
||||
isOpen ? "transform rotate-180" : ""
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-mid-gray/80 rounded shadow-lg z-50 max-h-60 overflow-y-auto">
|
||||
{devices.length === 0 ? (
|
||||
<div className="px-2 py-1 text-sm text-mid-gray">
|
||||
No microphones found
|
||||
</div>
|
||||
) : (
|
||||
devices.map((device) => (
|
||||
<button
|
||||
key={device.index}
|
||||
type="button"
|
||||
className={`w-full px-2 py-1 text-sm text-left hover:bg-logo-primary/10 transition-colors duration-150 ${
|
||||
selectedDevice === device.name
|
||||
? "bg-logo-primary/20 text-logo-primary font-semibold"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleSelect(device.name)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="truncate">{device.name}</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
107
src/components/ui/SettingContainer.tsx
Normal file
107
src/components/ui/SettingContainer.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
|
||||
interface SettingContainerProps {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
descriptionMode?: "inline" | "tooltip";
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export const SettingContainer: React.FC<SettingContainerProps> = ({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
descriptionMode = "tooltip",
|
||||
grouped = false,
|
||||
}) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle click outside to close tooltip
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
tooltipRef.current &&
|
||||
!tooltipRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowTooltip(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showTooltip) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
}, [showTooltip]);
|
||||
|
||||
const toggleTooltip = () => {
|
||||
setShowTooltip(!showTooltip);
|
||||
};
|
||||
|
||||
const containerClasses = grouped
|
||||
? "flex items-center justify-between px-4 p-2"
|
||||
: "flex items-center justify-between px-4 p-2 rounded-lg border border-mid-gray/20";
|
||||
|
||||
if (descriptionMode === "tooltip") {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="max-w-2/3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium ">{title}</h3>
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="relative"
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
onClick={toggleTooltip}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-mid-gray cursor-help hover:text-logo-primary transition-colors duration-200 select-none"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-label="More information"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggleTooltip();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{showTooltip && (
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-background border border-mid-gray/80 rounded-lg shadow-lg z-50 max-w-xs min-w-[200px] whitespace-normal animate-in fade-in-0 zoom-in-95 duration-200">
|
||||
<p className="text-sm text-center leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-[6px] border-r-[6px] border-t-[6px] border-l-transparent border-r-transparent border-t-mid-gray/80"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="max-w-2/3">
|
||||
<h3 className="text-sm font-medium ">{title}</h3>
|
||||
<p className="text-sm">{description}</p>
|
||||
</div>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
31
src/components/ui/SettingsGroup.tsx
Normal file
31
src/components/ui/SettingsGroup.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import React from "react";
|
||||
|
||||
interface SettingsGroupProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SettingsGroup: React.FC<SettingsGroupProps> = ({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{title && (
|
||||
<div className="px-4">
|
||||
<h2 className="text-sm font-medium text-mid-gray uppercase tracking-wide">
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="text-xs text-mid-gray mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
|
||||
<div className="divide-y divide-mid-gray/20">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
50
src/components/ui/ToggleSwitch.tsx
Normal file
50
src/components/ui/ToggleSwitch.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import React from "react";
|
||||
import { SettingContainer } from "./SettingContainer";
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
isUpdating?: boolean;
|
||||
label: string;
|
||||
description: string;
|
||||
descriptionMode?: "inline" | "tooltip";
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
isUpdating = false,
|
||||
label,
|
||||
description,
|
||||
descriptionMode = "tooltip",
|
||||
grouped = false,
|
||||
}) => {
|
||||
return (
|
||||
<SettingContainer
|
||||
title={label}
|
||||
description={description}
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
>
|
||||
<label className="inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
value=""
|
||||
className="sr-only peer"
|
||||
checked={checked}
|
||||
disabled={disabled || isUpdating}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<div className="relative w-11 h-6 bg-mid-gray/20 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-logo-primary rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-logo-primary peer-disabled:opacity-50"></div>
|
||||
</label>
|
||||
{isUpdating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</SettingContainer>
|
||||
);
|
||||
};
|
||||
4
src/components/ui/index.ts
Normal file
4
src/components/ui/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { Dropdown } from "./Dropdown";
|
||||
export { ToggleSwitch } from "./ToggleSwitch";
|
||||
export { SettingContainer } from "./SettingContainer";
|
||||
export { SettingsGroup } from "./SettingsGroup";
|
||||
|
|
@ -13,10 +13,20 @@ export const ShortcutBindingsMapSchema = z.record(
|
|||
ShortcutBindingSchema,
|
||||
);
|
||||
|
||||
export const AudioDeviceSchema = z.object({
|
||||
index: z.string(),
|
||||
name: z.string(),
|
||||
is_default: z.boolean(),
|
||||
});
|
||||
|
||||
export const SettingsSchema = z.object({
|
||||
bindings: ShortcutBindingsMapSchema,
|
||||
push_to_talk: z.boolean(),
|
||||
audio_feedback: z.boolean(),
|
||||
selected_model: z.string(),
|
||||
always_on_microphone: z.boolean(),
|
||||
selected_microphone: z.string().nullable().optional(),
|
||||
selected_output_device: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const BindingResponseSchema = z.object({
|
||||
|
|
@ -25,6 +35,7 @@ export const BindingResponseSchema = z.object({
|
|||
error: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type AudioDevice = z.infer<typeof AudioDeviceSchema>;
|
||||
export type BindingResponse = z.infer<typeof BindingResponseSchema>;
|
||||
export type ShortcutBinding = z.infer<typeof ShortcutBindingSchema>;
|
||||
export type ShortcutBindingsMap = z.infer<typeof ShortcutBindingsMapSchema>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue