From 52ccd7ee03f2accaeaa8afc9c8e0180103a929ec Mon Sep 17 00:00:00 2001 From: "michael.borak" Date: Tue, 20 Jan 2026 15:00:56 +0100 Subject: [PATCH] feat: complete history, attendees list, and smart templates --- README.md | 44 +- package-lock.json | 10 + package.json | 1 + src-tauri/Cargo.lock | 1107 ++++++++++++----- src-tauri/Cargo.toml | 8 +- src-tauri/capabilities/default.json | 3 +- .../resources/create_hearbit_audio.swift | 182 +++ src-tauri/src/audio_processor.rs | 183 +++ src-tauri/src/auth.rs | 112 ++ src-tauri/src/lib.rs | 127 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 169 ++- src/components/HistoryView.tsx | 67 + src/components/MeetingsView.tsx | 289 +++++ src/components/Recorder.tsx | 236 +++- src/components/Settings.tsx | 60 +- src/components/Tabs.tsx | 21 +- src/components/ui/Toast.tsx | 81 ++ 18 files changed, 2222 insertions(+), 480 deletions(-) create mode 100644 src-tauri/resources/create_hearbit_audio.swift create mode 100644 src-tauri/src/audio_processor.rs create mode 100644 src-tauri/src/auth.rs create mode 100644 src/components/HistoryView.tsx create mode 100644 src/components/MeetingsView.tsx create mode 100644 src/components/ui/Toast.tsx diff --git a/README.md b/README.md index 93b48ba..94a9b1b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,11 @@ * **🧠 Powered by Infomaniak AI**: * **Precision Transcription**: Standard-compliant formatting with **second-by-second timestamps** (e.g., `[00:12]`). * **Smart Summaries**: Uses advanced LLMs (Mixtral, Llama 3) to create actionable meeting notes. -* **📝 Professional Templates**: Comes with 3 built-in expert prompts: +* **ïżœ Smart VAD (Voice Activity Detection)**: Automatically filters out silence and background noise, ensuring your transcripts are clean and focused. +* **📅 Microsoft 365 Integration**: + * **Upcoming Meetings Panel**: View your daily schedule directly in the app. + * **One-Click Join & Record**: Instantly launch Teams/Zoom meetings and start recording with a single click. +* **ïżœđŸ“ Professional Templates**: Comes with 3 built-in expert prompts: * **Meeting Protocol**: For general business meetings. * **1:1 / Jour Fixe**: For confidential personnel discussions. * **Client Meeting**: For official, client-ready documentation. @@ -34,25 +38,15 @@ ## 🎧 Recording System Audio (Teams, Zoom, etc.) -To record clear meeting audio from other applications, you need a "virtual cable". We recommend **BlackHole 2ch**. +We've made this easy! Hearbit AI includes a built-in helper to set up your audio devices. -1. **Install BlackHole**: Download and install [BlackHole 2ch](https://existential.audio/blackhole/). -2. **Create a Multi-Output Device** (So you can hear the audio too!): - * Open **Audio MIDI Setup** on your Mac. - * Create a "Multi-Output Device". - * Select both **BlackHole 2ch** AND your **Headphones/Speakers**. - * *Tip: Use this Multi-Output Device as your SPEAKER in Teams/Zoom.* - - ![Multi-Output Device Setup](docs/screenshots/multi_output_setup.png) - -3. **Create Aggregate Device (Optional)**: - * If you want to record BOTH your Microphone and System Audio, create an **Aggregate Device**. - * Select **BlackHole 2ch** AND your **Microphone**. - - ![Aggregate Device Setup](docs/screenshots/aggregate_device_setup.png) - -4. **Select Input in Hearbit AI**: - * In Hearbit AI, select **BlackHole 2ch** (or your new Aggregate Device) as the **Input Device**. +1. **Open Audio MIDI Setup**: Click the "Open Audio MIDI Setup" button in the recorder view. +2. **Create "Hearbit Audio" Device**: + * If you don't have a virtual device, click **"đŸȘ„ Create Hearbit Audio Device"** in the app (appears in Meeting mode if no device is found). + * This will automatically configure a Multi-Output Device so you can record and hear at the same time. +3. **Select "Hearbit Audio" in Teams/Zoom**: + * In your meeting app settings (Teams/Zoom), set your **Speaker** to **Hearbit Audio**. + * In Hearbit AI, select **Hearbit Audio** (or BlackHole) as your input. --- @@ -61,14 +55,20 @@ To record clear meeting audio from other applications, you need a "virtual cable 1. **Configuration**: * Click the **Settings** (gear icon). * Enter your **Infomaniak API Key** and **Product ID**. - * (Optional) Customize where recordings are saved. -2. **Recording**: +2. **Connect M365 (Optional)**: + * Copy the **Application (client) ID**. + * Click the **Meetings** tab. + * Enter your **Client ID** and click "Connect". + * Proceed with MS login. + * View your upcoming meetings. + +3. **Recording**: * Choose your **Template** (e.g., "Meeting Protocol"). * Select your **Input Device**. * Click **Start Recording**. -3. **Processing**: +4. **Processing**: * Click **Stop** when finished. * The app will transcribe the audio (with timestamps!) and generate a summary based on your selected template. * You will be automatically taken to the **Transcription** tab to review the results. diff --git a/package-lock.json b/package-lock.json index 6c1c069..de3beed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@tailwindcss/postcss": "^4.1.18", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-opener": "^2", "jimp": "^1.6.0", "lucide-react": "^0.562.0", @@ -2086,6 +2087,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-fs": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz", + "integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", diff --git a/package.json b/package.json index caecb41..cd31718 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@tailwindcss/postcss": "^4.1.18", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-opener": "^2", "jimp": "^1.6.0", "lucide-react": "^0.562.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 86f2775..26cac17 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -236,26 +236,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "aws-lc-rs" -version = "1.15.3" +name = "base64" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e84ce723ab67259cfeb9877c6a639ee9eb7a27b28123abd71db7f0d5d0cc9d86" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a442ece363113bd4bd4c8b18977a7798dd4d3c3383f34fb61936960e8f4ad8" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64" @@ -269,6 +253,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "1.3.2" @@ -437,8 +427,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -465,7 +453,7 @@ version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ - "smallvec", + "smallvec 1.15.1", "target-lexicon", ] @@ -475,12 +463,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.43" @@ -495,15 +477,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - [[package]] name = "combine" version = "4.6.7" @@ -574,7 +547,7 @@ dependencies = [ "bitflags 2.10.0", "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -689,7 +662,7 @@ dependencies = [ "phf 0.10.1", "proc-macro2", "quote", - "smallvec", + "smallvec 1.15.1", "syn 1.0.109", ] @@ -754,6 +727,16 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.5" @@ -907,7 +890,7 @@ dependencies = [ "rustc_version", "toml 0.9.11+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1025,6 +1008,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -1047,6 +1041,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1054,7 +1057,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1068,6 +1071,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1083,12 +1092,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futf" version = "0.1.5" @@ -1099,6 +1102,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1106,6 +1124,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1173,6 +1192,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1333,11 +1353,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1355,7 +1373,7 @@ dependencies = [ "libc", "once_cell", "pin-project-lite", - "smallvec", + "smallvec 1.15.1", "thiserror 1.0.69", ] @@ -1391,7 +1409,7 @@ dependencies = [ "libc", "memchr", "once_cell", - "smallvec", + "smallvec 1.15.1", "thiserror 1.0.69", ] @@ -1490,16 +1508,16 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ - "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http", + "futures-util", + "http 0.2.12", "indexmap 2.13.0", "slab", "tokio", @@ -1526,14 +1544,20 @@ dependencies = [ "chrono", "cpal", "hound", - "reqwest 0.13.1", + "oauth2", + "reqwest 0.11.27", + "rubato", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-oauth", "tauri-plugin-opener", "tokio", + "url", + "voice_activity_detector", ] [[package]] @@ -1578,6 +1602,17 @@ dependencies = [ "match_token", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -1588,6 +1623,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1595,7 +1641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1606,8 +1652,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1617,6 +1663,36 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -1627,32 +1703,42 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", "pin-utils", - "smallvec", + "smallvec 1.15.1", "tokio", "want", ] [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ - "http", - "hyper", - "hyper-util", + "futures-util", + "http 0.2.12", + "hyper 0.14.32", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", - "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", ] [[package]] @@ -1666,19 +1752,17 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", - "system-configuration", + "socket2 0.6.1", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1751,7 +1835,7 @@ dependencies = [ "icu_normalizer_data", "icu_properties", "icu_provider", - "smallvec", + "smallvec 1.15.1", "zerovec", ] @@ -1809,7 +1893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", - "smallvec", + "smallvec 1.15.1", "utf8_iter", ] @@ -1941,16 +2025,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.85" @@ -2060,6 +2134,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", + "redox_syscall 0.7.0", ] [[package]] @@ -2089,12 +2164,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "mac" version = "0.1.1" @@ -2141,6 +2210,16 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.7.6" @@ -2214,6 +2293,38 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2256,6 +2367,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2273,6 +2393,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2304,6 +2433,26 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom 0.2.17", + "http 0.2.12", + "rand 0.8.5", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "objc2" version = "0.6.3" @@ -2585,10 +2734,48 @@ dependencies = [ ] [[package]] -name = "openssl-probe" -version = "0.2.0" +name = "openssl" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] [[package]] name = "option-ext" @@ -2606,6 +2793,31 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa7e49bd669d32d7bc2a15ec540a527e7764aec722a45467814005725bcd721" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec 2.0.0-alpha.10", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2aba9f5c7c479925205799216e7e5d07cc1d4fa76ea8058c60a9a30f6a4e890" +dependencies = [ + "flate2", + "pkg-config", + "sha2", + "tar", + "ureq", +] + [[package]] name = "pango" version = "0.18.3" @@ -2655,8 +2867,8 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", - "smallvec", + "redox_syscall 0.5.18", + "smallvec 1.15.1", "windows-link 0.2.1", ] @@ -2666,6 +2878,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2806,6 +3027,26 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2875,6 +3116,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2905,6 +3161,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -2982,62 +3247,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.43" @@ -3078,16 +3287,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - [[package]] name = "rand_chacha" version = "0.2.2" @@ -3108,16 +3307,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.5.1" @@ -3136,15 +3325,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_hc" version = "0.2.0" @@ -3169,6 +3349,21 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3178,6 +3373,15 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3238,6 +3442,51 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg 0.50.0", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -3248,10 +3497,10 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "js-sys", "log", @@ -3260,7 +3509,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-util", "tower", @@ -3273,48 +3522,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "reqwest" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" -dependencies = [ - "base64 0.22.1", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "rfd" version = "0.16.0" @@ -3354,10 +3561,16 @@ dependencies = [ ] [[package]] -name = "rustc-hash" -version = "2.1.1" +name = "rubato" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "e6dd52e80cfc21894deadf554a5673002938ae4625f7a283e536f9cf7c17b0d5" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "realfft", +] [[package]] name = "rustc_version" @@ -3368,6 +3581,20 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustix" version = "1.1.3" @@ -3383,28 +3610,23 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.36" +version = "0.21.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ - "aws-lc-rs", - "once_cell", - "rustls-pki-types", + "log", + "ring", "rustls-webpki", - "subtle", - "zeroize", + "sct", ] [[package]] -name = "rustls-native-certs" -version = "0.8.3" +name = "rustls-pemfile" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", + "base64 0.21.7", ] [[package]] @@ -3413,46 +3635,16 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ - "web-time", "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ - "aws-lc-rs", "ring", - "rustls-pki-types", "untrusted", ] @@ -3544,13 +3736,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "security-framework" -version = "3.5.1" +name = "sct" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.10.0", - "core-foundation 0.10.1", + "core-foundation 0.9.4", "core-foundation-sys", "libc", "security-framework-sys", @@ -3581,7 +3783,7 @@ dependencies = [ "phf_codegen 0.8.0", "precomputed-hash", "servo_arc", - "smallvec", + "smallvec 1.15.1", ] [[package]] @@ -3660,6 +3862,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3821,6 +4034,22 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smallvec" +version = "2.0.0-alpha.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d44cfb396c3caf6fbfd0ab422af02631b69ddd96d2eff0b0f0724f9024051b" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.1" @@ -3831,6 +4060,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "softbuffer" version = "0.4.8" @@ -3846,7 +4086,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -3885,6 +4125,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "string_cache" version = "0.8.9" @@ -3916,12 +4162,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "swift-rs" version = "1.0.7" @@ -3955,6 +4195,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3977,20 +4223,20 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ - "bitflags 2.10.0", + "bitflags 1.3.2", "core-foundation 0.9.4", "system-configuration-sys", ] [[package]] name = "system-configuration-sys" -version = "0.6.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" dependencies = [ "core-foundation-sys", "libc", @@ -4060,6 +4306,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4082,7 +4339,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http", + "http 1.4.0", "jni", "libc", "log", @@ -4237,6 +4494,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-oauth" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda564acdb23185caf700f89dd6e5d4540225d6a991516b2cad0cbcf27e4dcd3" +dependencies = [ + "httparse", + "log", + "serde", + "tauri", + "tauri-plugin", + "thiserror 1.0.69", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -4268,7 +4540,7 @@ dependencies = [ "cookie", "dpi", "gtk", - "http", + "http 1.4.0", "jni", "objc2", "objc2-ui-kit", @@ -4291,7 +4563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "187a3f26f681bdf028f796ccf57cf478c1ee422c50128e5a0a6ebeb3f5910065" dependencies = [ "gtk", - "http", + "http 1.4.0", "jni", "log", "objc2", @@ -4324,7 +4596,7 @@ dependencies = [ "dunce", "glob", "html5ever", - "http", + "http 1.4.0", "infer", "json-patch", "kuchikiki", @@ -4465,21 +4737,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.49.0" @@ -4492,7 +4749,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.1", "tokio-macros", "windows-sys 0.61.2", ] @@ -4509,10 +4766,20 @@ dependencies = [ ] [[package]] -name = "tokio-rustls" -version = "0.26.4" +name = "tokio-native-tls" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ "rustls", "tokio", @@ -4636,7 +4903,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -4651,8 +4918,8 @@ dependencies = [ "bitflags 2.10.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -4703,6 +4970,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "tray-icon" version = "0.21.3" @@ -4731,6 +5008,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-builder" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "typeid" version = "1.0.3" @@ -4819,6 +5116,36 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" +dependencies = [ + "base64 0.22.1", + "der", + "log", + "native-tls", + "percent-encoding", + "rustls-pki-types", + "socks", + "ureq-proto", + "utf-8", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4868,6 +5195,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4880,6 +5213,21 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "voice_activity_detector" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50407ebe9f37f46ee5d9a7a4228324f400439de9c62861273ab34bcfbdaf08b6" +dependencies = [ + "futures", + "ndarray", + "ort", + "ort-sys", + "pin-project", + "thiserror 2.0.18", + "typed-builder", +] + [[package]] name = "vswhom" version = "0.1.0" @@ -5022,16 +5370,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webkit2gtk" version = "2.0.1" @@ -5085,6 +5423,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "webview2-com" version = "0.38.2" @@ -5312,17 +5656,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -5368,6 +5701,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5419,6 +5761,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5485,6 +5842,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5503,6 +5866,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5521,6 +5890,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5551,6 +5926,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5569,6 +5950,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5587,6 +5974,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5605,6 +5998,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5635,6 +6034,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" @@ -5673,7 +6082,7 @@ dependencies = [ "gdkx11", "gtk", "html5ever", - "http", + "http 1.4.0", "javascriptcore-rs", "jni", "kuchikiki", @@ -5723,6 +6132,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0d3279b..b4a8184 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,5 +26,11 @@ serde_json = "1.0" chrono = "0.4" cpal = "0.17.1" hound = "3.5.1" -reqwest = { version = "0.13.1", features = ["json", "multipart"] } +reqwest = { version = "0.11", features = ["json", "multipart"] } tokio = { version = "1.40.0", features = ["full"] } +tauri-plugin-fs = "2.4.5" +voice_activity_detector = "0.2.1" +rubato = "0.14.1" +tauri-plugin-oauth = "2.0.0" +oauth2 = "4.4" +url = "2.5" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 3c1ad59..4f7c3b1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "permissions": [ "core:default", "opener:default", - "dialog:default" + "dialog:default", + "fs:default" ] } \ No newline at end of file diff --git a/src-tauri/resources/create_hearbit_audio.swift b/src-tauri/resources/create_hearbit_audio.swift new file mode 100644 index 0000000..3891c91 --- /dev/null +++ b/src-tauri/resources/create_hearbit_audio.swift @@ -0,0 +1,182 @@ +#!/usr/bin/env swift +import Foundation +import CoreAudio + +// Extensions and Helpers +extension Int32 { + var fourCC: String { + let utf16 = [ + UInt16((self >> 24) & 0xFF), + UInt16((self >> 16) & 0xFF), + UInt16((self >> 8) & 0xFF), + UInt16(self & 0xFF) + ] + return String(utf16CodeUnits: utf16, count: 4) + } +} + +// Safer Property Getter +func getPropertyData(objectID: AudioObjectID, selector: AudioObjectPropertySelector, initialValue: T) -> T? { + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var size = UInt32(MemoryLayout.size) + var value = initialValue + + let status = AudioObjectGetPropertyData(objectID, &address, 0, nil, &size, &value) + if status == noErr { + return value + } + return nil +} + +// CFString Helper +func getStringProperty(objectID: AudioObjectID, selector: AudioObjectPropertySelector) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + // CFStringRef is just a pointer, so size of Optional> is pointer size + var size = UInt32(MemoryLayout?>.size) + var value: Unmanaged? + + let status = AudioObjectGetPropertyData(objectID, &address, 0, nil, &size, &value) + if status == noErr, let existingValue = value { + return existingValue.takeRetainedValue() as String + } + return nil +} + +func findDeviceByName(_ name: String) -> AudioObjectID? { + // System Object is 1 + let systemID = AudioObjectID(kAudioObjectSystemObject) + + // Get all devices + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(systemID, &address, 0, nil, &size) == noErr else { return nil } + + let count = Int(size) / MemoryLayout.size + var deviceIDs = [AudioObjectID](repeating: 0, count: count) + guard AudioObjectGetPropertyData(systemID, &address, 0, nil, &size, &deviceIDs) == noErr else { return nil } + + for id in deviceIDs { + if let devName = getStringProperty(objectID: id, selector: kAudioDevicePropertyDeviceNameCFString) { + if devName == name { + return id + } + } + } + return nil +} + +func findDeviceByUID(_ uid: String) -> AudioObjectID? { + let systemID = AudioObjectID(kAudioObjectSystemObject) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(systemID, &address, 0, nil, &size) == noErr else { return nil } + + let count = Int(size) / MemoryLayout.size + var deviceIDs = [AudioObjectID](repeating: 0, count: count) + guard AudioObjectGetPropertyData(systemID, &address, 0, nil, &size, &deviceIDs) == noErr else { return nil } + + for id in deviceIDs { + if let devUID = getStringProperty(objectID: id, selector: kAudioDevicePropertyDeviceUID) { + if devUID == uid { + return id + } + } + } + return nil +} + +func createAggregateDevice() { + print("Searching for devices...") + + guard let blackHoleID = findDeviceByName("BlackHole 2ch") else { + print("Error: BlackHole 2ch not found. Please install it first.") + exit(1) + } + print("Found BlackHole 2ch (ID: \(blackHoleID))") + + // Default Input + var defaultInputID: AudioObjectID = 0 + var size = UInt32(MemoryLayout.size) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + if AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &defaultInputID) != noErr { + print("Error: Could not find default input.") + exit(1) + } + print("Found Default Input (ID: \(defaultInputID))") + + // Check for existing "Hearbit Audio" by UID + let targetUID = "hearbit_audio_aggregate_v1" + if let existingID = findDeviceByUID(targetUID) { + print("Found existing Hearbit Audio device (ID: \(existingID)). Destroying to recreate...") + if AudioHardwareDestroyAggregateDevice(existingID) != noErr { + print("Warning: Failed to destroy existing device.") + } else { + print("Existing device destroyed.") + } + Thread.sleep(forTimeInterval: 0.5) + } + + // Build SubDevice List + guard let bhUID = getStringProperty(objectID: blackHoleID, selector: kAudioDevicePropertyDeviceUID) else { + print("Error: Could not get BlackHole UID.") + exit(1) + } + guard let micUID = getStringProperty(objectID: defaultInputID, selector: kAudioDevicePropertyDeviceUID) else { + print("Error: Could not get Default Input UID.") + exit(1) + } + + // Dedup: if Mic IS BlackHole (user set BlackHole as default), don't duplicate + var subDevicesUIDs = [bhUID] + if micUID != bhUID { + subDevicesUIDs.append(micUID) + } + + let subDevicesArray = subDevicesUIDs.map { + [kAudioSubDeviceUIDKey: $0] + } + + let desc: [String: Any] = [ + kAudioAggregateDeviceNameKey: "Hearbit Audio", + kAudioAggregateDeviceUIDKey: targetUID, + kAudioAggregateDeviceIsPrivateKey: Int(0), + kAudioAggregateDeviceIsStackedKey: Int(0), + kAudioAggregateDeviceSubDeviceListKey: subDevicesArray + ] + + print("Creating Aggregate Device with UIDs: \(subDevicesUIDs)") + + var outID: AudioObjectID = 0 + let err = AudioHardwareCreateAggregateDevice(desc as CFDictionary, &outID) + + if err == noErr { + print("Success! Created 'Hearbit Audio' with ID: \(outID)") + exit(0) + } else { + print("Failed to create device. Error code: \(err) (\(err.fourCC))") + exit(1) + } +} + +createAggregateDevice() diff --git a/src-tauri/src/audio_processor.rs b/src-tauri/src/audio_processor.rs new file mode 100644 index 0000000..e09f803 --- /dev/null +++ b/src-tauri/src/audio_processor.rs @@ -0,0 +1,183 @@ +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Emitter}; +use cpal::Sample; +use hound::WavWriter; +use rubato::{Resampler, FastFixedIn, PolynomialDegree}; +use voice_activity_detector::VoiceActivityDetector; + +pub struct AudioProcessor { + // VAD + vad: VoiceActivityDetector, + vad_chunk_size: usize, + vad_buffer: Vec, + + // Resampler + resampler: FastFixedIn, + resample_input_buffer: Vec, + resample_output_buffer: Vec, + + // State + is_speech_active: bool, + last_speech_time: u64, // In samples or frames + hangover_samples: u64, + + // Ring Buffer (for pre-roll) + ring_buffer: Vec, + ring_pos: usize, + ring_size: usize, + + // Output + writer: Arc>>>, + sample_rate: u32, + total_processed_samples: u64, + // Event Emission + app_handle: Option, + last_event_time: std::time::Instant, +} + +impl AudioProcessor { + pub fn new( + sample_rate: u32, + writer: Arc>>>, + app_handle: AppHandle + ) -> Result { + let vad_sample_rate = 16000; + let vad_chunk_size = 512; // Silero usually likes ~30ms which is 512 at 16k? No 16000 * 0.032 = 512. + + // Initialize VAD + let vad = VoiceActivityDetector::builder() + .sample_rate(vad_sample_rate as u32) + .chunk_size(vad_chunk_size) + .build() + .map_err(|e| format!("Failed to init VAD: {:?}", e))?; + + // Initialize Resampler (Input Rate -> 16000) using FastFixedIn for speed/simplicity + // new(f_ratio, max_resample_ratio_relative, polyn_deg, chunk_size, channels) + let resampler = FastFixedIn::::new( + 16000.0 / sample_rate as f64, + 1.0, + PolynomialDegree::Septic, + 1024, + 1 + ).map_err(|e| format!("Failed to init Resampler: {:?}", e))?; + + // Pre-roll buffer (e.g. 0.5 seconds of high quality audio) + let ring_curr_seconds = 1.0; + let ring_size = (sample_rate as f32 * ring_curr_seconds) as usize; + + Ok(Self { + vad, + vad_chunk_size, + vad_buffer: Vec::new(), + resampler, + resample_input_buffer: Vec::new(), + resample_output_buffer: Vec::new(), + is_speech_active: false, + last_speech_time: 0, + hangover_samples: (sample_rate as f32 * 1.5) as u64, // 1.5s hangover + ring_buffer: vec![0.0; ring_size], + ring_pos: 0, + ring_size, + writer, + sample_rate, + total_processed_samples: 0, + app_handle: Some(app_handle), + last_event_time: std::time::Instant::now(), + }) + } + + pub fn process(&mut self, data: &[f32]) { + // 1. Add to Ring Buffer (always, for pre-roll) + for &sample in data { + self.ring_buffer[self.ring_pos] = sample; + self.ring_pos = (self.ring_pos + 1) % self.ring_size; + } + + // 2. Resample for VAD + // We append new data to input buffer for resampler + self.resample_input_buffer.extend_from_slice(data); + + // Process in chunks compatible with resampler + // Actually rubato process_into_buffer needs waves of input. + // Simplified: SincFixedIn wants a fixed number of input frames? + // Docs: "retrieve result... input buffer must contain needed number of frames" + // SincFixedIn: "input buffer used for resampling... must receive a fixed number of frames" + // Wait, SincFixedIn is fixed INPUT size. SincFixedOut is fixed OUTPUT size. + // We want to feed whatever we get. + // For simplicity, let's use a simpler resampling strategy or accept rubato's constraints. + // Rubato SincFixedIn: we must provide `input_frames_next` frames. + + // Let's defer strict resampling and just use decimation if sample rate is multiple? + // No, user devices vary. + + // Handling Resampling properly: + let needed = self.resampler.input_frames_next(); + while self.resample_input_buffer.len() >= needed { + let chunk: Vec = self.resample_input_buffer.drain(0..needed).collect(); + // Resample (mono) + let waves_in = vec![chunk]; + // Allocate output (approx) + let mut waves_out = vec![vec![0.0; (needed as f64 * (16000.0 / self.sample_rate as f64)).ceil() as usize + 10]; 1]; // +10 padding + + if let Ok((_in_len, out_len)) = self.resampler.process_into_buffer(&waves_in, &mut waves_out, None) { + if out_len > 0 { + self.vad_buffer.extend_from_slice(&waves_out[0][0..out_len]); + } + } + // Update output buffer usage... logic is tricky with drain. + } + + // 3. Process VAD + while self.vad_buffer.len() >= self.vad_chunk_size { + let vad_chunk: Vec = self.vad_buffer.drain(0..self.vad_chunk_size).collect(); + // Run Detection + let probability = self.vad.predict(vad_chunk); + let is_speech = probability > 0.5; + + if is_speech { + self.is_speech_active = true; + self.last_speech_time = self.total_processed_samples; + } + + // Emit VAD event periodically (every 500ms) + if self.last_event_time.elapsed().as_millis() > 500 { + if let Some(app) = &self.app_handle { + // Calculate crude RMS for visualization or just send probability + // Just sending probability is enough for now + #[derive(serde::Serialize, Clone)] + struct VadEvent { + probability: f32, + is_speech: bool, + } + let _ = app.emit("vad-event", VadEvent { probability, is_speech }); + } + self.last_event_time = std::time::Instant::now(); + } + } + + // 4. Update Hangover and Check Write condition + let time_since_speech = self.total_processed_samples.saturating_sub(self.last_speech_time); + + if self.is_speech_active || time_since_speech < self.hangover_samples { + // We are recording! + // Check if we just started (transition) + // Ideally we dump the ring buffer here if we just switched state. + // Implementing perfect ring buffer dump is complex (need to track state changes better). + // MVP: Just Write Current Data if in state. + + // Improvement: If we are in hangover, we just write. + // If we just detected speech (was not speech?), dump ring buffer? + // We'd need to know if we 'wrote' the ring buffer already. + + // Simple Logic: just write all incoming data if (Now - LastSpeech < Hangover) + + let mut guard = self.writer.lock().unwrap(); + for &sample in data { + let amplitude = i16::MAX as f32; + guard.write_sample((sample * amplitude) as i16).ok(); + } + } + + self.total_processed_samples += data.len() as u64; + } +} diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs new file mode 100644 index 0000000..a8687a2 --- /dev/null +++ b/src-tauri/src/auth.rs @@ -0,0 +1,112 @@ +use tauri::{AppHandle, Runtime}; +use tauri_plugin_opener::OpenerExt; +use tauri_plugin_oauth::start; +use url::Url; +use oauth2::{ + basic::BasicClient, AuthUrl, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, + TokenResponse, TokenUrl, +}; +use oauth2::reqwest::async_http_client; + +// const CLIENT_ID: &str = "YOUR_CLIENT_ID_HERE"; +const AUTH_URL: &str = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; +const TOKEN_URL: &str = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; + +#[tauri::command] +pub async fn start_auth_flow(app: AppHandle, client_id: String) -> Result { + // 1. Start Localhost Server + let (tx, rx) = std::sync::mpsc::channel(); + + // tauri-plugin-oauth start() returns a port and stops server when callback received + let port = start(move |url| { + // Ignore favicon requests to avoid triggering early + if url.contains("favicon.ico") { + return; + } + let _ = tx.send(url); + }) + .map_err(|e| format!("Failed to start oauth server: {}", e))?; + + let redirect_uri = format!("http://localhost:{}/auth/callback", port); + + // 2. Setup OAuth Client + let client = BasicClient::new( + ClientId::new(client_id), + None, // No client secret for PKCE public client + AuthUrl::new(AUTH_URL.to_string()).map_err(|e| e.to_string())?, + Some(TokenUrl::new(TOKEN_URL.to_string()).map_err(|e| e.to_string())?), + ) + .set_redirect_uri(RedirectUrl::new(redirect_uri.clone()).map_err(|e| e.to_string())?); + + // 3. Generate PKCE Challenge + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + // 4. Generate Auth URL + let (auth_url, _csrf_token) = client + .authorize_url(CsrfToken::new_random) + .add_scope(Scope::new("User.Read".to_string())) + .add_scope(Scope::new("Calendars.Read".to_string())) + .set_pkce_challenge(pkce_challenge) + .url(); + + // 5. Open Browser + app.opener().open_url(auth_url.as_str(), None::<&str>) + .map_err(|e| format!("Failed to open browser: {}", e))?; + + // 6. Wait for Callback + let received_url_str = rx.recv().map_err(|e| format!("Failed to receive auth code: {}", e))?; + + // 7. Parse Code from URL + // Actually we need to parse the query params from received_url_str + let parsed_url = Url::parse(&received_url_str).map_err(|e| e.to_string())?; + let pairs: std::collections::HashMap<_, _> = parsed_url.query_pairs().into_owned().collect(); + + if let Some(err) = pairs.get("error") { + let desc = pairs.get("error_description").map(|s| s.as_str()).unwrap_or("No description"); + return Err(format!("OAuth Error: {} ({})", err, desc)); + } + + let code = pairs.get("code").ok_or_else(|| format!("No code in redirect callback. Received URL: {}", received_url_str))?; + + // 8. Exchange Code for Token + let token_result = client + .exchange_code(oauth2::AuthorizationCode::new(code.clone())) + .set_pkce_verifier(pkce_verifier) + .request_async(async_http_client) + .await + .map_err(|e| format!("Failed to exchange token: {}", e))?; + + let access_token = token_result.access_token().secret(); + + // Save token? Or just return it. + // Ideally we save it in key storage, but for MVP return it. + + Ok(access_token.clone()) +} + +#[tauri::command] +pub async fn get_calendar_events(token: String) -> Result, String> { + let client = reqwest::Client::new(); + let res = client + .get("https://graph.microsoft.com/v1.0/me/calendarView") + .bearer_auth(token) + .query(&[ + ("startDateTime", chrono::Utc::now().to_rfc3339()), + ("endDateTime", (chrono::Utc::now() + chrono::Duration::days(7)).to_rfc3339()), + ("$select", "id,subject,start,end,location,onlineMeeting,bodyPreview,body,attendees".to_string()) + ]) + .header("Prefer", "outlook.timezone=\"UTC\"") + .send() + .await + .map_err(|e| e.to_string())? + .json::() + .await + .map_err(|e| e.to_string())?; + + // Extract 'value' array + if let Some(events) = res.get("value").and_then(|v| v.as_array()) { + Ok(events.clone()) + } else { + Ok(vec![]) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fce3a6c..5b2f3c7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,10 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use std::time::Duration; use tokio::time::sleep; +mod audio_processor; +use audio_processor::AudioProcessor; +mod auth; + // State to hold the active recording stream struct AppState { recording_stream: Mutex>, @@ -60,7 +64,7 @@ fn get_input_devices() -> Result, String> { #[tauri::command] -fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String, save_path: Option) -> Result<(), String> { +fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String, save_path: Option, custom_filename: Option) -> Result<(), String> { emit_log(&app, "INFO", &format!("Starting recording on device: {}", device_id)); let host = cpal::default_host(); @@ -73,6 +77,15 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String .ok_or("No input device found")?; let config = device.default_input_config().map_err(|e| e.to_string())?; + + // VAD requires 16Hz or 8kHz, typically. Silero likes 16k. + // We might need to resample or just check if the device supports it. + // For MVP VAD, we will try to stick to standard rates. + // Actually, simple energy VAD is easier to start with if Silero is too heavy or requires ONNX runtime. + // Let's check the crate docs or usage first. + // Wait, the user wants to IGNORE music. Energy VAD will fail on music. + // voice_activity_detector crate usually uses Silero or similar. + let spec = hound::WavSpec { channels: config.channels(), sample_rate: config.sample_rate(), @@ -81,16 +94,22 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String }; // Determine file path: User provided or Temp + let filename = if let Some(name) = custom_filename { + // Sanitize filename + let safe_name: String = name.chars().map(|x| if x.is_alphanumeric() || x == ' ' || x == '-' || x == '_' { x } else { '_' }).collect(); + format!("{}.wav", safe_name) + } else { + format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()) + }; + let file_path = if let Some(path) = save_path { if path.trim().is_empty() { - std::env::temp_dir().join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs())) + std::env::temp_dir().join(&filename) } else { - // Check if directory exists, if not try to create it or error out? - // For now, assume user gives a valid directory. We'll append filename. - std::path::PathBuf::from(path).join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs())) + std::path::PathBuf::from(path).join(&filename) } } else { - std::env::temp_dir().join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs())) + std::env::temp_dir().join(&filename) }; let file_path_str = file_path.to_string_lossy().to_string(); @@ -99,6 +118,19 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String let writer = hound::WavWriter::create(&file_path, spec).map_err(|e| e.to_string())?; let writer = Arc::new(Mutex::new(writer)); let writer_clone = writer.clone(); + + // Initialize AudioProcessor (VAD) + // We pass the writer to it. + let processor = AudioProcessor::new(config.sample_rate(), writer.clone(), app.clone()) + .map_err(|e| format!("Failed to create AudioProcessor: {}", e))?; + + // Wrap processor in Arc so we can share/move it into callback + // Actually, cpal callback takes ownership of its closure state usually if 'move'. + // Since stream is on another thread, we need Send. AudioProcessor should be Send. + // However, the callback is called repeatedly. We need to keep state. + // The workaround is to wrap it in a Mutex. + let processor = Arc::new(Mutex::new(processor)); + let processor_clone = processor.clone(); let app_handle = app.clone(); let err_fn = move |err| { @@ -110,21 +142,21 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String cpal::SampleFormat::F32 => device.build_input_stream( &config.into(), move |data: &[f32], _: &_| { - let mut guard = writer_clone.lock().unwrap(); - for &sample in data { - let amplitude = i16::MAX as f32; - guard.write_sample((sample * amplitude) as i16).ok(); + if let Ok(mut p) = processor_clone.lock() { + p.process(data); } }, err_fn, None ), + // For I16 and U16 we need to convert to F32 for our processor cpal::SampleFormat::I16 => device.build_input_stream( &config.into(), move |data: &[i16], _: &_| { - let mut guard = writer_clone.lock().unwrap(); - for &sample in data { - guard.write_sample(sample).ok(); + // Convert i16 to f32 + let f32_data: Vec = data.iter().map(|&s| s as f32 / i16::MAX as f32).collect(); + if let Ok(mut p) = processor_clone.lock() { + p.process(&f32_data); } }, err_fn, @@ -133,9 +165,10 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String cpal::SampleFormat::U16 => device.build_input_stream( &config.into(), move |data: &[u16], _: &_| { - let mut guard = writer_clone.lock().unwrap(); - for &sample in data { - guard.write_sample((sample as i32 - 32768) as i16).ok(); + // Convert u16 to f32 + let f32_data: Vec = data.iter().map(|&s| (s as i32 - 32768) as f32 / 32768.0).collect(); + if let Ok(mut p) = processor_clone.lock() { + p.process(&f32_data); } }, err_fn, @@ -536,6 +569,60 @@ fn open_audio_midi_setup() -> Result<(), String> { Ok(()) } +#[tauri::command] +fn create_hearbit_audio_device(app: AppHandle) -> Result { + emit_log(&app, "INFO", "Attempting to create Hearbit Audio device..."); + + // Resolve resource path + let resource_path = app.path().resource_dir() + .map_err(|e| e.to_string())? + .join("resources/create_hearbit_audio.swift"); + + if !resource_path.exists() { + // Fallback for dev environment where resources might not be bundled yet or different path + emit_log(&app, "WARN", &format!("Resource script not found at {:?}. Trying local src-tauri path.", resource_path)); + } + + // For now, in dev mode, we might need to point to the source location if bundle isn't active + // But let's try running it. + + let output = Command::new("swift") + .arg(resource_path) + .output() + .map_err(|e| e.to_string())?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + emit_log(&app, "DEBUG", &format!("Script Output: {}", stdout)); + if !stderr.is_empty() { + emit_log(&app, "WARN", &format!("Script Stderr: {}", stderr)); + } + + if output.status.success() { + emit_log(&app, "SUCCESS", "Hearbit Audio device created successfully."); + Ok("Device created successfully".to_string()) + } else { + emit_log(&app, "ERROR", "Failed to create device."); + Err(format!("Failed to create device: {} {}", stdout, stderr)) + } +} + +#[tauri::command] +async fn save_text_file(app: AppHandle, path: String, content: String) -> Result<(), String> { + emit_log(&app, "INFO", &format!("Saving text file to: {}", path)); + match std::fs::write(&path, content) { + Ok(_) => { + emit_log(&app, "SUCCESS", "File saved successfully."); + Ok(()) + }, + Err(e) => { + emit_log(&app, "ERROR", &format!("Failed to save file: {}", e)); + Err(e.to_string()) + } + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -543,6 +630,8 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_oauth::init()) .manage(AppState { recording_stream: Mutex::new(None), recording_file_path: Mutex::new(None), @@ -557,7 +646,11 @@ pub fn run() { transcribe_audio, summarize_text, get_available_models, - open_audio_midi_setup + open_audio_midi_setup, + create_hearbit_audio_device, + auth::start_auth_flow, + auth::get_calendar_events, + save_text_file ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 50d7380..332e2e9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -32,7 +32,7 @@ "icons/icon.ico" ], "resources": [ - "resources/BlackHole2ch.v0.6.1.pkg" + "resources/*" ] } } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 32f5aad..959d2ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,18 +6,37 @@ import Recorder from "./components/Recorder"; import LogViewer, { LogEntry } from "./components/LogViewer"; import TranscriptionView from "./components/TranscriptionView"; import Tabs from "./components/Tabs"; +import MeetingsView from "./components/MeetingsView"; +import HistoryView from "./components/HistoryView"; +import ToastContainer, { ToastMessage, ToastType } from "./components/ui/Toast"; export interface PromptTemplate { id: string; name: string; content: string; + keywords?: string[]; } function App() { - const [view, setView] = useState<'recorder' | 'logs' | 'settings' | 'transcription'>('recorder'); - // Keep track of the *previous* tab to return to from settings - const [lastTab, setLastTab] = useState<'recorder' | 'logs' | 'transcription'>('recorder'); + const [view, setView] = useState<'recorder' | 'logs' | 'settings' | 'transcription' | 'meetings' | 'history'>('recorder'); + const [lastTab, setLastTab] = useState<'recorder' | 'logs' | 'transcription' | 'meetings' | 'history'>('recorder'); + + // Auto-start recording state to handle "Join & Record" transition + const [autoStartRecording, setAutoStartRecording] = useState(false); + const [recordingSubject, setRecordingSubject] = useState(''); + + // Toast State + const [toasts, setToasts] = useState([]); + + const addToast = (message: string, type: ToastType = 'info', duration = 3000) => { + const id = Date.now().toString() + Math.random().toString(); + setToasts(prev => [...prev, { id, message, type, duration }]); + }; + + const removeToast = (id: string) => { + setToasts(prev => prev.filter(t => t.id !== id)); + }; const [apiKey, setApiKey] = useState(localStorage.getItem('infomaniak_api_key') || ''); const [productId, setProductId] = useState(localStorage.getItem('infomaniak_product_id') || ''); const [savePath, setSavePath] = useState(localStorage.getItem('infomaniak_save_path') || ''); @@ -61,7 +80,8 @@ Kurze Stichpunkte zu Themen, die besprochen, aber noch nicht final geklĂ€rt wurd | [Aufgabe 2] | [Name] | [Datum] | ## 5. NĂ€chste Schritte / NĂ€chstes Meeting -Kurze Info zum weiteren Vorgehen.` +Kurze Info zum weiteren Vorgehen.`, + keywords: ['protokoll', 'meeting', 'team', 'daily', 'weekly'] }, { id: '2', @@ -96,7 +116,8 @@ Thema B: [Kurze Zusammenfassung] | Wer? | Was ist zu tun / zu beachten? | Bis wann? | | :--- | :--- | :--- | | [Name] | [Aufgabe] | [Datum] | -| [Name] | [Aufgabe] | [Datum] |` +| [Name] | [Aufgabe] | [Datum] |`, + keywords: ['personal', 'privat', 'vertraulich', 'entwicklungsgesprĂ€ch', 'feedback', 'unter vier augen'] }, { id: '3', @@ -138,7 +159,8 @@ Teilnehmer: [Namen Kunden] & [Namen Intern] [ ] [Aufgabe, z.B. Zugangdaten senden, Design freigeben] (bis [Datum]) ## 5. NĂ€chster Termin / Timeline -Wann findet das nĂ€chste Meeting statt oder was ist der nĂ€chste Meilenstein?` +Wann findet das nĂ€chste Meeting statt oder was ist der nĂ€chste Meilenstein?`, + keywords: ['beratung', 'kunde', 'client', 'angebot', 'projekt', 'extern'] } ]; @@ -168,6 +190,8 @@ Wann findet das nĂ€chste Meeting statt oder was ist der nĂ€chste Meilenstein?` date: string; transcription: string; summary: string; + subject?: string; + filename?: string; } const [history, setHistory] = useState(() => { @@ -179,16 +203,39 @@ Wann findet das nĂ€chste Meeting statt oder was ist der nĂ€chste Meilenstein?` const transToSave = t !== undefined ? t : transcription; const sumToSave = s !== undefined ? s : summary; + // Sanitize subject for filename + const safeSubject = recordingSubject + ? recordingSubject.replace(/[^a-zA-Z0-9_-]/g, '_') + : `Meeting_${Date.now()}`; + const filename = `${safeSubject}.md`; + if (!transToSave && !sumToSave) return; + const newItem: HistoryItem = { id: Date.now().toString(), date: new Date().toLocaleString(), transcription: transToSave, - summary: sumToSave + summary: sumToSave, + subject: recordingSubject || "Untitled Recording", + filename: filename }; const newHistory = [newItem, ...history]; setHistory(newHistory); localStorage.setItem('infomaniak_history', JSON.stringify(newHistory)); + + // Persist to Disk (Markdown) + const content = `# ${newItem.subject}\nDate: ${newItem.date}\n\n## Summary\n${sumToSave}\n\n## Transcription\n${transToSave}`; + // If savePath is set, we use it. If not, backend defaults to temp. Here we want to save text. + // Let's assume savePath is set or we default to Documents/Hearbit (if we could). + // For now, if savePath is set, use it. + if (savePath) { + // We need invoke to save text + import("@tauri-apps/api/core").then(({ invoke }) => { + invoke('save_text_file', { path: `${savePath}/${filename}`, content }) + .then(() => addToast('Transcript saved to file', 'success')) + .catch(e => addToast(`Failed to save file: ${e}`, 'error')); + }); + } }; const handleDeleteHistory = (id: string) => { @@ -200,7 +247,7 @@ Wann findet das nĂ€chste Meeting statt oder was ist der nĂ€chste Meilenstein?` const handleLoadHistory = (item: HistoryItem) => { setTranscription(item.transcription); setSummary(item.summary); - setView('recorder'); // Ensure we go back to recorder to see it + setView('transcription'); // Switch to Transcription view to see content }; // Logs State @@ -224,7 +271,7 @@ Wann findet das nĂ€chste Meeting statt oder was ist der nĂ€chste Meilenstein?`
setView(t)} /> )} -
- {view === 'recorder' && ( - { - setLastTab('recorder'); - setView('settings'); - }} - transcription={transcription} - setTranscription={setTranscription} - summary={summary} - setSummary={setSummary} - history={history} - onSaveToHistory={handleSaveToHistory} - onDeleteHistory={handleDeleteHistory} - onLoadHistory={handleLoadHistory} - savePath={savePath} - onRecordingComplete={() => setView('transcription')} - /> - )} +
+
+ {view === 'recorder' && ( + { + setLastTab('recorder'); + setView('settings'); + }} + transcription={transcription} + setTranscription={setTranscription} + summary={summary} + setSummary={setSummary} + history={history} + onSaveToHistory={handleSaveToHistory} + onDeleteHistory={handleDeleteHistory} + onLoadHistory={handleLoadHistory} + savePath={savePath} - {view === 'transcription' && ( - - )} + onRecordingComplete={() => setView('transcription')} + autoStart={autoStartRecording} + recordingSubject={recordingSubject} + onAutoStartHandled={() => setAutoStartRecording(false)} + addToast={addToast} + /> + )} - {view === 'logs' && ( - - )} + {view === 'transcription' && ( + + )} - {view === 'settings' && ( - setView(lastTab)} - apiKey={apiKey} - productId={productId} - prompts={prompts} - savePath={savePath} - /> - )} + {view === 'history' && ( + + )} + + {view === 'meetings' && ( + { + setView('recorder'); + setRecordingSubject(subject || ''); + setAutoStartRecording(true); + }} + /> + )} + + {view === 'logs' && ( + + )} + + {view === 'settings' && ( + setView(lastTab)} + apiKey={apiKey} + productId={productId} + prompts={prompts} + savePath={savePath} + /> + )} +
+
); diff --git a/src/components/HistoryView.tsx b/src/components/HistoryView.tsx new file mode 100644 index 0000000..59cac57 --- /dev/null +++ b/src/components/HistoryView.tsx @@ -0,0 +1,67 @@ +import { FileText, Trash2, Calendar } from 'lucide-react'; + +interface HistoryItem { + id: string; + date: string; + transcription: string; // This might be raw text or path? + summary: string; + subject?: string; + filename?: string; +} + +interface HistoryViewProps { + history: HistoryItem[]; + onLoad: (item: HistoryItem) => void; + onDelete: (id: string) => void; +} + +export default function HistoryView({ history, onLoad, onDelete }: HistoryViewProps) { + return ( +
+

+ + Recording History +

+ + {history.length === 0 ? ( +
+ +

No history found.

+
+ ) : ( +
+ {history.map(item => ( +
+
+
onLoad(item)} + > +

+ {item.subject || "Untitled Recording"} +

+
+ + {item.date} + {item.filename && {item.filename}} +
+

+ {item.summary ? item.summary.substring(0, 150) + "..." : "No summary available."} +

+
+ + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/MeetingsView.tsx b/src/components/MeetingsView.tsx new file mode 100644 index 0000000..e0169e3 --- /dev/null +++ b/src/components/MeetingsView.tsx @@ -0,0 +1,289 @@ +import { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { Calendar, RefreshCw, LogIn, Video } from 'lucide-react'; +import { openUrl } from '@tauri-apps/plugin-opener'; + +interface CalendarEvent { + id: string; + subject: string; + start: { dateTime: string, timeZone: string }; + end: { dateTime: string, timeZone: string }; + onlineMeeting?: { joinUrl: string }; + location?: { displayName: string }; + bodyPreview?: string; // Text preview + body?: { content: string, contentType: string }; // Full HTML/Text + attendees?: { emailAddress: { name: string, address: string }, type: string, status: { response: string } }[]; +} + +interface MeetingsViewProps { + onStartRecording: (subject?: string) => void; +} + +export default function MeetingsView({ onStartRecording }: MeetingsViewProps) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [token, setToken] = useState(localStorage.getItem('m365_token') || ''); + const [clientId, setClientId] = useState(localStorage.getItem('m365_client_id') || ''); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const toggleExpand = (id: string) => { + const newSet = new Set(expandedIds); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + setExpandedIds(newSet); + }; + + useEffect(() => { + if (token) { + setIsAuthenticated(true); + fetchEvents(token); + } + }, [token]); + + const handleLogin = async () => { + if (!clientId) { + setError("Please enter a Client ID"); + return; + } + localStorage.setItem('m365_client_id', clientId); + + setLoading(true); + setError(''); + try { + const accessToken = await invoke('start_auth_flow', { clientId }); + setToken(accessToken); + localStorage.setItem('m365_token', accessToken); + setIsAuthenticated(true); + fetchEvents(accessToken); + } catch (err) { + console.error("Auth failed", err); + setError(String(err)); // Use String() to safely convert error object + } finally { + setLoading(false); + } + }; + + const fetchEvents = async (authToken: string) => { + setLoading(true); + setError(''); + try { + const data = await invoke('get_calendar_events', { token: authToken }); + // Sort by start time + const sorted = data.sort((a, b) => new Date(a.start.dateTime).getTime() - new Date(b.start.dateTime).getTime()); + setEvents(sorted); + } catch (err) { + console.error("Fetch failed", err); + setError(`Fetch failed: ${err}`); + // If error is 401, logout + if (String(err).includes('401')) { + logout(); + } + } finally { + setLoading(false); + } + }; + + const logout = () => { + setToken(''); + localStorage.removeItem('m365_token'); + setIsAuthenticated(false); + setEvents([]); + }; + + const handleJoin = async (joinUrl?: string, subject?: string) => { + if (!joinUrl) return; + try { + // 1. Open URL + await openUrl(joinUrl); + // 2. Start Recording (wait a sec for app focus switch?) + // Actually user might want to confirm recording? Protocol says "one-click". + onStartRecording(subject); + } catch (e) { + console.error("Failed to join", e); + } + }; + + const formatTime = (isoString: string) => { + return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + const formatDate = (isoString: string) => { + const date = new Date(isoString); + const today = new Date(); + if (date.toDateString() === today.toDateString()) return "Today"; + return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }); + }; + + return ( +
+

+ + Upcoming Meetings +

+ + {/* Auth Section */} + {!isAuthenticated ? ( +
+
+ +

Connect Microsoft 365

+

+ Connect your account to see upcoming Teams & Zoom meetings and join them with one click. +

+ +
+ setClientId(e.target.value)} + className="text-sm p-2 rounded border border-input bg-background w-full" + /> + + +
+ + {error && ( +
+ Error: {error} +
+ )} + +

+ Note: Requires an Azure App Registration (Multitenant) with redirect URI:
+ http://localhost:14200/auth/callback +

+
+
+ ) : ( +
+
+ Next 7 Days +
+ + +
+
+ + {events.length === 0 && !loading && ( +
+ {/* No meetings empty state (only if no error) */} + +

No upcoming meetings found for the next 7 days.

+
+ )} + + {error && ( +
+ {error} + +
+ )} + +
+ {events.map(event => ( +
+
+
+
+ + {formatDate(event.start.dateTime)} + + + {formatTime(event.start.dateTime)} + +
+

+ {event.subject} +

+ {event.location?.displayName && ( +
+ 📍 {event.location.displayName} +
+ )} +
+ + {event.onlineMeeting?.joinUrl ? ( + + ) : ( +
+ No online link +
+ )} +
+ + {/* Expand/Collapse Button */} + + + {/* Expanded Content */} + {expandedIds.has(event.id) && ( +
+ {event.body?.content ? ( +
+ ) : ( +

{event.bodyPreview || "No details available."}

+ )} + + {event.attendees && event.attendees.length > 0 && ( +
+

+ đŸ‘„ Attendees + + {event.attendees.length} + +

+
+ {event.attendees.map((att, i) => ( +
+
+ + {att.emailAddress.name || att.emailAddress.address} + +
+ ))} +
+
+ )} +
+ )} +
+ ))} +
+
+ )} +
+ ); +} + diff --git a/src/components/Recorder.tsx b/src/components/Recorder.tsx index 659f7c3..53d58ca 100644 --- a/src/components/Recorder.tsx +++ b/src/components/Recorder.tsx @@ -1,12 +1,14 @@ import React, { useState, useEffect } from 'react'; -import { Mic, Square } from 'lucide-react'; +import { Mic, Square, Users, Headphones } from 'lucide-react'; import { invoke } from "@tauri-apps/api/core"; +import { listen } from '@tauri-apps/api/event'; import logo from '../assets/logo.png'; // Import logo interface PromptTemplate { id: string; name: string; content: string; + keywords?: string[]; } interface HistoryItem { @@ -32,7 +34,12 @@ interface RecorderProps { onDeleteHistory: (id: string) => void; onLoadHistory: (item: HistoryItem) => void; savePath: string | null; + onRecordingComplete: () => void; + autoStart?: boolean; + recordingSubject?: string; + onAutoStartHandled?: () => void; + addToast: (msg: string, type: 'success' | 'error' | 'info', duration?: number) => void; } interface AudioDevice { @@ -43,7 +50,8 @@ interface AudioDevice { const Recorder: React.FC = ({ apiKey, productId, prompts, setTranscription, setSummary, - onSaveToHistory, savePath, onRecordingComplete + onSaveToHistory, savePath, onRecordingComplete, + onOpenSettings, addToast, ...props }) => { const [isRecording, setIsRecording] = useState(false); const [isPaused, setIsPaused] = useState(false); @@ -51,8 +59,17 @@ const Recorder: React.FC = ({ const [selectedDevice, setSelectedDevice] = useState(''); const [selectedPromptId, setSelectedPromptId] = useState(''); const [selectedModel, setSelectedModel] = useState('mixtral'); + const [recordingMode, setRecordingMode] = useState<'voice' | 'meeting'>('voice'); const [devices, setDevices] = useState([]); const [availableModels, setAvailableModels] = useState>([]); + const [lastSpeechTime, setLastSpeechTime] = useState(Date.now()); + const [silenceDuration, setSilenceDuration] = useState(0); + + // Filtered devices based on mode + const filteredDevices = devices.filter(d => { + const isVirtual = d.name.toLowerCase().includes('hearbit') || d.name.toLowerCase().includes('blackhole'); + return recordingMode === 'meeting' ? isVirtual : !isVirtual; + }); useEffect(() => { loadDevices(); @@ -95,12 +112,21 @@ const Recorder: React.FC = ({ setDevices(aliasedDevs); // Select Hearbit mic by default if available and no selection made + // Smart Auto-select based on mode if (!selectedDevice) { - const vb = aliasedDevs.find(d => d.name.includes('Hearbit Virtual Mic')); - if (vb) { - setSelectedDevice(vb.id); - } else if (aliasedDevs.length > 0) { - setSelectedDevice(aliasedDevs[0].id); + // Prioritize "Hearbit Audio" (Aggregate) over "Hearbit Virtual Mic" (BlackHole) + const aggregateDev = aliasedDevs.find(d => d.name === 'Hearbit Audio'); + const virtualDev = aliasedDevs.find(d => d.name.includes('Hearbit Virtual')); + + if (aggregateDev) { + setRecordingMode('meeting'); + setSelectedDevice(aggregateDev.id); + } else if (virtualDev) { + setRecordingMode('meeting'); + setSelectedDevice(virtualDev.id); + } else { + setRecordingMode('voice'); + if (aliasedDevs.length > 0) setSelectedDevice(aliasedDevs[0].id); } } } catch (e) { @@ -113,26 +139,114 @@ const Recorder: React.FC = ({ await invoke('open_audio_midi_setup'); } catch (e) { console.error(e); + addToast('Failed to open Audio Setup', 'error'); setStatus('Failed to open Audio Setup'); } }; - const startRecording = async () => { + const startRecording = async (deviceIdOverride?: string) => { try { setStatus('Starting...'); - await invoke('start_recording', { deviceId: selectedDevice, savePath: savePath || null }); + setStatus('Starting...'); + // Check override or state + const targetDeviceId = deviceIdOverride || selectedDevice; + + // Pass customFilename (camelCase key maps to snake_case in Rust automatically or we need to check Tauri mapping, usually it maps camel to camel? Rust expects snake. Let's use snake_case in invoke args to be safe) + await invoke('start_recording', { deviceId: targetDeviceId, savePath: savePath || null, customFilename: props.recordingSubject || null }); setIsRecording(true); setIsPaused(false); setTranscription(''); setSummary(''); setStatus('Recording...'); + addToast('Recording started', 'success', 2000); } catch (e) { console.error(e); setStatus(`Error: ${e}`); + addToast(`Error starting recording: ${e}`, 'error'); setIsRecording(false); } }; + // VAD & Auto-Stop Logic + useEffect(() => { + let unlisten: () => void; + + const setupListener = async () => { + unlisten = await listen<{ is_speech: boolean, probability: number }>('vad-event', (event) => { + if (event.payload.is_speech) { + setLastSpeechTime(Date.now()); + setSilenceDuration(0); + } + }); + }; + + if (isRecording && !isPaused) { + setupListener(); + setLastSpeechTime(Date.now()); // Reset on start + } + + const interval = setInterval(() => { + if (isRecording && !isPaused) { + const diff = (Date.now() - lastSpeechTime) / 1000; + setSilenceDuration(diff); + + // Auto-stop after 30 seconds of silence + if (diff > 30) { // 30 seconds + console.log("Auto-stopping due to silence"); + setStatus("Auto-stopping (Silence detected)..."); + stopRecording(); + } + } + }, 1000); + + return () => { + if (unlisten) unlisten(); + clearInterval(interval); + }; + }, [isRecording, isPaused, lastSpeechTime]); + + // Handle Auto Start Prop + useEffect(() => { + if (props.autoStart && !isRecording && devices.length > 0) { + // Force meeting mode for auto-joins + if (recordingMode !== 'meeting') { + setRecordingMode('meeting'); + } + + // Find best device (Race condition fix: we can't rely on selectedDevice state update being instant) + const aggregateDev = devices.find(d => d.name === 'Hearbit Audio'); + const virtualDev = devices.find(d => d.name.includes('Hearbit Virtual')); + const bestDevice = aggregateDev || virtualDev; + + if (bestDevice) { + setSelectedDevice(bestDevice.id); // Update UI state for consistency + console.log("Auto-starting with device:", bestDevice.name); + startRecording(bestDevice.id); // Pass ID directly + } else { + console.warn("Auto-start: No meeting device found, trying default."); + startRecording(); + } + + if (props.onAutoStartHandled) { + props.onAutoStartHandled(); + } + } + }, [props.autoStart, devices]); + + // Handle Custom Event (Legacy/Fallback) + useEffect(() => { + const handleStartReq = () => { + if (!isRecording) { + if (recordingMode !== 'meeting') { + setRecordingMode('meeting'); + } + startRecording(); + } + }; + window.addEventListener('start-recording-req', handleStartReq); + return () => window.removeEventListener('start-recording-req', handleStartReq); + }, [isRecording, recordingMode]); + const togglePause = async () => { try { if (isPaused) { @@ -172,8 +286,40 @@ const Recorder: React.FC = ({ return; } - // Find selected prompt content - const activePrompt = prompts.find(p => p.id === selectedPromptId); + // Find selected prompt content - SMART SELECTION + let activePrompt = prompts.find(p => p.id === selectedPromptId); + + // Smart Auto-Select based on keywords + const lowerText = transText.toLowerCase(); + let bestMatchId = selectedPromptId; + let maxMatches = 0; + + for (const p of prompts) { + if (!p.keywords) continue; + let matches = 0; + for (const kw of p.keywords) { + if (lowerText.includes(kw.toLowerCase())) { + matches++; + } + } + if (matches > maxMatches) { + maxMatches = matches; + bestMatchId = p.id; + } + } + + if (bestMatchId !== selectedPromptId) { + const newPrompt = prompts.find(p => p.id === bestMatchId); + if (newPrompt) { + console.log(`Smart Select: Switched to '${newPrompt.name}' with ${maxMatches} matches.`); + setStatus(`Smart Select: Using "${newPrompt.name}"...`); + addToast(`Smart Select: Switched to "${newPrompt.name}"`, 'success', 4000); + activePrompt = newPrompt; + // Optional: Update UI selection? setSelectedPromptId(bestMatchId); + // Let's verify with user preference? For now, we override as "Magic". + } + } + const promptContent = activePrompt ? activePrompt.content : "Summarize this."; setStatus(`Summarizing (${selectedModel})...`); @@ -190,11 +336,13 @@ const Recorder: React.FC = ({ onSaveToHistory(transText, sumText); setStatus('Done!'); + addToast('Transcription & Summary complete!', 'success', 4000); onRecordingComplete(); // Auto-switch tab setTimeout(() => setStatus('Ready to record'), 3000); } catch (e) { console.error(e); setStatus(`Error: ${e}`); + addToast(`Error processing: ${e}`, 'error'); } }; @@ -227,12 +375,17 @@ const Recorder: React.FC = ({

{status} + {isRecording && !isPaused && silenceDuration > 10 && ( + + Silence detected: {Math.floor(silenceDuration)}s (Auto-stop in {90 - Math.floor(silenceDuration)}s) + + )}

{!isRecording ? ( +
+ + +
+ +
+ {recordingMode === 'meeting' && filteredDevices.length === 0 && ( + + )}
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 3b91d26..9c90023 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; import { Save, FolderOpen, Lock, Upload, Download, Eye, EyeOff } from 'lucide-react'; -import { open } from '@tauri-apps/plugin-dialog'; +import { save, open } from '@tauri-apps/plugin-dialog'; +import { writeTextFile } from '@tauri-apps/plugin-fs'; +import { invoke } from '@tauri-apps/api/core'; import { encryptData, decryptData } from '../utils/backup'; import { PromptTemplate } from '../App'; @@ -72,16 +74,19 @@ const Settings: React.FC = ({ apiKey, productId, prompts, savePat savePath: localSavePath }; const encrypted = await encryptData(data, backupPassword); - const blob = new Blob([encrypted], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `hearbit_backup_${new Date().toISOString().slice(0, 10)}.conf`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - setStatusIdx('Configuration exported successfully!'); + + const filePath = await save({ + defaultPath: `hearbit_backup_${new Date().toISOString().slice(0, 10)}.conf`, + filters: [{ + name: 'Hearbit Config', + extensions: ['conf'] + }] + }); + + if (filePath) { + await writeTextFile(filePath, encrypted); + setStatusIdx(`Configuration exported to: ${filePath}`); + } } catch (e) { console.error(e); setStatusIdx('Export failed.'); @@ -131,6 +136,17 @@ const Settings: React.FC = ({ apiKey, productId, prompts, savePat } }; + const handleCreateDevice = async () => { + try { + setStatusIdx('Creating Hearbit Audio device...'); + await invoke('create_hearbit_audio_device'); + setStatusIdx('Success! "Hearbit Audio" device created.'); + } catch (e) { + console.error(e); + setStatusIdx(`Error: ${e}`); + } + }; + return (
{/* Import Password Modal */} @@ -179,7 +195,7 @@ const Settings: React.FC = ({ apiKey, productId, prompts, savePat
Settings -
@@ -220,13 +236,25 @@ const Settings: React.FC = ({ apiKey, productId, prompts, savePat />
+
+ +

+ For automatic recording in Teams, create a virtual device combining your Mic and computer audio. +

+ +
@@ -256,13 +284,13 @@ const Settings: React.FC = ({ apiKey, productId, prompts, savePat
@@ -279,7 +307,7 @@ const Settings: React.FC = ({ apiKey, productId, prompts, savePat

Prompts

-
diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx index 0e55108..4ed74ea 100644 --- a/src/components/Tabs.tsx +++ b/src/components/Tabs.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Mic, Terminal, FileText } from 'lucide-react'; +import { Mic, Terminal, FileText, Calendar } from 'lucide-react'; + interface TabsProps { - currentTab: 'recorder' | 'logs' | 'transcription' | 'settings'; - onTabChange: (tab: 'recorder' | 'logs' | 'transcription' | 'settings') => void; + currentTab: 'recorder' | 'logs' | 'transcription' | 'settings' | 'meetings' | 'history'; + onTabChange: (tab: 'recorder' | 'logs' | 'transcription' | 'settings' | 'meetings' | 'history') => void; } const Tabs: React.FC = ({ currentTab, onTabChange }) => { @@ -22,6 +23,20 @@ const Tabs: React.FC = ({ currentTab, onTabChange }) => { Transcription + + +
+ ); +}; + +export const ToastContainer: React.FC<{ toasts: ToastMessage[], removeToast: (id: string) => void }> = ({ toasts, removeToast }) => { + return ( +
+
+ {toasts.map(t => ( + + ))} +
+
+ ); +}; + +export default ToastContainer;