diff --git a/bun.lockb b/bun.lockb index 53f8db2..fe07be3 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..057a9c7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1273 @@ +{ + "name": "handy-app", + "version": "0.3.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "handy-app", + "version": "0.3.1", + "dependencies": { + "@tailwindcss/vite": "^4.0.2", + "@tauri-apps/api": "^2.6.0", + "@tauri-apps/plugin-autostart": "~2", + "@tauri-apps/plugin-clipboard-manager": "~2", + "@tauri-apps/plugin-fs": "^2.4.1", + "@tauri-apps/plugin-global-shortcut": "~2", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-os": "~2", + "@tauri-apps/plugin-process": "~2", + "@tauri-apps/plugin-store": "~2", + "@tauri-apps/plugin-stronghold": "~2", + "@tauri-apps/plugin-updater": "~2", + "@tauri-apps/plugin-upload": "~2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwindcss": "^4.0.2", + "tauri-plugin-macos-permissions-api": "^2.0.4", + "zod": "^3.24.4" + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "@types/node": "^24.0.6", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.6.2", + "vite": "^6.0.3" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.5", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.7", + "@babel/parser": "^7.26.7", + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.26.7", + "@babel/types": "^7.26.7", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.5", + "@babel/types": "^7.26.5", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.26.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.26.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.26.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.26.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.5", + "@babel/parser": "^7.26.7", + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.7", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.32.1", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "enhanced-resolve": "^5.18.0", + "jiti": "^2.4.2", + "tailwindcss": "4.0.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.0.2", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.0.2", + "@tailwindcss/oxide-darwin-arm64": "4.0.2", + "@tailwindcss/oxide-darwin-x64": "4.0.2", + "@tailwindcss/oxide-freebsd-x64": "4.0.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.0.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.0.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.0.2", + "@tailwindcss/oxide-linux-x64-musl": "4.0.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.0.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.0.2" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.0.2", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "^4.0.2", + "@tailwindcss/oxide": "^4.0.2", + "lightningcss": "^1.29.1", + "tailwindcss": "4.0.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.6.0", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.2.7", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.2.7", + "@tauri-apps/cli-darwin-x64": "2.2.7", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.2.7", + "@tauri-apps/cli-linux-arm64-gnu": "2.2.7", + "@tauri-apps/cli-linux-arm64-musl": "2.2.7", + "@tauri-apps/cli-linux-x64-gnu": "2.2.7", + "@tauri-apps/cli-linux-x64-musl": "2.2.7", + "@tauri-apps/cli-win32-arm64-msvc": "2.2.7", + "@tauri-apps/cli-win32-ia32-msvc": "2.2.7", + "@tauri-apps/cli-win32-x64-msvc": "2.2.7" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.2.7", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-autostart": { + "version": "2.2.0", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, + "node_modules/@tauri-apps/plugin-autostart/node_modules/@tauri-apps/api": { + "version": "2.2.0", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-clipboard-manager": { + "version": "2.2.1", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, + "node_modules/@tauri-apps/plugin-clipboard-manager/node_modules/@tauri-apps/api": { + "version": "2.2.0", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-fs": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.1.tgz", + "integrity": "sha512-vJlKZVGF3UAFGoIEVT6Oq5L4HGDCD78WmA4uhzitToqYiBKWAvZR61M6zAyQzHqLs0ADemkE4RSy/5sCmZm6ZQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.6.0" + } + }, + "node_modules/@tauri-apps/plugin-global-shortcut": { + "version": "2.2.0", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, + "node_modules/@tauri-apps/plugin-global-shortcut/node_modules/@tauri-apps/api": { + "version": "2.2.0", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.2.5", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, + "node_modules/@tauri-apps/plugin-opener/node_modules/@tauri-apps/api": { + "version": "2.2.0", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-os": { + "version": "2.2.1", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, + "node_modules/@tauri-apps/plugin-os/node_modules/@tauri-apps/api": { + "version": "2.2.0", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-process": { + "version": "2.3.0", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.6.0" + } + }, + "node_modules/@tauri-apps/plugin-store": { + "version": "2.2.0", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, + "node_modules/@tauri-apps/plugin-store/node_modules/@tauri-apps/api": { + "version": "2.2.0", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-stronghold": { + "version": "2.2.0", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, + "node_modules/@tauri-apps/plugin-stronghold/node_modules/@tauri-apps/api": { + "version": "2.2.0", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.9.0", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.6.0" + } + }, + "node_modules/@tauri-apps/plugin-updater/node_modules/@tauri-apps/api": { + "version": "2.6.0", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-upload": { + "version": "2.2.1", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, + "node_modules/@tauri-apps/plugin-upload/node_modules/@tauri-apps/api": { + "version": "2.2.0", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.0.10", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.14", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.18", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.5", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.24.4", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001696", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.90", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.0", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.4.2", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.29.1", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^1.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.29.1", + "lightningcss-darwin-x64": "1.29.1", + "lightningcss-freebsd-x64": "1.29.1", + "lightningcss-linux-arm-gnueabihf": "1.29.1", + "lightningcss-linux-arm64-gnu": "1.29.1", + "lightningcss-linux-arm64-musl": "1.29.1", + "lightningcss-linux-x64-gnu": "1.29.1", + "lightningcss-linux-x64-musl": "1.29.1", + "lightningcss-win32-arm64-msvc": "1.29.1", + "lightningcss-win32-x64-msvc": "1.29.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.29.1", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.8", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.1", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.32.1", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.32.1", + "@rollup/rollup-android-arm64": "4.32.1", + "@rollup/rollup-darwin-arm64": "4.32.1", + "@rollup/rollup-darwin-x64": "4.32.1", + "@rollup/rollup-freebsd-arm64": "4.32.1", + "@rollup/rollup-freebsd-x64": "4.32.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.32.1", + "@rollup/rollup-linux-arm-musleabihf": "4.32.1", + "@rollup/rollup-linux-arm64-gnu": "4.32.1", + "@rollup/rollup-linux-arm64-musl": "4.32.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.32.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.32.1", + "@rollup/rollup-linux-riscv64-gnu": "4.32.1", + "@rollup/rollup-linux-s390x-gnu": "4.32.1", + "@rollup/rollup-linux-x64-gnu": "4.32.1", + "@rollup/rollup-linux-x64-musl": "4.32.1", + "@rollup/rollup-win32-arm64-msvc": "4.32.1", + "@rollup/rollup-win32-ia32-msvc": "4.32.1", + "@rollup/rollup-win32-x64-msvc": "4.32.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.0.2", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tauri-plugin-macos-permissions-api": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "@tauri-apps/api": ">=2.0.0-beta.6" + } + }, + "node_modules/tauri-plugin-macos-permissions-api/node_modules/@tauri-apps/api": { + "version": "2.2.0", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.2", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.0.11", + "license": "MIT", + "dependencies": { + "esbuild": "^0.24.2", + "postcss": "^8.4.49", + "rollup": "^4.23.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.24.4", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index 2c43f39..461d1c8 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@tauri-apps/api": "^2.6.0", "@tauri-apps/plugin-autostart": "~2", "@tauri-apps/plugin-clipboard-manager": "~2", + "@tauri-apps/plugin-fs": "~2", "@tauri-apps/plugin-global-shortcut": "~2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "~2", diff --git a/public/icon/recording.png b/public/icon/recording.png new file mode 100644 index 0000000..8839e84 Binary files /dev/null and b/public/icon/recording.png differ diff --git a/public/icon/transcribing.png b/public/icon/transcribing.png new file mode 100644 index 0000000..bb6fb25 Binary files /dev/null and b/public/icon/transcribing.png differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2ca528c..d9f0460 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2018,12 +2018,14 @@ dependencies = [ "reqwest 0.11.27", "rodio", "rubato", + "rustfft", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-autostart", "tauri-plugin-clipboard-manager", + "tauri-plugin-fs", "tauri-plugin-global-shortcut", "tauri-plugin-macos-permissions", "tauri-plugin-opener", @@ -4403,9 +4405,9 @@ dependencies = [ [[package]] name = "rustfft" -version = "6.2.0" +version = "6.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43806561bc506d0c5d160643ad742e3161049ac01027b5e6d7524091fd401d86" +checksum = "c6f140db74548f7c9d7cce60912c9ac414e74df5e718dc947d514b051b42f3f4" dependencies = [ "num-complex", "num-integer", @@ -4413,7 +4415,6 @@ dependencies = [ "primal-check", "strength_reduce", "transpose", - "version_check", ] [[package]] @@ -5450,6 +5451,28 @@ dependencies = [ "thiserror 2.0.11", ] +[[package]] +name = "tauri-plugin-fs" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6ef84ee2f2094ce093e55106d90d763ba343fad57566992962e8f76d113f99" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.11", + "toml", + "url", +] + [[package]] name = "tauri-plugin-global-shortcut" version = "2.2.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 79304c9..b86d834 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,7 +28,7 @@ tauri-build = { version = "2", features = [] } [dependencies] once_cell = "1" -tauri = { version = "2", features = ["tray-icon", 'image-png'] } +tauri = { version = "2", features = [ "macos-private-api", "tray-icon", 'image-png'] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -51,6 +51,8 @@ tauri-plugin-process = "2" rodio = { git = "https://github.com/cjpais/rodio.git" } reqwest = { version = "0.11", features = ["json", "stream"] } futures-util = "0.3" +tauri-plugin-fs = "2" +rustfft = "6.4.0" [dependencies.ort-sys] version = "=2.0.0-rc.9" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 91054d8..84593ed 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,8 +1,8 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window", - "windows": ["main"], + "description": "Capabilities for the app", + "windows": ["main", "recording_overlay"], "permissions": [ "core:default", "opener:default", @@ -13,6 +13,8 @@ "global-shortcut:allow-register", "global-shortcut:allow-unregister", "global-shortcut:allow-unregister-all", - "macos-permissions:default" + "macos-permissions:default", + "fs:read-files", + "fs:allow-resource-read-recursive" ] } diff --git a/src-tauri/gen/apple/PrivacyInfo.xcprivacy b/src-tauri/gen/apple/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..7caf423 --- /dev/null +++ b/src-tauri/gen/apple/PrivacyInfo.xcprivacy @@ -0,0 +1,17 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index f8a657f..53b2a08 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -5,6 +5,8 @@ use crate::utils; use crate::utils::change_tray_icon; use crate::utils::play_recording_start_sound; use crate::utils::play_recording_stop_sound; +use crate::utils::show_recording_overlay; +use crate::utils::show_transcribing_overlay; use crate::utils::TrayIconState; use log::debug; use once_cell::sync::Lazy; @@ -30,6 +32,7 @@ impl ShortcutAction for TranscribeAction { let binding_id = binding_id.to_string(); change_tray_icon(app, TrayIconState::Recording); + show_recording_overlay(app); let rm = app.state::>(); @@ -78,6 +81,7 @@ impl ShortcutAction for TranscribeAction { let tm = Arc::clone(&app.state::>()); change_tray_icon(app, TrayIconState::Transcribing); + show_transcribing_overlay(app); // Play audio feedback for recording stop play_recording_stop_sound(app); @@ -119,23 +123,29 @@ impl ShortcutAction for TranscribeAction { ), Err(e) => eprintln!("Failed to paste transcription: {}", e), } + // Hide the overlay after transcription is complete + utils::hide_recording_overlay(&ah_clone); change_tray_icon(&ah_clone, TrayIconState::Idle); }) .unwrap_or_else(|e| { eprintln!("Failed to run paste on main thread: {:?}", e); + utils::hide_recording_overlay(&ah); change_tray_icon(&ah, TrayIconState::Idle); }); } else { + utils::hide_recording_overlay(&ah); change_tray_icon(&ah, TrayIconState::Idle); } } Err(err) => { debug!("Global Shortcut Transcription error: {}", err); + utils::hide_recording_overlay(&ah); change_tray_icon(&ah, TrayIconState::Idle); - }, + } } } else { debug!("No samples retrieved from recording stop"); + utils::hide_recording_overlay(&ah); change_tray_icon(&ah, TrayIconState::Idle); } }); diff --git a/src-tauri/src/audio_toolkit/audio/mod.rs b/src-tauri/src/audio_toolkit/audio/mod.rs index e837b8e..83e307c 100644 --- a/src-tauri/src/audio_toolkit/audio/mod.rs +++ b/src-tauri/src/audio_toolkit/audio/mod.rs @@ -2,7 +2,9 @@ mod device; mod recorder; mod resampler; +mod visualizer; pub use device::{list_input_devices, list_output_devices, CpalDeviceInfo}; pub use recorder::AudioRecorder; pub use resampler::FrameResampler; +pub use visualizer::AudioVisualiser; diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index 3ec466d..a3fb159 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -10,7 +10,7 @@ use cpal::{ }; use crate::audio_toolkit::{ - audio::FrameResampler, + audio::{AudioVisualiser, FrameResampler}, constants, vad::{self, VadFrame}, VoiceActivityDetector, @@ -27,6 +27,7 @@ pub struct AudioRecorder { cmd_tx: Option>, worker_handle: Option>, vad: Option>>>, + level_cb: Option) + Send + Sync + 'static>>, } impl AudioRecorder { @@ -36,6 +37,7 @@ impl AudioRecorder { cmd_tx: None, worker_handle: None, vad: None, + level_cb: None, }) } @@ -44,6 +46,14 @@ impl AudioRecorder { self } + pub fn with_level_callback(mut self, cb: F) -> Self + where + F: Fn(Vec) + Send + Sync + 'static, + { + self.level_cb = Some(Arc::new(cb)); + self + } + pub fn open(&mut self, device: Option) -> Result<(), Box> { if self.worker_handle.is_some() { return Ok(()); // already open @@ -62,6 +72,8 @@ impl AudioRecorder { let thread_device = device.clone(); let vad = self.vad.clone(); + // Move the optional level callback into the worker thread + let level_cb = self.level_cb.clone(); let worker = std::thread::spawn(move || { let config = AudioRecorder::get_preferred_config(&thread_device) @@ -105,7 +117,7 @@ impl AudioRecorder { stream.play().expect("failed to start stream"); // keep the stream alive while we process samples - run_consumer(sample_rate, vad, sample_rx, cmd_rx); + run_consumer(sample_rate, vad, sample_rx, cmd_rx, level_cb); // stream is dropped here, after run_consumer returns }); @@ -215,6 +227,7 @@ fn run_consumer( vad: Option>>>, sample_rx: mpsc::Receiver>, cmd_rx: mpsc::Receiver, + level_cb: Option) + Send + Sync + 'static>>, ) { let mut frame_resampler = FrameResampler::new( in_sample_rate as usize, @@ -225,6 +238,17 @@ fn run_consumer( let mut processed_samples = Vec::::new(); let mut recording = false; + // ---------- spectrum visualisation setup ---------------------------- // + const BUCKETS: usize = 16; + const WINDOW_SIZE: usize = 512; + let mut visualizer = AudioVisualiser::new( + in_sample_rate, + WINDOW_SIZE, + BUCKETS, + 80.0, // vocal_min_hz + 4000.0, // vocal_max_hz + ); + fn handle_frame( samples: &[f32], recording: bool, @@ -252,6 +276,14 @@ fn run_consumer( Err(_) => break, // stream closed }; + // ---------- spectrum processing ---------------------------------- // + if let Some(buckets) = visualizer.feed(&raw) { + if let Some(cb) = &level_cb { + cb(buckets); + } + } + + // ---------- existing pipeline ------------------------------------ // frame_resampler.push(&raw, &mut |frame: &[f32]| { handle_frame(frame, recording, &vad, &mut processed_samples) }); @@ -262,6 +294,7 @@ fn run_consumer( Cmd::Start => { processed_samples.clear(); recording = true; + visualizer.reset(); // Reset visualization buffer if let Some(v) = &vad { v.lock().unwrap().reset(); } diff --git a/src-tauri/src/audio_toolkit/audio/visualizer.rs b/src-tauri/src/audio_toolkit/audio/visualizer.rs new file mode 100644 index 0000000..b0ef038 --- /dev/null +++ b/src-tauri/src/audio_toolkit/audio/visualizer.rs @@ -0,0 +1,156 @@ +use rustfft::{num_complex::Complex32, Fft, FftPlanner}; +use std::sync::Arc; + +const DB_MIN: f32 = -55.0; +const DB_MAX: f32 = -8.0; +const GAIN: f32 = 1.3; +const CURVE_POWER: f32 = 0.7; + +pub struct AudioVisualiser { + fft: Arc>, + window: Vec, + bucket_ranges: Vec<(usize, usize)>, + fft_input: Vec, + noise_floor: Vec, + buffer: Vec, + window_size: usize, + buckets: usize, +} + +impl AudioVisualiser { + pub fn new( + sample_rate: u32, + window_size: usize, + buckets: usize, + freq_min: f32, + freq_max: f32, + ) -> Self { + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(window_size); + + // Pre-compute Hann window + let window: Vec = (0..window_size) + .map(|i| { + 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / window_size as f32).cos()) + }) + .collect(); + + // Pre-compute bucket frequency ranges + let nyquist = sample_rate as f32 / 2.0; + let freq_min = freq_min.min(nyquist); + let freq_max = freq_max.min(nyquist); + + let mut bucket_ranges = Vec::with_capacity(buckets); + + for b in 0..buckets { + // Use logarithmic spacing for better perceptual representation + let log_start = (b as f32 / buckets as f32).powi(2); + let log_end = ((b + 1) as f32 / buckets as f32).powi(2); + + let start_hz = freq_min + (freq_max - freq_min) * log_start; + let end_hz = freq_min + (freq_max - freq_min) * log_end; + + let start_bin = ((start_hz * window_size as f32) / sample_rate as f32) as usize; + let mut end_bin = ((end_hz * window_size as f32) / sample_rate as f32) as usize; + + // Ensure each bucket has at least one bin + if end_bin <= start_bin { + end_bin = start_bin + 1; + } + + // Clamp to valid range + let start_bin = start_bin.min(window_size / 2); + let end_bin = end_bin.min(window_size / 2); + + bucket_ranges.push((start_bin, end_bin)); + } + + Self { + fft, + window, + bucket_ranges, + fft_input: vec![Complex32::new(0.0, 0.0); window_size], + noise_floor: vec![-40.0; buckets], // Initialize to reasonable noise floor + buffer: Vec::with_capacity(window_size * 2), + window_size, + buckets, + } + } + + pub fn feed(&mut self, samples: &[f32]) -> Option> { + // Add new samples to buffer + self.buffer.extend_from_slice(samples); + + // Only process if we have enough samples + if self.buffer.len() < self.window_size { + return None; + } + + // Take the required window of samples + let window_samples = &self.buffer[..self.window_size]; + + // Remove DC component + let mean = window_samples.iter().sum::() / self.window_size as f32; + + // Apply window function and prepare FFT input + for (i, &sample) in window_samples.iter().enumerate() { + let windowed_sample = (sample - mean) * self.window[i]; + self.fft_input[i] = Complex32::new(windowed_sample, 0.0); + } + + // Perform FFT + self.fft.process(&mut self.fft_input); + + // Compute power spectrum and bucket levels + let mut buckets = vec![0.0; self.buckets]; + + for (bucket_idx, &(start_bin, end_bin)) in self.bucket_ranges.iter().enumerate() { + if start_bin >= end_bin || end_bin > self.fft_input.len() / 2 { + continue; + } + + // Calculate average power in this frequency range + let mut power_sum = 0.0; + for bin_idx in start_bin..end_bin { + let magnitude = self.fft_input[bin_idx].norm(); + power_sum += magnitude * magnitude; + } + + let avg_power = power_sum / (end_bin - start_bin) as f32; + + // Convert to dB with proper scaling + let db = if avg_power > 1e-12 { + 20.0 * (avg_power.sqrt() / self.window_size as f32).log10() + } else { + -80.0 // Very low floor for zero power + }; + + // Only update noise floor when signal is quiet (below current floor + 10dB) + if db < self.noise_floor[bucket_idx] + 10.0 { + const NOISE_ALPHA: f32 = 0.001; // Very slow adaptation + self.noise_floor[bucket_idx] = + NOISE_ALPHA * db + (1.0 - NOISE_ALPHA) * self.noise_floor[bucket_idx]; + } + + // Map configurable dB range to 0-1 with gain and curve shaping + let normalized = ((db - DB_MIN) / (DB_MAX - DB_MIN)).clamp(0.0, 1.0); + buckets[bucket_idx] = (normalized * GAIN).powf(CURVE_POWER).clamp(0.0, 1.0); + } + + // Apply light smoothing to reduce jitter + for i in 1..buckets.len() - 1 { + buckets[i] = buckets[i] * 0.7 + buckets[i - 1] * 0.15 + buckets[i + 1] * 0.15; + } + + // Clear processed samples from buffer + self.buffer.clear(); + + Some(buckets) + } + + pub fn reset(&mut self) { + self.buffer.clear(); + // Reset noise floor to initial values + self.noise_floor.fill(-40.0); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eb8e6fe..1cb6529 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -60,6 +60,7 @@ pub fn run() { env_logger::init(); tauri::Builder::default() + .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_os::init()) @@ -126,6 +127,9 @@ pub fn run() { app.manage(model_manager.clone()); app.manage(transcription_manager.clone()); + // Create the recording overlay window (hidden by default) + utils::create_recording_overlay(&app.handle()); + shortcut::init_shortcuts(app); Ok(()) @@ -153,6 +157,7 @@ pub fn run() { shortcut::change_audio_feedback_setting, shortcut::change_translate_to_english_setting, shortcut::change_selected_language_setting, + shortcut::change_show_overlay_setting, trigger_update_check, commands::cancel_operation, commands::models::get_available_models, diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index 0988aff..19df778 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -1,5 +1,6 @@ use crate::audio_toolkit::{list_input_devices, vad::SmoothedVad, AudioRecorder, SileroVad}; use crate::settings::get_settings; +use crate::utils; use log::{debug, info}; use std::sync::{Arc, Mutex}; use std::time::Instant; @@ -23,13 +24,26 @@ pub enum MicrophoneMode { /* ──────────────────────────────────────────────────────────────── */ -fn create_audio_recorder(vad_path: &str) -> Result { +fn create_audio_recorder( + vad_path: &str, + app_handle: &tauri::AppHandle, +) -> Result { let silero = SileroVad::new(vad_path, 0.3) .map_err(|e| anyhow::anyhow!("Failed to create SileroVad: {}", e))?; let smoothed_vad = SmoothedVad::new(Box::new(silero), 15, 15, 2); + + // Recorder with VAD plus a spectrum-level callback that forwards updates to + // the frontend. let recorder = AudioRecorder::new() .map_err(|e| anyhow::anyhow!("Failed to create AudioRecorder: {}", e))? - .with_vad(Box::new(smoothed_vad)); + .with_vad(Box::new(smoothed_vad)) + .with_level_callback({ + let app_handle = app_handle.clone(); + move |levels| { + utils::emit_levels(&app_handle, &levels); + } + }); + Ok(recorder) } @@ -97,7 +111,10 @@ impl AudioRecordingManager { let mut recorder_opt = self.recorder.lock().unwrap(); if recorder_opt.is_none() { - *recorder_opt = Some(create_audio_recorder(vad_path.to_str().unwrap())?); + *recorder_opt = Some(create_audio_recorder( + vad_path.to_str().unwrap(), + &self.app_handle, + )?); } // Get the selected device from settings diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index f46ff59..4a2addc 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -30,6 +30,8 @@ pub struct AppSettings { pub translate_to_english: bool, #[serde(default = "default_selected_language")] pub selected_language: String, + #[serde(default = "default_show_overlay")] + pub show_overlay: bool, } fn default_model() -> String { @@ -54,6 +56,11 @@ fn default_selected_language() -> String { "auto".to_string() } +fn default_show_overlay() -> bool { + // Default to true - users expect visual feedback by default + true +} + pub const SETTINGS_STORE_PATH: &str = "settings_store.json"; pub fn get_default_settings() -> AppSettings { @@ -99,6 +106,7 @@ pub fn get_default_settings() -> AppSettings { selected_output_device: None, translate_to_english: false, selected_language: "auto".to_string(), + show_overlay: true, } } diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index 82d54b6..e14f840 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -127,6 +127,14 @@ pub fn change_selected_language_setting(app: AppHandle, language: String) -> Res Ok(()) } +#[tauri::command] +pub fn change_show_overlay_setting(app: AppHandle, enabled: bool) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.show_overlay = enabled; + settings::write_settings(&app, settings); + Ok(()) +} + fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { // Parse shortcut and return error if it fails let shortcut = match binding.current_binding.parse::() { diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 400b271..4ae14a4 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -6,6 +6,7 @@ use enigo::Keyboard; use enigo::Settings; use cpal::traits::{DeviceTrait, HostTrait}; +use log::debug; use rodio::OutputStreamBuilder; use std::fs::File; use std::io::BufReader; @@ -13,8 +14,7 @@ use std::thread; use tauri::image::Image; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::TrayIcon; -use tauri::AppHandle; -use tauri::Manager; +use tauri::{AppHandle, Emitter, Manager, WebviewWindowBuilder}; use tauri_plugin_clipboard_manager::ClipboardExt; fn send_paste() -> Result<(), String> { @@ -310,3 +310,107 @@ fn play_audio_file( Ok(()) } + +/* ──────────────────────────────────────────────────────────────── */ +/* OVERLAY MANAGEMENT */ +/* ──────────────────────────────────────────────────────────────── */ + +/// Creates the recording overlay window and keeps it hidden by default +pub fn create_recording_overlay(app_handle: &AppHandle) { + // Get screen dimensions for positioning + if let Ok(monitors) = app_handle.primary_monitor() { + if let Some(monitor) = monitors { + let size = monitor.size(); + let scale = monitor.scale_factor(); + let screen_width = (size.width as f64 / scale) as i32; + let screen_height = (size.height as f64 / scale) as i32; + + // Position at bottom center, 30px from bottom + let x = (screen_width - 150) / 2; + let y = screen_height - 30 - 30; + + match WebviewWindowBuilder::new( + app_handle, + "recording_overlay", + tauri::WebviewUrl::App("src/overlay/index.html".into()), + ) + .title("Recording") + .position(x as f64, y as f64) + .resizable(false) + .maximizable(false) + .minimizable(false) + .closable(false) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .transparent(true) + .focused(false) + .visible(false) // Start hidden + .build() + { + Ok(_window) => { + debug!("Recording overlay window created successfully (hidden)"); + } + Err(e) => { + debug!("Failed to create recording overlay window: {}", e); + } + } + } + } +} + +/// Shows the recording overlay window with fade-in animation +pub fn show_recording_overlay(app_handle: &AppHandle) { + // Check if show_overlay is enabled in settings + let settings = settings::get_settings(app_handle); + if !settings.show_overlay { + return; + } + + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + let _ = overlay_window.show(); + // Emit event to trigger fade-in animation with recording state + let _ = overlay_window.emit("show-overlay", "recording"); + } +} + +/// Shows the transcribing overlay window +pub fn show_transcribing_overlay(app_handle: &AppHandle) { + // Check if show_overlay is enabled in settings + let settings = settings::get_settings(app_handle); + if !settings.show_overlay { + return; + } + + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + let _ = overlay_window.show(); + // Emit event to switch to transcribing state + let _ = overlay_window.emit("show-overlay", "transcribing"); + } +} + +/// Hides the recording overlay window with fade-out animation +pub fn hide_recording_overlay(app_handle: &AppHandle) { + // Check if show_overlay is enabled in settings - if disabled, the overlay shouldn't be shown anyway + // but we still want to hide it in case the setting was changed while recording + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + // Emit event to trigger fade-out animation + let _ = overlay_window.emit("hide-overlay", ()); + // Hide the window after a short delay to allow animation to complete + let window_clone = overlay_window.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(300)); + let _ = window_clone.hide(); + }); + } +} + +pub fn emit_levels(app_handle: &AppHandle, levels: &Vec) { + // emit levels to main app + let _ = app_handle.emit("mic-level", levels); + + // also emit to the recording overlay if it's open + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + let _ = overlay_window.emit("mic-level", levels); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5a0f2eb..5162ab5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,13 +10,14 @@ "frontendDist": "../dist" }, "app": { + "macOSPrivateApi": true, "windows": [ { "title": "Handy", "width": 540, - "height": 680, + "height": 720, "minWidth": 540, - "minHeight": 680, + "minHeight": 720, "resizable": true, "maximizable": false } diff --git a/src/assets/fonts/ShortStack-Regular.ttf b/src/assets/fonts/ShortStack-Regular.ttf deleted file mode 100644 index 671a4d3..0000000 Binary files a/src/assets/fonts/ShortStack-Regular.ttf and /dev/null differ diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index b2a33fa..b0d0774 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -4,6 +4,7 @@ import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone"; import { PushToTalk } from "./PushToTalk"; import { AudioFeedback } from "./AudioFeedback"; import { OutputDeviceSelector } from "./OutputDeviceSelector"; +import { ShowOverlay } from "./ShowOverlay"; import { HandyShortcut } from "./HandyShortcut"; import { TranslateToEnglish } from "./TranslateToEnglish"; import { LanguageSelector } from "./LanguageSelector"; @@ -22,6 +23,7 @@ export const Settings: React.FC = () => { + diff --git a/src/components/settings/ShowOverlay.tsx b/src/components/settings/ShowOverlay.tsx new file mode 100644 index 0000000..79da474 --- /dev/null +++ b/src/components/settings/ShowOverlay.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; +import { useSettings } from "../../hooks/useSettings"; + +interface ShowOverlayProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const ShowOverlay: React.FC = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const { getSetting, updateSetting, isUpdating } = useSettings(); + + const showOverlayEnabled = getSetting("show_overlay") ?? true; + + return ( + updateSetting("show_overlay", enabled)} + isUpdating={isUpdating("show_overlay")} + label="Show Overlay" + description="Display visual feedback overlay during recording and transcription" + descriptionMode={descriptionMode} + grouped={grouped} + /> + ); +}; diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index b6f37bf..e8ffd89 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -4,5 +4,6 @@ export { OutputDeviceSelector } from "./OutputDeviceSelector"; export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone"; export { PushToTalk } from "./PushToTalk"; export { AudioFeedback } from "./AudioFeedback"; +export { ShowOverlay } from "./ShowOverlay"; export { HandyShortcut } from "./HandyShortcut"; export { TranslateToEnglish } from "./TranslateToEnglish"; diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 7931b33..2269919 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -199,6 +199,9 @@ export const useSettings = (): UseSettingsReturn => { language: value, }); break; + case "show_overlay": + await invoke("change_show_overlay_setting", { enabled: value }); + break; case "bindings": // Handle bindings separately - they use their own invoke methods break; @@ -243,6 +246,7 @@ export const useSettings = (): UseSettingsReturn => { selected_output_device: "Default", translate_to_english: false, selected_language: "auto", + show_overlay: true, }; const defaultValue = defaults[key]; diff --git a/src/lib/types.ts b/src/lib/types.ts index ff90867..ba3c49f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -29,6 +29,7 @@ export const SettingsSchema = z.object({ selected_output_device: z.string().nullable().optional(), translate_to_english: z.boolean(), selected_language: z.string(), + show_overlay: z.boolean(), }); export const BindingResponseSchema = z.object({ diff --git a/src/overlay/RecordingOverlay.css b/src/overlay/RecordingOverlay.css new file mode 100644 index 0000000..80f7e35 --- /dev/null +++ b/src/overlay/RecordingOverlay.css @@ -0,0 +1,55 @@ +.recording-overlay { + height: 40px; + /*width: min-content;*/ + width: 120px; + display: flex; + gap: 12px; + align-items: center; + padding-left: 6px; + padding-right: 24px; + background: rgba(0, 0, 0, 0.6); + border-radius: 24px; + opacity: 0; + transition: opacity 300ms ease-out; +} + +.bars-container { + display: flex; + align-items: end; + justify-content: center; + gap: 3px; + padding-bottom: 0px; + height: 28px; +} + +.bar { + width: 6px; + background: linear-gradient(to top, #faa2ca, #f28cbb); + max-height: 28px; + border-radius: 4px; + transition: height 80ms linear; + min-height: 4px; +} + +.recording-overlay.fade-in { + opacity: 1; +} + +.transcribing-text { + color: #faa2ca; + font-size: 12px; + font-weight: 600; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + animation: transcribing-pulse 1.5s infinite ease-in-out; +} + +@keyframes transcribing-pulse { + 0%, + 100% { + opacity: 0.6; + } + 50% { + opacity: 1; + } +} diff --git a/src/overlay/RecordingOverlay.tsx b/src/overlay/RecordingOverlay.tsx new file mode 100644 index 0000000..8297120 --- /dev/null +++ b/src/overlay/RecordingOverlay.tsx @@ -0,0 +1,94 @@ +import React, { useEffect, useState, useRef } from "react"; +import "./RecordingOverlay.css"; +import { listen } from "@tauri-apps/api/event"; + +type OverlayState = "recording" | "transcribing"; + +const RecordingOverlay: React.FC = () => { + const [isVisible, setIsVisible] = useState(false); + const [state, setState] = useState("recording"); + const [levels, setLevels] = useState(Array(16).fill(0)); + const smoothedLevelsRef = useRef(Array(16).fill(0)); + + useEffect(() => { + const setupEventListeners = async () => { + // Listen for show-overlay event from Rust + const unlistenShow = await listen("show-overlay", (event) => { + const overlayState = event.payload as OverlayState; + setState(overlayState); + setIsVisible(true); + }); + + // Listen for hide-overlay event from Rust + const unlistenHide = await listen("hide-overlay", () => { + setIsVisible(false); + }); + + // Listen for mic-level updates + const unlistenLevel = await listen("mic-level", (event) => { + const newLevels = event.payload as number[]; + + // Apply smoothing to reduce jitter + const smoothed = smoothedLevelsRef.current.map((prev, i) => { + const target = newLevels[i] || 0; + return prev * 0.7 + target * 0.3; // Smooth transition + }); + + smoothedLevelsRef.current = smoothed; + console.log(smoothed.length); + setLevels(smoothed.slice(0, 10)); + }); + + // Cleanup function + return () => { + unlistenShow(); + unlistenHide(); + unlistenLevel(); + }; + }; + + setupEventListeners(); + }, []); + + const getIconPath = () => { + return state === "recording" + ? "/icon/recording.png" + : "/icon/transcribing.png"; + }; + + const getIconAlt = () => { + return state === "recording" ? "Recording Icon" : "Transcribing Icon"; + }; + + return ( +
+ {getIconAlt()} + {state === "recording" && ( +
+ {levels.map((v, i) => ( +
+ ))} +
+ )} + {state === "transcribing" && ( +
Transcribing...
+ )} +
+ ); +}; + +export default RecordingOverlay; diff --git a/src/overlay/index.html b/src/overlay/index.html new file mode 100644 index 0000000..08db593 --- /dev/null +++ b/src/overlay/index.html @@ -0,0 +1,19 @@ + + + + + Recording Overlay + + + +
+ + + diff --git a/src/overlay/main.tsx b/src/overlay/main.tsx new file mode 100644 index 0000000..76053f3 --- /dev/null +++ b/src/overlay/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import RecordingOverlay from "./RecordingOverlay"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + , +); diff --git a/vite.config.ts b/vite.config.ts index 8c63787..9ea7659 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,14 +1,24 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import { resolve } from "path"; -// @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST; // https://vitejs.dev/config/ export default defineConfig(async () => ({ plugins: [react(), tailwindcss()], + // Multiple entry points for main app and overlay + build: { + rollupOptions: { + input: { + main: resolve(__dirname, "index.html"), + overlay: resolve(__dirname, "src/overlay/index.html"), + }, + }, + }, + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // // 1. prevent vite from obscuring rust errors