diff --git a/README.md b/README.md index 2d58061..9b127e8 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,118 @@ + + +[![Contributors][contributors-shield]][contributors-url] +[![Forks][forks-shield]][forks-url] +[![Stargazers][stars-shield]][stars-url] +[![Issues][issues-shield]][issues-url] + [English](./README_EN.md) -# Voice Key +
+
+ + Logo + + +

Voice Key

+ +

+ 一款开源的桌面端语音输入产品 +
+
+ 查看演示 + · + 报告 Bug + · + 请求功能 +

+
-Voice Key 是一款开源的的桌面端语音输入产品。 +

+ Voice Key Screenshot +

-## 主要功能 + +
+ 目录 +
    +
  1. + 主要功能 + +
  2. +
  3. + 上手指南 + +
  4. +
  5. 配置要求
  6. +
  7. macOS 安装指南
  8. +
  9. 开源协议
  10. +
  11. Star History
  12. +
+
+ +## 主要功能 - **语音转写**: 集成 GLM ASR (智谱AI) 实现高精度的语音转文字。 -## 配置要求 +### 技术栈 + +本项目使用了以下主要框架和库: + +- [![Electron][Electron.js]][Electron-url] +- [![React][React.js]][React-url] +- [![Vite][Vite.js]][Vite-url] +- [![TypeScript][TypeScript]][TypeScript-url] +- [![TailwindCSS][TailwindCSS]][TailwindCSS-url] +- [![shadcn/ui][shadcn/ui]][shadcn-url] +- [![Zustand][Zustand]][Zustand-url] + +

(back to top)

+ +## 上手指南 + +按照以下步骤在本地搭建并运行项目。 + +### 环境要求 + +开发前请确保已安装 Node.js 和 npm。 + +- npm + ```sh + npm install npm@latest -g + ``` + +### 安装步骤 + +1. 获取免费 API Key (详见 [配置要求](#prerequisites)) +2. 克隆仓库 + ```sh + git clone https://github.com/BuildWithAIs/voicekey.git + ``` +3. 安装依赖包 + ```sh + npm install + ``` +4. 运行开发环境 + ```sh + npm run dev + ``` +5. 在应用设置中填入你的 API Key + +

(back to top)

+ +## 配置要求 本应用依赖 **智谱 AI (GLM)** 的语音转写服务。使用前请务必配置 API Key。 1. **获取 API Key**: 访问智谱 AI 开放平台[中国版](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) 或者 [国际版](https://z.ai/manage-apikey/apikey-list) 注册并获取 Key。 2. **配置**: 打开 Voice Key 设置页面,填入你的 API Key。 -## macOS 安装指南 +## macOS 安装指南 由于应用未签名(我们还没有注册 Apple 开发者账户),安装后需执行以下步骤: @@ -33,6 +130,40 @@ Voice Key 是一款开源的的桌面端语音输入产品。 ![权限请求](imgs/macos-accessibility-prompt.png) ![权限设置](imgs/macos-accessibility-settings.png) -## 开源协议 +## 开源协议 本项目采用 [Elastic License 2.0](LICENSE) 开源协议。 + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=BuildWithAIs/voicekey&type=Date)](https://star-history.com/#BuildWithAIs/voicekey&Date) + +

(back to top)

+ + + + +[contributors-shield]: https://img.shields.io/github/contributors/BuildWithAIs/voicekey.svg?style=for-the-badge +[contributors-url]: https://github.com/BuildWithAIs/voicekey/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/BuildWithAIs/voicekey.svg?style=for-the-badge +[forks-url]: https://github.com/BuildWithAIs/voicekey/network/members +[stars-shield]: https://img.shields.io/github/stars/BuildWithAIs/voicekey.svg?style=for-the-badge +[stars-url]: https://github.com/BuildWithAIs/voicekey/stargazers +[issues-shield]: https://img.shields.io/github/issues/BuildWithAIs/voicekey.svg?style=for-the-badge +[issues-url]: https://github.com/BuildWithAIs/voicekey/issues +[license-shield]: https://img.shields.io/github/license/BuildWithAIs/voicekey.svg?style=for-the-badge +[license-url]: https://github.com/BuildWithAIs/voicekey/blob/master/LICENSE +[Electron.js]: https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white +[Electron-url]: https://www.electronjs.org/ +[React.js]: https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB +[React-url]: https://reactjs.org/ +[Vite.js]: https://img.shields.io/badge/vite-%23646CFF.svg?style=for-the-badge&logo=vite&logoColor=white +[Vite-url]: https://vitejs.dev/ +[TypeScript]: https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white +[TypeScript-url]: https://www.typescriptlang.org/ +[TailwindCSS]: https://img.shields.io/badge/tailwindcss-%2338B2AC.svg?style=for-the-badge&logo=tailwind-css&logoColor=white +[TailwindCSS-url]: https://tailwindcss.com/ +[shadcn/ui]: https://img.shields.io/badge/shadcn%2Fui-000000?style=for-the-badge&logo=shadcnui&logoColor=white +[shadcn-url]: https://ui.shadcn.com/ +[Zustand]: https://img.shields.io/badge/zustand-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB +[Zustand-url]: https://github.com/pmndrs/zustand diff --git a/README_EN.md b/README_EN.md index 89f5d1b..be94619 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,19 +1,116 @@ -# Voice Key + -Voice Key is an open-source desktop voice input product. +[![Contributors][contributors-shield]][contributors-url] +[![Forks][forks-shield]][forks-url] +[![Stargazers][stars-shield]][stars-url] +[![Issues][issues-shield]][issues-url] -## Features +
+
+ + Logo + + +

Voice Key

+ +

+ An open-source desktop voice input product +
+
+ View Demo + · + Report Bug + · + Request Feature +

+
+ +

+ Voice Key Screenshot +

+ + +
+ Table of Contents +
    +
  1. + Features + +
  2. +
  3. + Getting Started + +
  4. +
  5. Configuration Requirements
  6. +
  7. macOS Installation Guide
  8. +
  9. License
  10. +
  11. Star History
  12. +
+
+ +## Features - **Voice Transcription**: Integrates GLM ASR (Zhipu AI) for high-precision speech-to-text. -## Configuration Requirements +### Built With + +This section lists the major frameworks and libraries used to bootstrap this project. + +- [![Electron][Electron.js]][Electron-url] +- [![React][React.js]][React-url] +- [![Vite][Vite.js]][Vite-url] +- [![TypeScript][TypeScript]][TypeScript-url] +- [![TailwindCSS][TailwindCSS]][TailwindCSS-url] +- [![shadcn/ui][shadcn/ui]][shadcn-url] +- [![Zustand][Zustand]][Zustand-url] + +

(back to top)

+ +## Getting Started + +Follow these simple steps to get a local copy up and running. + +### Prerequisites + +Ensure you have Node.js and npm installed. + +- npm + ```sh + npm install npm@latest -g + ``` + +### Installation + +1. Get a free API Key (See [Configuration Requirements](#prerequisites)) +2. Clone the repo + ```sh + git clone https://github.com/BuildWithAIs/voicekey.git + ``` +3. Install NPM packages + ```sh + npm install + ``` +4. Run locally + ```sh + npm run dev + ``` +5. Enter your API Key in the Settings + +

(back to top)

+ +## Configuration Requirements This application depends on the **Zhipu AI (GLM)** speech transcription service. You must configure an API Key before use. 1. **Get API Key**: Visit the Zhipu AI Open Platform ([China](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) or [International](https://z.ai/manage-apikey/apikey-list)) to register and obtain a Key. 2. **Configure**: Open the Voice Key settings page and enter your API Key. -## macOS Installation Guide +## macOS Installation Guide Since the application is unsigned (we have not yet registered an Apple Developer account), you need to perform the following steps after installation: @@ -31,6 +128,36 @@ Since the application is unsigned (we have not yet registered an Apple Developer ![Permission Request](imgs/macos-accessibility-prompt.png) ![Permission Settings](imgs/macos-accessibility-settings.png) -## License +## License This project is licensed under the [Elastic License 2.0](LICENSE). + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=BuildWithAIs/voicekey&type=Date)](https://star-history.com/#BuildWithAIs/voicekey&Date) + + + + +[contributors-shield]: https://img.shields.io/github/contributors/BuildWithAIs/voicekey.svg?style=for-the-badge +[contributors-url]: https://github.com/BuildWithAIs/voicekey/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/BuildWithAIs/voicekey.svg?style=for-the-badge +[forks-url]: https://github.com/BuildWithAIs/voicekey/network/members +[stars-shield]: https://img.shields.io/github/stars/BuildWithAIs/voicekey.svg?style=for-the-badge +[stars-url]: https://github.com/BuildWithAIs/voicekey/stargazers +[issues-shield]: https://img.shields.io/github/issues/BuildWithAIs/voicekey.svg?style=for-the-badge +[issues-url]: https://github.com/BuildWithAIs/voicekey/issues +[Electron.js]: https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white +[Electron-url]: https://www.electronjs.org/ +[React.js]: https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB +[React-url]: https://reactjs.org/ +[Vite.js]: https://img.shields.io/badge/vite-%23646CFF.svg?style=for-the-badge&logo=vite&logoColor=white +[Vite-url]: https://vitejs.dev/ +[TypeScript]: https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white +[TypeScript-url]: https://www.typescriptlang.org/ +[TailwindCSS]: https://img.shields.io/badge/tailwindcss-%2338B2AC.svg?style=for-the-badge&logo=tailwind-css&logoColor=white +[TailwindCSS-url]: https://tailwindcss.com/ +[shadcn/ui]: https://img.shields.io/badge/shadcn%2Fui-000000?style=for-the-badge&logo=shadcnui&logoColor=white +[shadcn-url]: https://ui.shadcn.com/ +[Zustand]: https://img.shields.io/badge/zustand-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB +[Zustand-url]: https://github.com/pmndrs/zustand diff --git a/electron/main/README.md b/electron/main/README.md index 9518efa..80bf4fb 100644 --- a/electron/main/README.md +++ b/electron/main/README.md @@ -4,7 +4,7 @@ Electron 主进程目录,负责窗口管理、IPC、录音流程、ASR 调用 ## 文件列表 -- `main.ts` - 应用入口;创建后台/设置/浮窗窗口、托盘菜单与 IPC 处理,协调 PTT 录音 → 转录 → 注入流程并初始化 FFmpeg。 +- `main.ts` - 应用入口;创建后台/设置/浮窗窗口、托盘菜单与 IPC 处理,协调 PTT 录音 → 转录 → 注入流程、会话取消与 FFmpeg 初始化。 - `i18n.ts` - 主进程 i18next 初始化与语言切换。 - `config-manager.ts` - 使用 `electron-store` 持久化应用偏好、ASR 配置与快捷键配置。 - `history-manager.ts` - 录音历史存储(最多 1000 条),提供增删清空与统计接口。 diff --git a/electron/main/main.ts b/electron/main/main.ts index 109a0df..9c20e91 100644 --- a/electron/main/main.ts +++ b/electron/main/main.ts @@ -621,6 +621,15 @@ async function handleAudioData(buffer: Buffer) { const conversionStartTime = Date.now() await convertToMP3(tempWebmPath, tempMp3Path) + + // Check cancellation after conversion + if (!currentSession) { + console.log('[Main] Session cancelled during conversion, aborting.') + if (fs.existsSync(tempWebmPath)) fs.unlinkSync(tempWebmPath) + if (fs.existsSync(tempMp3Path)) fs.unlinkSync(tempMp3Path) + return + } + const conversionDuration = Date.now() - conversionStartTime console.log(`[Main] [${new Date().toISOString()}] Audio converted to MP3: ${tempMp3Path}`) console.log(`[Main] ⏱️ Total conversion process took ${conversionDuration}ms`) @@ -637,6 +646,15 @@ async function handleAudioData(buffer: Buffer) { const asrStartTime = Date.now() console.log(`[Main] [${new Date().toISOString()}] Sending audio to ASR service...`) const transcription = await asrProvider.transcribe(tempMp3Path) + + // Check cancellation after transcription + if (!currentSession) { + console.log('[Main] Session cancelled during transcription, aborting.') + if (fs.existsSync(tempWebmPath)) fs.unlinkSync(tempWebmPath) + if (fs.existsSync(tempMp3Path)) fs.unlinkSync(tempMp3Path) + return + } + const asrDuration = Date.now() - asrStartTime console.log(`[Main] [${new Date().toISOString()}] Transcription received`) console.log(`[Main] ⏱️ ASR transcription took ${asrDuration}ms`) @@ -655,6 +673,15 @@ async function handleAudioData(buffer: Buffer) { }) const injectStartTime = Date.now() + + // Check cancellation before injection + if (!currentSession) { + console.log('[Main] Session cancelled before injection, aborting.') + if (fs.existsSync(tempWebmPath)) fs.unlinkSync(tempWebmPath) + if (fs.existsSync(tempMp3Path)) fs.unlinkSync(tempMp3Path) + return + } + console.log(`[Main] [${new Date().toISOString()}] Injecting text...`) await textInjector.injectText(transcription.text) const injectDuration = Date.now() - injectStartTime @@ -722,6 +749,24 @@ function showNotification(title: string, body: string) { } } +async function handleCancelSession() { + // 1. 立即隐藏窗口 + hideOverlay() + + // 2. 标记当前会话为已取消 + if (currentSession) { + currentSession = null // 或保留引用但标记失效 + } + + // 3. 通知后台窗口停止录音 (如果正在录音) + if (backgroundWindow) { + backgroundWindow.webContents.send(IPC_CHANNELS.SESSION_STOP) + } + + // 4. (关键) 在 handleAudioData 中添加检查 + // 如果收到音频数据时 currentSession 为 null 或 status 为 aborted,则直接丢弃,不执行 ASR 和 注入。 +} + // IPC处理器 function setupIPCHandlers() { // 配置相关 @@ -823,6 +868,8 @@ function setupIPCHandlers() { ipcMain.handle(IPC_CHANNELS.OPEN_EXTERNAL, (_event, url) => { UpdaterManager.openReleasePage(url) }) + + ipcMain.handle(IPC_CHANNELS.CANCEL_SESSION, handleCancelSession) } // 应用程序生命周期 diff --git a/electron/preload/README.md b/electron/preload/README.md index 6601d75..7ff854f 100644 --- a/electron/preload/README.md +++ b/electron/preload/README.md @@ -28,6 +28,7 @@ IPC 通信桥接脚本,运行在渲染进程上下文但可访问部分 Node.j - `onStopRecording(callback)` - 监听录音停止信号(主进程 → 渲染) - `sendAudioData(buffer)` - 发送录制的音频数据(渲染 → 主进程) - `sendError(error)` - 发送错误信息 +- `cancelSession()` - 取消当前会话并停止录音 **快捷键** diff --git a/electron/preload/preload.ts b/electron/preload/preload.ts index c2b76e7..5e251c4 100644 --- a/electron/preload/preload.ts +++ b/electron/preload/preload.ts @@ -54,6 +54,9 @@ export interface ElectronAPI { getUpdateStatus: () => Promise getAppVersion: () => Promise openExternal: (url: string) => Promise + + // 取消会话 + cancelSession: () => Promise } // 暴露安全的API到渲染进程 @@ -147,4 +150,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getUpdateStatus: () => ipcRenderer.invoke(IPC_CHANNELS.GET_UPDATE_STATUS), getAppVersion: () => ipcRenderer.invoke(IPC_CHANNELS.GET_APP_VERSION), openExternal: (url: string) => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url), + + // 取消会话 + cancelSession: () => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_SESSION), } as ElectronAPI) diff --git a/electron/shared/types.ts b/electron/shared/types.ts index 5f9159e..5808009 100644 --- a/electron/shared/types.ts +++ b/electron/shared/types.ts @@ -95,6 +95,9 @@ export const IPC_CHANNELS = { GET_UPDATE_STATUS: 'update:get-status', GET_APP_VERSION: 'app:version', OPEN_EXTERNAL: 'app:open-external', + + // 取消回话 + CANCEL_SESSION: 'session:cancel', } as const export type OverlayStatus = 'recording' | 'processing' | 'success' | 'error' diff --git a/imgs/logo.png b/imgs/logo.png new file mode 100644 index 0000000..f5ffd9b Binary files /dev/null and b/imgs/logo.png differ diff --git a/imgs/screenshot.png b/imgs/screenshot.png new file mode 100644 index 0000000..ada232a Binary files /dev/null and b/imgs/screenshot.png differ diff --git a/src/components/HUD.tsx b/src/components/HUD.tsx index 647f5b6..7d7759a 100644 --- a/src/components/HUD.tsx +++ b/src/components/HUD.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState } from 'react' -import { Check, CheckCheck, Mic, Sparkles, X, Zap } from 'lucide-react' +import { Check, Mic, Sparkles, X, Zap } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { OverlayState, OverlayStatus } from '../../electron/shared/types' +import { Waveform } from './Waveform' // ... export function HUD() { const { t } = useTranslation() @@ -11,7 +12,7 @@ export function HUD() { const [isVisible, setIsVisible] = useState(false) // 模拟波形数据 (结合真实的 audioLevel) - const [waveform, setWaveform] = useState([]) + // const [waveform, setWaveform] = useState([]) const audioLevelRef = useRef(0) useEffect(() => { @@ -42,31 +43,8 @@ export function HUD() { audioLevelRef.current = audioLevel }, [audioLevel]) - useEffect(() => { - if (status === 'recording') { - const interval = setInterval(() => { - setWaveform(() => { - const currentLevel = audioLevelRef.current - const newData = Array.from({ length: 7 }, () => - Math.max(0.2, (currentLevel * 1.5 + Math.random() * 0.5) * Math.random()), - ) - return newData - }) - }, 80) - return () => clearInterval(interval) - } - }, [status]) - const handleCancel = () => { - if (status === 'recording') { - window.electronAPI.stopSession() - } - } - - const handleConfirm = () => { - if (status === 'recording') { - window.electronAPI.stopSession() - } + window.electronAPI.cancelSession() } return ( @@ -88,8 +66,8 @@ export function HUD() { {/* Status Orb / Icon - 左侧状态球 */}
{/* 1. Recording State */} + {status === 'recording' && ( -
+
{/* Dynamic Waveform Visualizer */} -
- {waveform.map((h, i) => ( -
- ))} - {waveform.length === 0 &&
...
} -
- - {/* Action Buttons */} -
- - -
+
)} - {/* 2. Processing State */} {status === 'processing' && ( -
- +
+ {t('hud.thinking')}
)} - {/* 3. Success State */} {status === 'success' && ( -
-
- - {message || t('hud.done')} - -
- - {t('hud.injected')} -
+
+ {/* + {message || t('hud.done')} + */} +
+ + {t('hud.injected')}
)} - {/* 4. Error State */} {status === 'error' && (
@@ -186,6 +131,16 @@ export function HUD() {
)}
+ {/* 右侧关闭按钮 */} +
+ +
) diff --git a/src/components/README.md b/src/components/README.md index 9b57e94..fc89574 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -37,6 +37,14 @@ shadcn/ui 组件库,基于 Radix UI 构建的可复用 UI 组件集合。包 - 录制并发送快捷键数据回主进程 - 不渲染任何 UI(返回 `null`) +### `Waveform.tsx` + +音频波形可视化组件: + +- 根据音频电平动态显示条形波形动画 +- 支持自定义条形数量与颜色 +- 约 20fps 更新频率平滑动画 + ## HotkeySettings 快捷键设置组件,负责: diff --git a/src/components/Waveform.tsx b/src/components/Waveform.tsx new file mode 100644 index 0000000..10ace5b --- /dev/null +++ b/src/components/Waveform.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState } from 'react' + +interface WaveformProps { + /** Current audio level (0-1) */ + audioLevel: number + /** Number of bars to render */ + barCount?: number + /** Color of the bars */ + barColor?: string +} + +export function Waveform({ audioLevel, barCount = 12, barColor = 'bg-white/80' }: WaveformProps) { + const [data, setData] = useState([]) + const audioLevelRef = useRef(0) + + // Sync ref with prop for access inside interval + useEffect(() => { + audioLevelRef.current = audioLevel + }, [audioLevel]) + + // Animation loop + useEffect(() => { + const update = () => { + const currentLevel = audioLevelRef.current + // Create variations: + // - Base idle movement (0.1 ~ 0.3) + // - Active movement (currentLevel * multiplier) applied randomly + const newData = Array.from({ length: barCount }, () => { + const idle = 0.15 + Math.random() * 0.15 + const active = currentLevel * 2 * Math.random() // High multiplier for visibility + return Math.min(1.0, Math.max(0.15, idle + active)) + }) + setData(newData) + } + + // Faster update rate for smoother look (approx 20fps) + const interval = setInterval(update, 50) + update() + + return () => clearInterval(interval) + }, [barCount]) + + return ( +
+ {data.map((h, i) => ( +
+ ))} + {data.length === 0 &&
...
} +
+ ) +}