diff --git a/README.md b/README.md index cdf08289..7d703b0f 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,8 @@ ### 字幕生成(转写) - 多种视频 / 音频格式批量生成字幕,并发任务数可调 -- 7 类转写引擎逐任务切换:内置 `whisper.cpp`、`faster-whisper`、`FunASR`、`Qwen3-ASR`、`FireRedASR`、本地 `Whisper CLI`,以及免 GPU 的云端听写(8 家服务商) -- 本地引擎完全离线,无需联网上传;中文场景可直接选用 FunASR / FireRedASR +- 8 类转写引擎逐任务切换:内置 `whisper.cpp`、`faster-whisper`、`FunASR`、`Qwen3-ASR`、`FireRedASR`、`NVIDIA Parakeet`、本地 `Whisper CLI`,以及免 GPU 的云端听写(8 家服务商) +- 本地引擎完全离线,无需联网上传;中文场景可选 FunASR / FireRedASR,英文与欧洲语言可选 Parakeet - AI 字幕精修(可选):大模型语义断句 + 批量校正——断句按语义重组且时间轴仍精确到词(连接词不吊行尾、数字不被停顿劈开),校正修同音字、去语气词、规范标点;服务商默认跟随 AI 翻译配置(本地 Ollama 零成本),失败自动回退规则断句 - 简繁转换、自定义字幕文件名(方便不同播放器挂载识别)、可选中文字幕去标点 @@ -107,13 +107,13 @@ 对价格敏感的用户,下面这条路线不花一分钱,也不需要注册任何服务: -| 环节 | 免费方案 | 说明 | -| -------- | ----------------------------------------------------------------------- | ---------------------------- | -| 视频下载 | yt-dlp / lux 开源引擎 | 应用内一键安装,免费使用 | -| 语音转写 | whisper.cpp / faster-whisper / FunASR / Qwen3-ASR / FireRedASR 本地模型 | 模型下载一次,离线可用 | -| 字幕翻译 | 内置免费翻译(必应 / 谷歌接口,自动回退)、Ollama 本地大模型、DeepLX | 免费翻译开箱即用,零配置 | -| TTS 配音 | 本地 Kokoro / VITS / ZipVoice 声音克隆;Edge TTS 免费档 | 本地模型离线合成,无用量限制 | -| 字幕烧录 | 内置 ffmpeg | 本地合成 | +| 环节 | 免费方案 | 说明 | +| -------- | ---------------------------------------------------------------------------------- | ---------------------------- | +| 视频下载 | yt-dlp / lux 开源引擎 | 应用内一键安装,免费使用 | +| 语音转写 | whisper.cpp / faster-whisper / FunASR / Qwen3-ASR / FireRedASR / Parakeet 本地模型 | 模型下载一次,离线可用 | +| 字幕翻译 | 内置免费翻译(必应 / 谷歌接口,自动回退)、Ollama 本地大模型、DeepLX | 免费翻译开箱即用,零配置 | +| TTS 配音 | 本地 Kokoro / VITS / ZipVoice 声音克隆;Edge TTS 免费档 | 本地模型离线合成,无用量限制 | +| 字幕烧录 | 内置 ffmpeg | 本地合成 | 付费云服务(OpenAI、ElevenLabs、火山引擎、腾讯云等)全部是可选增强,按需选用。 @@ -160,10 +160,11 @@ brew upgrade --cask smartsub # 升级 | **FunASR** | SenseVoice(中 / 英 / 日 / 韩 / 粤)与 Paraformer-zh,中文表现优秀 | 内置 sherpa-onnx 原生库 | | **Qwen3-ASR** | 通义千问语音识别(qwen3-asr-0.6b / 1.7b) | 内置 sherpa-onnx 原生库 | | **FireRedASR** | FireRedASR-AED large(中英),中文表现优秀 | 内置 sherpa-onnx 原生库 | +| **NVIDIA Parakeet** | Parakeet TDT 0.6B v3,支持 25 种欧洲语言及标点、大小写 | 内置 sherpa-onnx 原生库 | | **本地 Whisper CLI** | 调用你自行安装的 whisper 兼容命令 | 使用系统已装命令 | | **云端听写(在线 ASR)** | 8 家在线服务商,免 GPU、支持多服务商多实例 | 在线服务(音频上传到你配置的端点) | -FunASR / Qwen3-ASR / FireRedASR 均通过内置的 sherpa-onnx 原生库运行,无需额外环境;faster-whisper 会在应用内下载一个自包含运行时。 +FunASR / Qwen3-ASR / FireRedASR / Parakeet 均通过内置的 sherpa-onnx 原生库运行,无需额外环境;faster-whisper 会在应用内下载一个自包含运行时。 @@ -280,7 +281,7 @@ AI 翻译的结果受模型和提示词影响较大,可以尝试不同的模 导入步骤:在「引擎与模型」页面点击「导入模型」,选择下载好的模型文件确认导入;或直接复制到模型目录。 -FunASR / Qwen3-ASR / FireRedASR 等引擎的模型可在「引擎与模型」页面内按需下载(支持 ModelScope / GitHub 等多源)。 +FunASR / Qwen3-ASR / FireRedASR / Parakeet 等引擎的模型可在「引擎与模型」页面内按需下载(支持 ModelScope / GitHub 等多源)。 @@ -361,7 +362,7 @@ yarn dev ## 致谢 - [whisper.cpp](https://github.com/ggerganov/whisper.cpp) — 本地转写引擎基础 -- [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) — FunASR / Qwen3-ASR / FireRedASR 与本地 TTS 的运行时 +- [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) — FunASR / Qwen3-ASR / FireRedASR / Parakeet 与本地 TTS 的运行时 - [FFmpeg](https://ffmpeg.org/) — 音视频处理与字幕烧录 - [Bob](https://bobtranslate.com/) — 翻译服务申请文档 diff --git a/README_EN.md b/README_EN.md index dc209481..bebc6b0b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -56,8 +56,8 @@ Online video **download** / local media → **transcribe** → **translate** → ### Subtitle generation (transcription) - Batch subtitle generation for a wide range of video / audio formats, with configurable concurrency -- 7 engine families, switchable per task: built-in `whisper.cpp`, `faster-whisper`, `FunASR`, `Qwen3-ASR`, `FireRedASR`, your local `Whisper CLI`, plus GPU-free Cloud ASR (8 providers) -- Local engines are fully offline — nothing gets uploaded; FunASR / FireRedASR shine on Chinese content +- 8 engine families, switchable per task: built-in `whisper.cpp`, `faster-whisper`, `FunASR`, `Qwen3-ASR`, `FireRedASR`, `NVIDIA Parakeet`, your local `Whisper CLI`, plus GPU-free Cloud ASR (8 providers) +- Local engines are fully offline — nothing gets uploaded; FunASR / FireRedASR shine on Chinese, while Parakeet targets English and European languages - Optional AI subtitle refine: LLM semantic segmentation + batch correction — lines are regrouped by meaning while timing stays word-accurate (no dangling connectives, numbers never split by pauses); correction fixes homophones, removes fillers and normalizes punctuation. Defaults to your AI translation provider (free with local Ollama) and falls back to rule-based segmentation on failure - Simplified/Traditional Chinese conversion, custom subtitle file naming (for player auto-loading), optional punctuation removal for Chinese subtitles @@ -107,13 +107,13 @@ Online video **download** / local media → **transcribe** → **translate** → If you're cost-conscious, this route costs nothing and requires no sign-ups: -| Step | Free option | Notes | -| -------------- | --------------------------------------------------------------------------------- | ----------------------------------------- | -| Video download | yt-dlp / lux open-source engines | Installed in-app with one click, free | -| Transcription | whisper.cpp / faster-whisper / FunASR / Qwen3-ASR / FireRedASR local models | Download a model once, works offline | -| Translation | Built-in free translation (Bing / Google endpoints with fallback), Ollama, DeepLX | Free translation works with zero setup | -| TTS dubbing | Local Kokoro / VITS / ZipVoice voice cloning; Edge TTS free tier | Local synthesis is offline, no usage caps | -| Burn-in | Bundled ffmpeg | Fully local | +| Step | Free option | Notes | +| -------------- | -------------------------------------------------------------------------------------- | ----------------------------------------- | +| Video download | yt-dlp / lux open-source engines | Installed in-app with one click, free | +| Transcription | whisper.cpp / faster-whisper / FunASR / Qwen3-ASR / FireRedASR / Parakeet local models | Download a model once, works offline | +| Translation | Built-in free translation (Bing / Google endpoints with fallback), Ollama, DeepLX | Free translation works with zero setup | +| TTS dubbing | Local Kokoro / VITS / ZipVoice voice cloning; Edge TTS free tier | Local synthesis is offline, no usage caps | +| Burn-in | Bundled ffmpeg | Fully local | Paid cloud services (OpenAI, ElevenLabs, Volcengine, Tencent Cloud, and others) are optional upgrades — use them only if you want them. @@ -160,10 +160,11 @@ The engine is a per-task choice. Manage runtimes and models from the "Engines & | **FunASR** | SenseVoice (zh/en/ja/ko/yue) and Paraformer-zh; great for Chinese | Bundled sherpa-onnx native library | | **Qwen3-ASR** | Qwen speech recognition (qwen3-asr-0.6b / 1.7b) | Bundled sherpa-onnx native library | | **FireRedASR** | FireRedASR-AED large (zh-en); great for Chinese | Bundled sherpa-onnx native library | +| **NVIDIA Parakeet** | Parakeet TDT 0.6B v3; 25 European languages with punctuation/casing | Bundled sherpa-onnx native library | | **Local Whisper CLI** | Calls a whisper-compatible command you installed yourself | Uses your system command | | **Cloud ASR (online)** | 8 providers, no GPU needed, multi-provider and multi-instance | Online service (audio uploaded to your endpoint) | -FunASR / Qwen3-ASR / FireRedASR all run on the bundled sherpa-onnx native library with no extra setup; faster-whisper downloads a self-contained runtime inside the app. +FunASR / Qwen3-ASR / FireRedASR / Parakeet all run on the bundled sherpa-onnx native library with no extra setup; faster-whisper downloads a self-contained runtime inside the app. @@ -280,7 +281,7 @@ On Apple Silicon, also download the model's `encoder.mlmodelc` file and unzip it To import: on the "Engines & Models" page click "Import Model" and pick the downloaded file — or copy it straight into the model directory. -Models for FunASR / Qwen3-ASR / FireRedASR download on demand inside the "Engines & Models" page (multiple sources: ModelScope, GitHub, and more). +Models for FunASR / Qwen3-ASR / FireRedASR / Parakeet download on demand inside the "Engines & Models" page (multiple sources: ModelScope, GitHub, and more). @@ -361,7 +362,7 @@ If this project helps you, a star is appreciated — or buy the author a coffee ## Acknowledgements - [whisper.cpp](https://github.com/ggerganov/whisper.cpp) — the foundation of local transcription -- [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) — runtime for FunASR / Qwen3-ASR / FireRedASR and local TTS +- [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) — runtime for FunASR / Qwen3-ASR / FireRedASR / Parakeet and local TTS - [FFmpeg](https://ffmpeg.org/) — media processing and subtitle burn-in - [Bob](https://bobtranslate.com/) — documentation on translation service signup diff --git a/docs/docs/advanced/ai-refine.md b/docs/docs/advanced/ai-refine.md index 64be0792..da55905f 100644 --- a/docs/docs/advanced/ai-refine.md +++ b/docs/docs/advanced/ai-refine.md @@ -38,12 +38,12 @@ keywords: [AI 断句, 语义断句, 字幕校正, LLM 字幕, 词级时间戳, ## 引擎适配说明 -| 转写引擎 | 断句时间轴 | -| ------------------------------------------ | ---------------------------------- | -| 内置 whisper.cpp | 词级精确(真实词时间戳) | -| faster-whisper | 词级精确 | -| 云端听写(whisper-1 等词级服务商) | 词级精确 | -| FunASR / Qwen3-ASR / FireRedASR / 本地 CLI | 近似(条内按比例插值),界面会提示 | +| 转写引擎 | 断句时间轴 | +| ----------------------------------------------------- | ---------------------------------- | +| 内置 whisper.cpp | 词级精确(真实词时间戳) | +| faster-whisper | 词级精确 | +| 云端听写(whisper-1 等词级服务商) | 词级精确 | +| FunASR / Qwen3-ASR / FireRedASR / Parakeet / 本地 CLI | 近似(条内按比例插值),界面会提示 | 内置引擎与 faster-whisper 还会把低置信度的词标注给校正模型(「这些词可能识别错」),定点修正更有把握。 diff --git a/docs/docs/development.md b/docs/docs/development.md index 25f16d7f..18dfdb18 100644 --- a/docs/docs/development.md +++ b/docs/docs/development.md @@ -14,7 +14,7 @@ keywords: [SmartSub 开发, Electron, 源码构建, 贡献指南, whisper.cpp ad - **Electron**(v30)+ **Next.js**(nextron):跨平台桌面应用与界面 - **TypeScript** + **Tailwind CSS** - **whisper.cpp**:内置转写引擎的 C++ 实现(Node addon 形式集成,支持 CUDA / Vulkan / CoreML / Metal) -- **sherpa-onnx**:FunASR / Qwen3-ASR / FireRedASR 与本地 TTS 的原生运行库 +- **sherpa-onnx**:FunASR / Qwen3-ASR / FireRedASR / Parakeet 与本地 TTS 的原生运行库 - **FFmpeg**:音视频处理与字幕烧录 ## 本地开发 diff --git a/docs/docs/features/subtitle-generation.md b/docs/docs/features/subtitle-generation.md index 3716ae52..2ceb3399 100644 --- a/docs/docs/features/subtitle-generation.md +++ b/docs/docs/features/subtitle-generation.md @@ -1,7 +1,7 @@ --- sidebar_position: 1 title: 字幕生成(语音转写) -description: 用妙幕把视频和音频批量转写成 SRT 字幕:7 类转写引擎可选(whisper.cpp、faster-whisper、FunASR、Qwen3-ASR、FireRedASR、Whisper CLI、云端听写),支持 GPU 加速、效果档位与精细断句控制。 +description: 用妙幕把视频和音频批量转写成 SRT 字幕:8 类转写引擎可选(whisper.cpp、faster-whisper、FunASR、Qwen3-ASR、FireRedASR、NVIDIA Parakeet、Whisper CLI、云端听写),支持 GPU 加速、效果档位与精细断句控制。 keywords: [视频转字幕, 语音转文字, whisper 字幕, FunASR, SRT 生成, 批量转写, 字幕断句] --- @@ -22,7 +22,7 @@ keywords: 支持常见的视频(mp4 / mkv / mov / avi …)与音频(mp3 / wav / m4a / flac …)格式;并发任务数可在任务设置中调整。 -## 七类转写引擎 +## 八类转写引擎 转写引擎可以**逐任务切换**,在「引擎」页面统一安装与管理: @@ -33,11 +33,12 @@ keywords: | **FunASR** | SenseVoice(中 / 英 / 日 / 韩 / 粤)与 Paraformer-zh,中文表现优秀 | 内置 sherpa-onnx,无需额外环境 | | **Qwen3-ASR** | 通义千问语音识别(qwen3-asr-0.6b / 1.7b) | 内置 sherpa-onnx,无需额外环境 | | **FireRedASR** | FireRedASR-AED large(中英),中文表现优秀 | 内置 sherpa-onnx,无需额外环境 | +| **NVIDIA Parakeet** | Parakeet TDT 0.6B v3,英文与 25 种欧洲语言,自动标点及大小写 | 内置 sherpa-onnx,无需额外环境 | | **本地 Whisper CLI** | 调用你自行安装的 whisper 兼容命令 | 使用系统已装命令 | | **云端听写** | 8 家在线服务商,免 GPU 免模型,部分有免费额度 | 在线服务(音频上传到配置的端点) | - 引擎选型与逐个安装说明见[转写引擎总览](/guides/engines/overview) -- 中文内容推荐 FunASR / FireRedASR;没有显卡推荐[云端听写](/guides/cloud-asr/overview) +- 中文内容推荐 FunASR / FireRedASR;英文与欧洲语言可试 Parakeet;没有显卡且不想下载模型时推荐[云端听写](/guides/cloud-asr/overview) - whisper 系模型怎么选(tiny 到 large、量化版本)见[模型选择与导入](/guides/engines/models) ## 字幕效果档位 diff --git a/docs/docs/guides/engines/models.md b/docs/docs/guides/engines/models.md index d1bc9802..1c71eff3 100644 --- a/docs/docs/guides/engines/models.md +++ b/docs/docs/guides/engines/models.md @@ -1,5 +1,5 @@ --- -sidebar_position: 8 +sidebar_position: 9 title: 模型选择与手动导入 description: whisper 模型怎么选:tiny 到 large-v3-turbo 各档位建议、量化版本说明、国内镜像手动下载与导入方法、Apple 芯片 CoreML 文件说明。 keywords: @@ -46,7 +46,7 @@ Apple 芯片走 CoreML 加速时,非量化模型需要配套的 `<模型名>-e ## 其它引擎的模型 - **faster-whisper**:模型在引擎页内按需下载(HuggingFace / 镜像源),同样支持导入 -- **FunASR / Qwen3-ASR / FireRedASR**:在「本地多模型引擎」分组内下载,支持 ModelScope / GitHub 多源 +- **FunASR / Qwen3-ASR / FireRedASR / Parakeet**:在「本地多模型引擎」分组内下载,支持应用内选择下载源或从文件夹导入 - 所有模型的存放位置由[统一存储目录](/advanced/storage)管理,可迁移到大容量磁盘 --- diff --git a/docs/docs/guides/engines/overview.md b/docs/docs/guides/engines/overview.md index 7a649e88..8007137e 100644 --- a/docs/docs/guides/engines/overview.md +++ b/docs/docs/guides/engines/overview.md @@ -1,8 +1,9 @@ --- sidebar_position: 1 title: 转写引擎选型总览 -description: 妙幕 7 类转写引擎怎么选:whisper.cpp、faster-whisper、FunASR、Qwen3-ASR、FireRedASR、本地 Whisper CLI 与云端听写的对比表与按需求推荐路径。 -keywords: [转写引擎对比, whisper 选择, FunASR, 语音识别引擎, ASR 引擎选型] +description: 妙幕 8 类转写引擎怎么选:whisper.cpp、faster-whisper、FunASR、Qwen3-ASR、FireRedASR、NVIDIA Parakeet、本地 Whisper CLI 与云端听写的对比表与按需求推荐路径。 +keywords: + [转写引擎对比, whisper 选择, FunASR, Parakeet, 语音识别引擎, ASR 引擎选型] --- # 转写引擎选型总览 @@ -22,6 +23,7 @@ keywords: [转写引擎对比, whisper 选择, FunASR, 语音识别引擎, ASR | [FunASR](./funasr) | ✅ | ✅ | **优秀** | 内置原生库 | 中文/中英混合内容 | | [Qwen3-ASR](./qwen3-asr) | ✅ | ✅ | 优秀 | 内置原生库 | 中文,轻量模型 | | [FireRedASR](./firered-asr) | ✅ | ✅ | **优秀** | 内置原生库 | 中文精度优先 | +| [NVIDIA Parakeet](./parakeet) | ✅ | ✅ | 不适用 | 内置原生库 | 英文与欧洲语言精度优先 | | [本地 Whisper CLI](./whisper-cli) | ✅ | ✅ | 好 | 自装命令 | 已有 whisper 环境的高级用户 | | [云端听写(8 家)](/guides/cloud-asr/overview) | 部分有免费额度 | ❌ | 好 | API Key | 无 GPU / 低配电脑,省心起步 | @@ -29,6 +31,7 @@ keywords: [转写引擎对比, whisper 选择, FunASR, 语音识别引擎, ASR - **开箱即用**:用内置 whisper.cpp + 推荐模型,零依赖,下载一个模型即可离线转写 - **中文内容最佳**:FunASR(SenseVoice)或 FireRedASR,中文准确率通常高于同级 whisper 模型 +- **英文 / 欧洲语言精度优先**:NVIDIA Parakeet TDT 0.6B v3,支持自动标点与大小写 - **速度优先(NVIDIA)**:faster-whisper + CUDA - **没有显卡 / 不想下模型**:云端听写,腾讯云每月赠 5 小时、Gladia 每月赠 10 小时,见[云端听写总览](/guides/cloud-asr/overview) - **完全免费 + 离线**:任意本地引擎均满足 diff --git a/docs/docs/guides/engines/parakeet.md b/docs/docs/guides/engines/parakeet.md new file mode 100644 index 00000000..b7b4ece1 --- /dev/null +++ b/docs/docs/guides/engines/parakeet.md @@ -0,0 +1,45 @@ +--- +sidebar_position: 7 +title: NVIDIA Parakeet +description: 妙幕 NVIDIA Parakeet TDT 0.6B v3 引擎配置指南:支持英文与 25 种欧洲语言、自动标点和大小写,经内置 sherpa-onnx 原生库离线运行。 +keywords: [NVIDIA Parakeet, Parakeet TDT, 英文语音识别, 欧洲语言转写, 本地 ASR] +--- + +# NVIDIA Parakeet + + + +妙幕集成 NVIDIA **Parakeet TDT 0.6B v3** 的 int8 ONNX 模型,通过内置 sherpa-onnx 原生库运行,无需 Python。模型支持 25 种欧洲语言,并可直接输出标点和正确的大小写;模型许可证为 CC-BY-4.0。 + +## 在妙幕中配置 + +1. 「引擎」页面选中「本地多模型引擎」分组,展开 NVIDIA Parakeet +2. 点「下载」,按网络环境选择 GitHub 国内加速或 GitHub 官方源;也可从已有模型文件夹导入 +3. 在任务向导的「语音模型」中选择 `parakeet-tdt-0.6b-v3` + +模型下载包约 487 MB,解包后约 670 MB。下载和解包都可查看进度,也可随时取消。 + +## 特点与适用 + +- **英文精度优先**:适合英文视频、课程、访谈和播客 +- 覆盖 25 种欧洲语言,并自动处理标点与大小写 +- int8 模型可在 CPU 上离线运行,音频和结果不离开本机 +- 与 FunASR、Qwen3-ASR、FireRedASR 共用内置 sherpa-onnx 运行库 + +## 使用边界 + +- 不支持中文;中英文或中文内容优先选择 FunASR、FireRedASR 或 whisper 系模型 +- 当前提供段级时间戳,字幕过长时按文本比例细分时间;需要真实词级时间戳时请选择 whisper.cpp 或 faster-whisper +- 首次转写需要加载约 670 MB 模型,耗时取决于磁盘和 CPU + +--- + +> 信息更新于 2026-07。 diff --git a/docs/docs/guides/engines/whisper-cli.md b/docs/docs/guides/engines/whisper-cli.md index 1efbe07e..d1da1a0f 100644 --- a/docs/docs/guides/engines/whisper-cli.md +++ b/docs/docs/guides/engines/whisper-cli.md @@ -1,5 +1,5 @@ --- -sidebar_position: 7 +sidebar_position: 8 title: 本地 Whisper CLI description: 妙幕本地命令行引擎配置指南:调用你自行安装的 whisper 兼容命令(openai-whisper、whisper-ctranslate2 等)执行转写,支持自定义命令模板。 keywords: [whisper 命令行, openai-whisper, 自定义转写命令, whisper CLI] diff --git a/docs/docs/intro/introduction.md b/docs/docs/intro/introduction.md index a0f567a2..0653c656 100644 --- a/docs/docs/intro/introduction.md +++ b/docs/docs/intro/introduction.md @@ -44,13 +44,13 @@ keywords: 音视频 → **转写** → **翻译** → **校对** → **配音** → **合成导出**。每一步都可以独立使用,也可以在任务向导里串成流水线批量执行。 -| 环节 | 能力概要 | 详细文档 | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| 转写 | 7 类转写引擎逐任务切换:内置 whisper.cpp、faster-whisper、FunASR、Qwen3-ASR、FireRedASR、本地 Whisper CLI,以及免 GPU 的云端听写(8 家服务商) | [字幕生成](../features/subtitle-generation) | -| 翻译 | 20 个翻译服务:内置免费翻译开箱即用,可接入 DeepSeek、Gemini 等大模型与百度、火山等传统机翻,支持术语表与双语字幕 | [字幕翻译](../features/subtitle-translation) | -| 校对 | 内置校对台,逐句对照视频检查修改,支持撤销重做与 AI 一键润色 | [字幕校对](../features/proofreading) | -| 配音 | TTS 逐条合成并自动对齐时间轴;本地引擎离线免费(Kokoro / VITS),支持 ZipVoice 零样本声音克隆 | [TTS 配音与声音克隆](../features/tts-dubbing) | -| 合成 | 硬字幕烧录 / 软字幕封装,字体样式所见即所得,支持硬件加速编码 | [视频合成](../features/video-merge) | +| 环节 | 能力概要 | 详细文档 | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| 转写 | 8 类转写引擎逐任务切换:内置 whisper.cpp、faster-whisper、FunASR、Qwen3-ASR、FireRedASR、NVIDIA Parakeet、本地 Whisper CLI,以及免 GPU 的云端听写(8 家服务商) | [字幕生成](../features/subtitle-generation) | +| 翻译 | 20 个翻译服务:内置免费翻译开箱即用,可接入 DeepSeek、Gemini 等大模型与百度、火山等传统机翻,支持术语表与双语字幕 | [字幕翻译](../features/subtitle-translation) | +| 校对 | 内置校对台,逐句对照视频检查修改,支持撤销重做与 AI 一键润色 | [字幕校对](../features/proofreading) | +| 配音 | TTS 逐条合成并自动对齐时间轴;本地引擎离线免费(Kokoro / VITS),支持 ZipVoice 零样本声音克隆 | [TTS 配音与声音克隆](../features/tts-dubbing) | +| 合成 | 硬字幕烧录 / 软字幕封装,字体样式所见即所得,支持硬件加速编码 | [视频合成](../features/video-merge) | 流水线之外,还有一个「视频下载」入口:粘贴 B 站、YouTube 等平台链接即可下载在线视频并衔接字幕任务,详见[在线视频下载](../features/video-download)。 @@ -64,12 +64,12 @@ keywords: 对价格敏感的用户,下面这条路线不花一分钱,也不需要注册任何服务: -| 环节 | 免费方案 | 说明 | -| -------- | ----------------------------------------------------------------------- | ---------------------------- | -| 语音转写 | whisper.cpp / faster-whisper / FunASR / Qwen3-ASR / FireRedASR 本地模型 | 模型下载一次,离线可用 | -| 字幕翻译 | 内置免费翻译(必应 / 谷歌接口,自动回退)、Ollama 本地大模型、DeepLX | 免费翻译开箱即用,零配置 | -| TTS 配音 | 本地 Kokoro / VITS / ZipVoice 声音克隆;Edge TTS 免费档 | 本地模型离线合成,无用量限制 | -| 字幕烧录 | 内置 ffmpeg | 本地合成 | +| 环节 | 免费方案 | 说明 | +| -------- | ---------------------------------------------------------------------------------- | ---------------------------- | +| 语音转写 | whisper.cpp / faster-whisper / FunASR / Qwen3-ASR / FireRedASR / Parakeet 本地模型 | 模型下载一次,离线可用 | +| 字幕翻译 | 内置免费翻译(必应 / 谷歌接口,自动回退)、Ollama 本地大模型、DeepLX | 免费翻译开箱即用,零配置 | +| TTS 配音 | 本地 Kokoro / VITS / ZipVoice 声音克隆;Edge TTS 免费档 | 本地模型离线合成,无用量限制 | +| 字幕烧录 | 内置 ffmpeg | 本地合成 | 付费云服务(OpenAI、ElevenLabs、火山引擎、腾讯云等)全部是可选增强,按需选用。 diff --git a/extraResources/sherpa/README.md b/extraResources/sherpa/README.md index f38f6eee..f0c5664d 100644 --- a/extraResources/sherpa/README.md +++ b/extraResources/sherpa/README.md @@ -11,7 +11,9 @@ (见 `main/helpers/sherpaOnnx/sherpaLibDownloader.ts`,产物由引擎仓 `smartsub-py-engine` 的 `sherpa-libs` workflow 发布)。 -`worker/sherpa-worker.js` 是转写 worker 入口(worker_threads,纯 JS,不经 webpack)。 +`worker/sherpa-worker.js` 是转写 worker 入口(Electron utility process,纯 JS, +不经 webpack),支持 SenseVoice、Paraformer、Qwen3-ASR、FireRedASR 与 +NeMo transducer(NVIDIA Parakeet TDT)配置。 升级 sherpa 版本:重新 `npm pack sherpa-onnx-node@` 覆盖 `vendor/` 内除 `addon.js` 外的文件,并同步更新引擎仓 `pack_sherpa_libs.mjs` / 下载器中的版本号。 diff --git a/extraResources/sherpa/vendor/types.js b/extraResources/sherpa/vendor/types.js index ce09db0e..632c3a8f 100644 --- a/extraResources/sherpa/vendor/types.js +++ b/extraResources/sherpa/vendor/types.js @@ -283,6 +283,7 @@ * @property {number} [numThreads] * @property {boolean|number} [debug] * @property {string} [provider] + * @property {string} [modelType] */ /** diff --git a/extraResources/sherpa/worker/sherpa-worker.js b/extraResources/sherpa/worker/sherpa-worker.js index bfab8fda..b2467944 100644 --- a/extraResources/sherpa/worker/sherpa-worker.js +++ b/extraResources/sherpa/worker/sherpa-worker.js @@ -63,6 +63,18 @@ function buildKey(req) { req.params.provider, ].join('|'); } + if (req.modelType === 'nemo_transducer') { + const t = req.transducer || {}; + return [ + 'nemo_transducer', + t.encoder, + t.decoder, + t.joiner, + req.tokens, + req.params.num_threads, + req.params.provider, + ].join('|'); + } return [ req.modelType, req.asrModel, @@ -173,6 +185,26 @@ function buildFireRedRecognizerConfig(f, tokens, p) { }; } +// NVIDIA Parakeet TDT:通用 transducer 三件套 + 顶层 tokens,并显式指定 +// nemo_transducer,避免 sherpa 按普通 transducer decoder 结构解析。 +function buildParakeetRecognizerConfig(t, tokens, p) { + return { + featConfig: { sampleRate: SAMPLE_RATE, featureDim: 80 }, + modelConfig: { + transducer: { + encoder: t.encoder, + decoder: t.decoder, + joiner: t.joiner, + }, + tokens, + numThreads: p.num_threads, + provider: p.provider, + debug: 0, + modelType: 'nemo_transducer', + }, + }; +} + function ensureLoaded(req) { const key = buildKey(req); if (!recognizer || key !== cacheKey) { @@ -185,6 +217,12 @@ function ensureLoaded(req) { req.tokens, req.params, ); + } else if (req.modelType === 'nemo_transducer') { + config = buildParakeetRecognizerConfig( + req.transducer, + req.tokens, + req.params, + ); } else { config = buildRecognizerConfig( req.modelType, diff --git a/main/helpers/download/atomicDirectoryInstall.ts b/main/helpers/download/atomicDirectoryInstall.ts new file mode 100644 index 00000000..e4137671 --- /dev/null +++ b/main/helpers/download/atomicDirectoryInstall.ts @@ -0,0 +1,55 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface AtomicDirectoryInstallOptions { + stagedDir: string; + destDir: string; + backupDir: string; + onCleanupWarning?: (message: string) => void; +} + +/** + * 将同一父目录下已校验的 staging 目录提交为最终模型目录。 + * + * 已有目录先改名为 backup;新目录提交失败时恢复旧目录。提交成功后即使 + * backup 清理失败也保留新目录,并仅通过 warning 报告,避免把成功安装误报为失败。 + */ +export async function commitStagedDirectory( + options: AtomicDirectoryInstallOptions, +): Promise { + const { stagedDir, destDir, backupDir, onCleanupWarning } = options; + if (path.dirname(stagedDir) !== path.dirname(destDir)) { + throw new Error('staged and destination directories must share a parent'); + } + if (path.dirname(backupDir) !== path.dirname(destDir)) { + throw new Error('backup and destination directories must share a parent'); + } + + await fs.promises.rm(backupDir, { recursive: true, force: true }); + const hadExisting = fs.existsSync(destDir); + if (hadExisting) { + await fs.promises.rename(destDir, backupDir); + } + + try { + await fs.promises.rename(stagedDir, destDir); + } catch (error) { + if (hadExisting && fs.existsSync(backupDir)) { + await fs.promises.rm(destDir, { recursive: true, force: true }); + await fs.promises.rename(backupDir, destDir); + } + throw error; + } + + if (hadExisting) { + try { + await fs.promises.rm(backupDir, { recursive: true, force: true }); + } catch (error) { + onCleanupWarning?.( + `failed to remove model backup ${backupDir}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } +} diff --git a/main/helpers/download/downloadSession.ts b/main/helpers/download/downloadSession.ts new file mode 100644 index 00000000..186e240d --- /dev/null +++ b/main/helpers/download/downloadSession.ts @@ -0,0 +1,24 @@ +/** + * 下载会话所有权跟踪器。旧异步回调只能结束自己创建的会话,不能清掉随后 + * 启动的新会话;用于防止「取消后立即重试」的 stale-finally 串扰。 + */ +export class DownloadSessionTracker { + private nextId = 0; + private activeId: number | null = null; + + begin(): number { + const id = ++this.nextId; + this.activeId = id; + return id; + } + + owns(id: number): boolean { + return this.activeId === id; + } + + finish(id: number): boolean { + if (!this.owns(id)) return false; + this.activeId = null; + return true; + } +} diff --git a/main/helpers/download/singleFileDownloader.ts b/main/helpers/download/singleFileDownloader.ts new file mode 100644 index 00000000..5b860c1e --- /dev/null +++ b/main/helpers/download/singleFileDownloader.ts @@ -0,0 +1,169 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_REDIRECTS = 5; +export const SINGLE_DOWNLOAD_CANCELLED = 'Download cancelled'; + +export interface SingleFileDownloadOptions { + url: string; + destPath: string; + signal?: AbortSignal; + headers?: Record; + timeoutMs?: number; + maxRedirects?: number; + onProgress?: (downloaded: number, total: number) => void; +} + +function redirectUrl(currentUrl: string, location: string): string { + return new URL(location, currentUrl).href; +} + +/** + * 单连接下载回退。以 socket inactivity timeout 约束连接和响应停滞,只有在 + * HTTP 消息完整、Content-Length(若提供)匹配且写流正常 close 后才算成功。 + */ +export function downloadFileSingle( + options: SingleFileDownloadOptions, + redirectCount = 0, +): Promise { + return new Promise((resolve, reject) => { + const { + url, + destPath, + signal, + headers = {}, + timeoutMs = DEFAULT_TIMEOUT_MS, + maxRedirects = DEFAULT_MAX_REDIRECTS, + onProgress, + } = options; + + if (signal?.aborted) { + reject(new Error(SINGLE_DOWNLOAD_CANCELLED)); + return; + } + + let settled = false; + let response: http.IncomingMessage | null = null; + let output: fs.WriteStream | null = null; + let request: http.ClientRequest | null = null; + + const cleanup = () => { + signal?.removeEventListener('abort', onAbort); + }; + const fail = (error: Error) => { + if (settled) return; + settled = true; + cleanup(); + response?.destroy(); + output?.destroy(); + reject(error); + }; + const succeed = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + const onAbort = () => { + request?.destroy(); + fail(new Error(SINGLE_DOWNLOAD_CANCELLED)); + }; + + signal?.addEventListener('abort', onAbort, { once: true }); + + const parsed = new URL(url); + const protocol = parsed.protocol === 'https:' ? https : http; + request = protocol.get(url, { headers }, (incoming) => { + response = incoming; + const status = incoming.statusCode ?? 0; + + if (status >= 300 && status < 400 && incoming.headers.location) { + if (redirectCount >= maxRedirects) { + incoming.resume(); + fail(new Error('Too many redirects')); + return; + } + settled = true; + cleanup(); + incoming.resume(); + downloadFileSingle( + { + ...options, + url: redirectUrl(url, incoming.headers.location), + }, + redirectCount + 1, + ) + .then(resolve) + .catch(reject); + return; + } + + if (status < 200 || status >= 300) { + incoming.resume(); + fail(new Error(`HTTP Error: ${status}`)); + return; + } + + const total = Number(incoming.headers['content-length'] || 0); + let downloaded = 0; + let responseEnded = false; + let writeFinished = false; + + incoming.on('aborted', () => + fail(new Error('Download response aborted before completion')), + ); + incoming.on('error', (error) => + fail(error instanceof Error ? error : new Error(String(error))), + ); + incoming.on('data', (chunk: Buffer) => { + downloaded += chunk.length; + onProgress?.(downloaded, total); + }); + incoming.on('end', () => { + responseEnded = true; + }); + + output = fs.createWriteStream(destPath, { flags: 'w' }); + output.on('error', (error) => + fail(error instanceof Error ? error : new Error(String(error))), + ); + output.on('finish', () => { + if (!responseEnded || !incoming.complete) { + fail(new Error('Download response ended before completion')); + return; + } + if (total > 0 && downloaded !== total) { + fail( + new Error( + `Download size mismatch: got ${downloaded}, expected ${total}`, + ), + ); + return; + } + writeFinished = true; + }); + output.on('close', () => { + if (!writeFinished) { + fail(new Error('Download output closed before completion')); + return; + } + succeed(); + }); + incoming.pipe(output); + }); + + request.on('error', (error) => { + if (signal?.aborted) { + fail(new Error(SINGLE_DOWNLOAD_CANCELLED)); + return; + } + fail(error instanceof Error ? error : new Error(String(error))); + }); + request.setTimeout(timeoutMs, () => { + request?.destroy(); + fail(new Error(`Download timed out after ${timeoutMs}ms`)); + }); + }); +} diff --git a/main/helpers/engines/outcomePresets.ts b/main/helpers/engines/outcomePresets.ts index 58d501c5..518e89e4 100644 --- a/main/helpers/engines/outcomePresets.ts +++ b/main/helpers/engines/outcomePresets.ts @@ -49,7 +49,7 @@ const VAD_NOISY: VadTuning = { }; /** sherpa-onnx 系引擎:VAD 结构性常开、无上下文/抗重复概念,档位只映射 VAD 灵敏度。 */ -const SHERPA_ENGINES = new Set(['funasr', 'qwen', 'fireRedAsr']); +const SHERPA_ENGINES = new Set(['funasr', 'qwen', 'fireRedAsr', 'parakeet']); export function isSherpaEngineId(engine?: string): boolean { return !!engine && SHERPA_ENGINES.has(engine); @@ -167,7 +167,7 @@ export function resolveEffectiveSettings( return resolved; } - // sherpa 系(funasr / qwen / fireRedAsr):只映射 VAD 灵敏度,不动上下文/抗重复(不适用)。 + // sherpa 系:只映射 VAD 灵敏度,不动上下文/抗重复(不适用)。 if (isSherpaEngineId(engine)) { const tuning = outcome === 'accurate' diff --git a/main/helpers/engines/parakeetEngine.ts b/main/helpers/engines/parakeetEngine.ts new file mode 100644 index 00000000..fa4dc8e3 --- /dev/null +++ b/main/helpers/engines/parakeetEngine.ts @@ -0,0 +1,189 @@ +import fs from 'fs'; +import type { EngineStatus } from '../../../types/engine'; +import { + getParakeetModelFiles, + getParakeetVadModelPath, + isParakeetReady, + getInstalledParakeetModels, + resolveParakeetSelection, +} from '../parakeetModelCatalog'; +import { isSherpaLibInstalled } from '../sherpaOnnx/sherpaLibPaths'; +import { getSherpaLibStatus } from '../sherpaOnnx/sherpaLibManager'; +import { + getSherpaAsrRuntime, + type SherpaModelRequest, +} from '../sherpaOnnx/sherpaFunasrRuntime'; +import { formatSrtContent } from '../fileUtils'; +import { logMessage, store } from '../storeManager'; +import { getTaskContext, TaskCancelledError } from '../taskContext'; +import { + subtitleCueFromSegment, + trimSubtitleTrailingSilence, +} from '../subtitleTiming'; +import { resplitSubtitleCues } from '../subtitleSegmentation'; +import { buildParakeetParams } from './parakeetParams'; +import { resolveEffectiveSettings } from './outcomePresets'; +import type { TranscribeContext, TranscriptionEngineAdapter } from './types'; + +const activeTranscribeIds = new Set(); + +type ParakeetSelection = NonNullable< + ReturnType +>; + +function buildModelRequest( + selection: ParakeetSelection, + settings: Record, +): SherpaModelRequest { + const files = getParakeetModelFiles(selection.id); + return { + modelType: 'nemo_transducer', + transducer: { + encoder: files.encoder, + decoder: files.decoder, + joiner: files.joiner, + }, + tokens: files.tokens, + vadModel: getParakeetVadModelPath(), + params: buildParakeetParams(settings), + }; +} + +function prewarmParakeet(formData: Record): void { + try { + if (!isSherpaLibInstalled() || !isParakeetReady()) return; + const selection = resolveParakeetSelection( + (formData as { model?: string })?.model, + getInstalledParakeetModels(), + ); + if (!selection) return; + const settings = resolveEffectiveSettings( + formData, + store.get('settings') as Record, + ); + getSherpaAsrRuntime().prewarm(buildModelRequest(selection, settings)); + logMessage('parakeet (sherpa) prewarm started', 'info'); + } catch (error) { + logMessage(`parakeet prewarm error (non-fatal): ${error}`, 'warning'); + } +} + +async function transcribeParakeet(ctx: TranscribeContext): Promise { + const { event, file, formData } = ctx; + event.sender.send('taskFileChange', { + ...file, + extractSubtitle: 'loading', + }); + + const { tempAudioFile, srtFile } = file; + const settings = resolveEffectiveSettings( + formData, + store.get('settings') as Record, + ); + + if (!isSherpaLibInstalled()) { + throw new Error( + 'sherpa runtime not installed. Download it from Resource Hub > Engines.', + ); + } + if (!isParakeetReady()) { + throw new Error( + 'parakeet model not installed. Download Parakeet TDT + silero-VAD from Resource Hub > Models.', + ); + } + + const selection = resolveParakeetSelection( + (formData as { model?: string })?.model, + getInstalledParakeetModels(), + ); + if (!selection) { + throw new Error( + 'parakeet ASR model not installed. Download Parakeet TDT from Resource Hub > Models.', + ); + } + + const model = buildModelRequest(selection, settings); + logMessage(`parakeet(sherpa) model: ${JSON.stringify(model)}`, 'info'); + event.sender.send('taskProgressChange', file, 'extractSubtitle', 0); + + const runtime = getSherpaAsrRuntime(); + const { id, result } = runtime.transcribe(model, tempAudioFile, (percent) => + event.sender.send('taskProgressChange', file, 'extractSubtitle', percent), + ); + activeTranscribeIds.add(id); + + const signal = ctx.signal ?? getTaskContext()?.signal; + const onAbort = () => { + if (activeTranscribeIds.has(id)) runtime.cancel(id); + }; + if (signal?.aborted) runtime.cancel(id); + else signal?.addEventListener('abort', onAbort, { once: true }); + + let transcription; + try { + transcription = await result; + } catch (error) { + if (signal?.aborted || (error as { code?: string })?.code === 'cancelled') { + throw new TaskCancelledError(); + } + throw error; + } finally { + signal?.removeEventListener('abort', onAbort); + activeTranscribeIds.delete(id); + } + + if (signal?.aborted) throw new TaskCancelledError(); + + const subtitles = trimSubtitleTrailingSilence( + resplitSubtitleCues( + (transcription?.segments || []).map(subtitleCueFromSegment), + formData as Record, + ), + tempAudioFile, + ); + await fs.promises.writeFile(srtFile, formatSrtContent(subtitles)); + + event.sender.send('taskProgressChange', file, 'extractSubtitle', 100); + event.sender.send('taskFileChange', { + ...file, + extractSubtitle: 'done', + }); + logMessage('generate subtitle done (parakeet/sherpa)', 'info'); + return srtFile; +} + +export const parakeetEngineAdapter: TranscriptionEngineAdapter = { + id: 'parakeet', + displayName: 'NVIDIA Parakeet TDT 0.6B v3', + requiresRuntime: true, + + async isAvailable(): Promise { + if (!isSherpaLibInstalled()) { + return { + state: 'not_installed', + message: 'sherpa runtime not downloaded', + }; + } + if (!isParakeetReady()) { + return { + state: 'not_installed', + message: 'parakeet model not downloaded', + }; + } + return { state: 'ready', version: getSherpaLibStatus().version }; + }, + + async transcribe(ctx: TranscribeContext): Promise { + return transcribeParakeet(ctx); + }, + + cancelActive(): void { + const runtime = getSherpaAsrRuntime(); + activeTranscribeIds.forEach((id) => runtime.cancel(id)); + activeTranscribeIds.clear(); + }, + + prewarm(formData: Record): void { + prewarmParakeet(formData); + }, +}; diff --git a/main/helpers/engines/parakeetParams.ts b/main/helpers/engines/parakeetParams.ts new file mode 100644 index 00000000..97d91110 --- /dev/null +++ b/main/helpers/engines/parakeetParams.ts @@ -0,0 +1,40 @@ +import { getNumericSetting } from './transcribeShared'; + +/** Parakeet TDT 专属参数映射:SmartSub 统一 settings → sherpa-onnx addon 参数。 */ + +export interface ParakeetEngineSettings { + parakeetProvider?: 'cpu' | 'cuda'; + parakeetNumThreads?: number; + useVAD?: boolean; + vadThreshold?: number; + vadMinSilenceDuration?: number; + vadMinSpeechDuration?: number; + vadMaxSpeechDuration?: number; +} + +export interface ParakeetAddonParams { + provider: string; + num_threads: number; + vad_threshold: number; + vad_min_silence_duration_ms: number; + vad_min_speech_duration_ms: number; + vad_max_speech_duration_s: number; +} + +export function buildParakeetParams( + settings: Record, +): ParakeetAddonParams { + const s = settings as ParakeetEngineSettings; + return { + provider: s.parakeetProvider === 'cuda' ? 'cuda' : 'cpu', + num_threads: + Number(s.parakeetNumThreads) > 0 ? Number(s.parakeetNumThreads) : 2, + vad_threshold: getNumericSetting(s.vadThreshold, 0.5), + vad_min_silence_duration_ms: getNumericSetting( + s.vadMinSilenceDuration, + 100, + ), + vad_min_speech_duration_ms: getNumericSetting(s.vadMinSpeechDuration, 250), + vad_max_speech_duration_s: getNumericSetting(s.vadMaxSpeechDuration, 0), + }; +} diff --git a/main/helpers/engines/registry.ts b/main/helpers/engines/registry.ts index 144469c5..f8d37949 100644 --- a/main/helpers/engines/registry.ts +++ b/main/helpers/engines/registry.ts @@ -3,6 +3,7 @@ import { fasterWhisperEngineAdapter } from './fasterWhisperEngine'; import { funasrEngineAdapter } from './funasrEngine'; import { qwenEngineAdapter } from './qwenEngine'; import { fireRedEngineAdapter } from './fireRedEngine'; +import { parakeetEngineAdapter } from './parakeetEngine'; import { localCliEngineAdapter } from './localCliEngine'; import { cloudAsrEngineAdapter } from './cloudAsrEngine'; import type { TranscriptionEngine } from '../../../types/engine'; @@ -14,6 +15,7 @@ const adapters: TranscriptionEngineAdapter[] = [ funasrEngineAdapter, qwenEngineAdapter, fireRedEngineAdapter, + parakeetEngineAdapter, localCliEngineAdapter, cloudAsrEngineAdapter, ]; diff --git a/main/helpers/engines/transcribeGate.ts b/main/helpers/engines/transcribeGate.ts index f5a989ac..d4540cba 100644 --- a/main/helpers/engines/transcribeGate.ts +++ b/main/helpers/engines/transcribeGate.ts @@ -4,7 +4,7 @@ import { TaskCancelledError } from '../taskContext'; /** * 转写阶段互斥闸(阶段流水线的核心)。 * - * 背景:faster-whisper 共享单 Python sidecar,funasr/qwen/fireRedAsr 共享单 sherpa + * 背景:faster-whisper 共享单 Python sidecar,所有本地 sherpa ASR 引擎共享单 * worker(单例 recognizer/VAD,两个转写交错会互相污染状态),这些引擎的「转写阶段」 * 必须串行。旧实现是在调度器把整个任务队列的有效并发钳为 1——代价是排队文件的 * 音频提取、翻译等本可并行的阶段也被迫干等。 @@ -20,7 +20,12 @@ type ExecutorGroup = 'pySidecar' | 'sherpaWorker'; function executorGroupOf(engine: TranscriptionEngine): ExecutorGroup | null { if (engine === 'fasterWhisper') return 'pySidecar'; - if (engine === 'funasr' || engine === 'qwen' || engine === 'fireRedAsr') { + if ( + engine === 'funasr' || + engine === 'qwen' || + engine === 'fireRedAsr' || + engine === 'parakeet' + ) { return 'sherpaWorker'; } return null; diff --git a/main/helpers/fireRedModelCatalog.ts b/main/helpers/fireRedModelCatalog.ts index eb84f287..6bbd74c9 100644 --- a/main/helpers/fireRedModelCatalog.ts +++ b/main/helpers/fireRedModelCatalog.ts @@ -161,7 +161,7 @@ export function getFireRedModelFiles(id: FireRedModelId): { }; } -/** 共享 silero VAD:随应用内置(extraResources/sherpa/vad/silero_vad.onnx),与 funasr/qwen 共用同一份。 */ +/** 共享 silero VAD:随应用内置(extraResources/sherpa/vad/silero_vad.onnx),与其它本地 sherpa ASR 共用同一份。 */ export function getFireRedVadModelPath(): string { const { getExtraResourcesPath } = require('./utils') as typeof import('./utils'); diff --git a/main/helpers/funasrModelCatalog.ts b/main/helpers/funasrModelCatalog.ts index 5ecfc8a2..0800b689 100644 --- a/main/helpers/funasrModelCatalog.ts +++ b/main/helpers/funasrModelCatalog.ts @@ -117,7 +117,7 @@ export function isFunasrModelInstalled(id: FunasrModelId): boolean { /** * 共享 silero VAD 的绝对路径:随应用内置(extraResources/sherpa/vad/silero_vad.onnx), - * 不再依赖下载。funasr / qwen / fireRedAsr 共用这一份,与各引擎可自定义的模型根目录解耦。 + * 不再依赖下载。所有本地 sherpa ASR 引擎共用这一份,与各引擎可自定义的模型根目录解耦。 */ export function getFunasrVadModelPath(): string { const { getExtraResourcesPath } = diff --git a/main/helpers/ipcEngineHandlers.ts b/main/helpers/ipcEngineHandlers.ts index cd29be28..1499a0d6 100644 --- a/main/helpers/ipcEngineHandlers.ts +++ b/main/helpers/ipcEngineHandlers.ts @@ -19,7 +19,7 @@ import { normalizePyEngineVariant } from './pythonRuntime/paths'; let mainWindow: BrowserWindow | null = null; -/** 仅 faster-whisper 走 Python 运行时下载(funasr/qwen/firered 已迁移内置 sherpa 原生库)。 */ +/** 仅 faster-whisper 走 Python 运行时下载(其它本地 ASR 使用内置 sherpa 原生库)。 */ function coerceEngineId(_value: unknown): PyEngineId { return 'faster-whisper'; } @@ -51,7 +51,7 @@ export function registerEngineIpcHandlers(): void { } }); - // --- sherpa-onnx 原生运行库(funasr / qwen / fireRed 引擎共用):随安装包内置 --- + // --- sherpa-onnx 原生运行库(所有本地 sherpa ASR 引擎共用):随安装包内置 --- // 库随 App 固定发布、整体随版本升级,故无下载/升级/卸载通道,仅暴露内置状态。 ipcMain.handle('sherpa-lib-status', async () => { const { getSherpaLibStatus } = await import( @@ -307,6 +307,35 @@ export function registerEngineIpcHandlers(): void { }, ); + ipcMain.handle( + 'set-parakeet-settings', + async ( + _event, + { + provider, + numThreads, + }: { + provider?: 'cpu' | 'cuda'; + numThreads?: number; + }, + ) => { + try { + const settings = store.get('settings'); + store.set('settings', { + ...settings, + ...(provider !== undefined ? { parakeetProvider: provider } : {}), + ...(numThreads !== undefined + ? { parakeetNumThreads: numThreads } + : {}), + }); + return { success: true }; + } catch (error) { + logMessage(`Error setting parakeet settings: ${error}`, 'error'); + return { success: false, error: String(error) }; + } + }, + ); + ipcMain.handle( 'python-engine:ping', async (_event, payload?: { engineId?: PyEngineId }) => { diff --git a/main/helpers/modelImport.ts b/main/helpers/modelImport.ts index 915c9480..4fb05817 100644 --- a/main/helpers/modelImport.ts +++ b/main/helpers/modelImport.ts @@ -30,8 +30,8 @@ export interface ModelFileSizeExpectation { /** * 校验源目录是否含某模型的全部必需文件。 - * requiredFiles 支持嵌套相对路径(如 `tokenizer/vocab.json`);目录、空文件与 - * 无法 stat 的路径都视为无效,避免把占位文件误报为可加载模型。 + * requiredFiles 支持嵌套相对路径(如 `tokenizer/vocab.json`)。每项必须是 + * 非符号链接的普通文件且大小大于 0,避免把半截下载、空占位文件或目录误判为模型。 */ export function validateModelLayout( srcDir: string, @@ -39,7 +39,7 @@ export function validateModelLayout( ): LayoutCheckResult { const missing = requiredFiles.filter((rel) => { try { - const stat = fs.statSync(path.join(srcDir, rel)); + const stat = fs.lstatSync(path.join(srcDir, rel)); return !stat.isFile() || stat.size <= 0; } catch { return true; @@ -59,7 +59,7 @@ export function validateModelLayoutWithSizes( const missing = expectedFiles .filter(({ path: rel, size }) => { try { - const stat = fs.statSync(path.join(srcDir, rel)); + const stat = fs.lstatSync(path.join(srcDir, rel)); return !stat.isFile() || stat.size !== size; } catch { return true; @@ -71,7 +71,7 @@ export function validateModelLayoutWithSizes( /** * sherpa 系共享 VAD(silero)随应用内置的相对子路径(相对 extraResources 根)。 - * funasr / qwen / fireRedAsr 共用这一份;与各引擎可自定义的模型根目录解耦。 + * 所有本地 sherpa ASR 引擎共用这一份;与各引擎可自定义的模型根目录解耦。 */ export const SHERPA_VAD_SUBPATH = path.join('sherpa', 'vad', 'silero_vad.onnx'); diff --git a/main/helpers/parakeetModelCatalog.ts b/main/helpers/parakeetModelCatalog.ts new file mode 100644 index 00000000..40299ecc --- /dev/null +++ b/main/helpers/parakeetModelCatalog.ts @@ -0,0 +1,158 @@ +import path from 'path'; +import fs from 'fs'; +import { app } from 'electron'; +import { resolveBundledVadPath, validateModelLayout } from './modelImport'; +import { resolveModelRoot } from './storagePaths'; +import { getGithubBase, getGithubProxyPrefix } from './config/downloadConfig'; + +/** Parakeet 模型根目录:单独覆盖 > 统一存储目录 > userData/models/parakeet */ +export function getParakeetModelsRoot(): string { + const { store } = require('./store') as typeof import('./store'); + const root = resolveModelRoot( + 'parakeet', + store.get('settings'), + app.getPath('userData'), + ).path; + if (!fs.existsSync(root)) fs.mkdirSync(root, { recursive: true }); + return root; +} + +/** Parakeet 子模型标识(与本地子目录一一对应)。 */ +export type ParakeetModelId = 'parakeet-tdt-0.6b-v3'; + +export const PARAKEET_DEFAULT_MODEL_ID: ParakeetModelId = + 'parakeet-tdt-0.6b-v3'; + +/** 官方 sherpa-onnx release 整包下载源。 */ +export type ParakeetModelSource = 'ghproxy' | 'github'; + +/** 国内默认使用 GitHub 代理,失败时自动回退 GitHub。 */ +export const PARAKEET_DEFAULT_SOURCE: ParakeetModelSource = 'ghproxy'; + +const PARAKEET_SOURCE_ORDER: ParakeetModelSource[] = ['ghproxy', 'github']; + +export function getParakeetSourceOrder( + selected: ParakeetModelSource, +): ParakeetModelSource[] { + return [ + selected, + ...PARAKEET_SOURCE_ORDER.filter((source) => source !== selected), + ]; +} + +export interface ParakeetModelSpec { + id: ParakeetModelId; + dirName: string; + upstreamModel: string; + license: 'CC-BY-4.0'; + languageCount: number; + supportsPunctuation: boolean; + /** 解包后约 670MB;用于解包进度估算。 */ + approxInstallBytes: number; + releasePath: string; + archiveName: string; + archiveInnerDir: string; + requiredFiles: string[]; +} + +const PARAKEET_ARCHIVE = 'sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2'; +const PARAKEET_INNER = 'sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8'; +const PARAKEET_RELEASE_PATH = 'k2-fsa/sherpa-onnx/releases/download/asr-models'; + +export const PARAKEET_MODELS: Record = { + 'parakeet-tdt-0.6b-v3': { + id: 'parakeet-tdt-0.6b-v3', + dirName: 'parakeet-tdt-0.6b-v3', + upstreamModel: 'nvidia/parakeet-tdt-0.6b-v3', + license: 'CC-BY-4.0', + languageCount: 25, + supportsPunctuation: true, + // encoder 652MB + decoder 11.8MB + joiner 6.36MB + tokens 94KB。 + approxInstallBytes: 671_000_000, + releasePath: PARAKEET_RELEASE_PATH, + archiveName: PARAKEET_ARCHIVE, + archiveInnerDir: PARAKEET_INNER, + requiredFiles: [ + 'encoder.int8.onnx', + 'decoder.int8.onnx', + 'joiner.int8.onnx', + 'tokens.txt', + ], + }, +}; + +export function getParakeetArchiveUrl( + spec: ParakeetModelSpec, + source: ParakeetModelSource, +): string { + const github = `${getGithubBase()}/${spec.releasePath}/${spec.archiveName}`; + return source === 'ghproxy' ? `${getGithubProxyPrefix()}/${github}` : github; +} + +export function getParakeetModelDir(id: ParakeetModelId): string { + const dir = path.join(getParakeetModelsRoot(), PARAKEET_MODELS[id].dirName); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +export function isParakeetModelInstalled(id: ParakeetModelId): boolean { + const dir = path.join(getParakeetModelsRoot(), PARAKEET_MODELS[id].dirName); + return validateModelLayout(dir, PARAKEET_MODELS[id].requiredFiles).ok; +} + +/** NeMo transducer 三件套 + tokens 的绝对路径。 */ +export function getParakeetModelFiles(id: ParakeetModelId): { + encoder: string; + decoder: string; + joiner: string; + tokens: string; +} { + const dir = getParakeetModelDir(id); + return { + encoder: path.join(dir, 'encoder.int8.onnx'), + decoder: path.join(dir, 'decoder.int8.onnx'), + joiner: path.join(dir, 'joiner.int8.onnx'), + tokens: path.join(dir, 'tokens.txt'), + }; +} + +/** 与其它本地 sherpa ASR 引擎共享随包内置的 Silero VAD。 */ +export function getParakeetVadModelPath(): string { + const { getExtraResourcesPath } = + require('./utils') as typeof import('./utils'); + return resolveBundledVadPath(getExtraResourcesPath()); +} + +export function isParakeetVadInstalled(): boolean { + return fs.existsSync(getParakeetVadModelPath()); +} + +export function getParakeetModelIds(): ParakeetModelId[] { + return Object.keys(PARAKEET_MODELS) as ParakeetModelId[]; +} + +export function getInstalledParakeetModels(): ParakeetModelId[] { + return getParakeetModelIds().filter((id) => isParakeetModelInstalled(id)); +} + +export function resolveParakeetSelection( + requested: string | undefined, + installed: ParakeetModelId[], +): { id: ParakeetModelId } | null { + if (installed.length === 0) return null; + const ids = getParakeetModelIds(); + const normalized = (requested || '').toLowerCase(); + const chosen = + ids.find((id) => id === normalized && installed.includes(id)) ?? + installed[0]; + return { id: chosen }; +} + +export function isParakeetReady(): boolean { + return getInstalledParakeetModels().length > 0 && isParakeetVadInstalled(); +} + +export function deleteParakeetModel(id: ParakeetModelId): void { + const dir = path.join(getParakeetModelsRoot(), PARAKEET_MODELS[id].dirName); + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); +} diff --git a/main/helpers/parakeetModelDownloader.ts b/main/helpers/parakeetModelDownloader.ts new file mode 100644 index 00000000..da09c273 --- /dev/null +++ b/main/helpers/parakeetModelDownloader.ts @@ -0,0 +1,331 @@ +import { BrowserWindow } from 'electron'; +import { randomUUID } from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { logMessage } from './storeManager'; +import type { ModelDownloadProgress } from './modelDownloader'; +import { + PARAKEET_MODELS, + ParakeetModelId, + ParakeetModelSource, + ParakeetModelSpec, + PARAKEET_DEFAULT_SOURCE, + getParakeetSourceOrder, + getParakeetArchiveUrl, + getParakeetModelsRoot, + isParakeetModelInstalled, +} from './parakeetModelCatalog'; +import { + downloadFileParallel, + RangeNotSupportedError, +} from './download/parallelDownloader'; +import { extractArchive } from './download/extractArchive'; +import { validateModelLayout } from './modelImport'; +import { commitStagedDirectory } from './download/atomicDirectoryInstall'; +import { DownloadSessionTracker } from './download/downloadSession'; +import { + downloadFileSingle, + SINGLE_DOWNLOAD_CANCELLED, +} from './download/singleFileDownloader'; + +const CANCELLED = SINGLE_DOWNLOAD_CANCELLED; + +export function getParakeetProgressKey(id: ParakeetModelId): string { + return `parakeet:${id}`; +} + +/** + * NVIDIA Parakeet TDT 模型下载器:从 sherpa-onnx 官方 release 下载 tar.bz2, + * 解包到 userData/models/parakeet//。复用断点续传、多连接、取消和独立进程解包。 + */ +export class ParakeetModelDownloader { + private abortController: AbortController | null = null; + private mainWindow: BrowserWindow | null = null; + private currentKey: string | null = null; + private inFlight: Promise | null = null; + private readonly sessions = new DownloadSessionTracker(); + private progress: ModelDownloadProgress = { + status: 'idle', + progress: 0, + downloaded: 0, + total: 0, + speed: 0, + eta: 0, + }; + + constructor(mainWindow?: BrowserWindow) { + this.mainWindow = mainWindow || null; + } + + setMainWindow(window: BrowserWindow): void { + this.mainWindow = window; + } + + async cancel(): Promise { + const inFlight = this.inFlight; + this.abortController?.abort(); + if (inFlight) { + await inFlight.catch(() => {}); + } + } + + private send(sessionId: number): void { + if ( + !this.sessions.owns(sessionId) || + !this.mainWindow || + this.mainWindow.isDestroyed() || + !this.currentKey + ) { + return; + } + const ratio = + this.progress.total > 0 + ? this.progress.downloaded / this.progress.total + : 0; + this.mainWindow.webContents.send( + 'downloadProgress', + this.currentKey, + Math.min(ratio, 0.99), + ); + this.mainWindow.webContents.send( + 'modelDownloadDetail', + this.currentKey, + this.progress, + ); + } + + private update( + sessionId: number, + patch: Partial, + ): void { + if (!this.sessions.owns(sessionId)) return; + this.progress = { ...this.progress, ...patch }; + if (this.progress.total > 0) { + this.progress.progress = + (this.progress.downloaded / this.progress.total) * 100; + } + this.send(sessionId); + } + + private sendFinal(sessionId: number, key: string, value: number): void { + if ( + !this.sessions.owns(sessionId) || + !this.mainWindow || + this.mainWindow.isDestroyed() + ) { + return; + } + this.mainWindow.webContents.send('downloadProgress', key, value); + this.mainWindow.webContents.send('modelDownloadDetail', key, this.progress); + } + + private sendExtract(sessionId: number, ratio: number): void { + if (!this.sessions.owns(sessionId)) return; + const capped = Math.min(ratio, 0.99); + this.progress = { + ...this.progress, + status: 'extracting', + progress: Math.round(capped * 100), + }; + if (!this.mainWindow || this.mainWindow.isDestroyed() || !this.currentKey) { + return; + } + this.mainWindow.webContents.send( + 'downloadProgress', + this.currentKey, + capped, + ); + this.mainWindow.webContents.send( + 'modelDownloadDetail', + this.currentKey, + this.progress, + ); + } + + async download( + id: ParakeetModelId, + source: ParakeetModelSource = PARAKEET_DEFAULT_SOURCE, + ): Promise { + if (isParakeetModelInstalled(id)) return true; + if (this.inFlight) { + throw new Error('another Parakeet model download is still finishing'); + } + + const sessionId = this.sessions.begin(); + const controller = new AbortController(); + this.abortController = controller; + const run = this.runDownload(id, source, sessionId, controller.signal); + const tracked = run.finally(() => { + if (this.sessions.finish(sessionId)) { + this.abortController = null; + this.currentKey = null; + this.inFlight = null; + } + }); + this.inFlight = tracked; + return tracked; + } + + private async runDownload( + id: ParakeetModelId, + source: ParakeetModelSource, + sessionId: number, + signal: AbortSignal, + ): Promise { + const spec = PARAKEET_MODELS[id]; + const key = getParakeetProgressKey(id); + this.currentKey = key; + this.update(sessionId, { + status: 'downloading', + downloaded: 0, + total: 0, + progress: 0, + error: undefined, + }); + + let lastError: unknown = null; + for (const currentSource of getParakeetSourceOrder(source)) { + try { + await this.downloadFromArchive(spec, currentSource, sessionId, signal); + if (!isParakeetModelInstalled(id)) { + throw new Error( + `download finished but required files missing for ${id}: ${spec.requiredFiles.join(', ')}`, + ); + } + this.progress = { + ...this.progress, + status: 'completed', + progress: 100, + }; + this.sendFinal(sessionId, key, 1); + logMessage( + `parakeet model ${id} installed from ${currentSource}`, + 'info', + ); + return true; + } catch (error) { + lastError = error; + const message = error instanceof Error ? error.message : String(error); + if (message === CANCELLED) { + this.progress = { ...this.progress, status: 'idle' }; + this.sendFinal(sessionId, key, 1); + throw error; + } + logMessage( + `parakeet model ${id} from ${currentSource} failed: ${message}`, + 'warning', + ); + } + } + + this.progress = { + ...this.progress, + status: 'error', + error: lastError instanceof Error ? lastError.message : String(lastError), + }; + this.sendFinal(sessionId, key, 0); + throw lastError instanceof Error ? lastError : new Error(String(lastError)); + } + + private async downloadFromArchive( + spec: ParakeetModelSpec, + source: ParakeetModelSource, + sessionId: number, + signal: AbortSignal, + ): Promise { + const root = getParakeetModelsRoot(); + const destDir = path.join(root, spec.dirName); + const transactionId = randomUUID(); + const stagedDir = `${destDir}.install-${transactionId}`; + const backupDir = `${destDir}.backup-${transactionId}`; + const tmp = path.join( + root, + `.${spec.dirName}.download-${transactionId}.tar.bz2`, + ); + const url = getParakeetArchiveUrl(spec, source); + this.update(sessionId, { + status: 'downloading', + downloaded: 0, + total: 0, + progress: 0, + error: undefined, + }); + + try { + await fs.promises.rm(stagedDir, { recursive: true, force: true }); + await fs.promises.rm(tmp, { force: true }); + await this.downloadArchive(url, tmp, sessionId, signal); + this.progress = { ...this.progress, status: 'extracting' }; + this.sendExtract(sessionId, 0); + await extractArchive({ + archivePath: tmp, + destDir: stagedDir, + strip: 1, + excludeContains: 'test_wavs', + approxTotalBytes: spec.approxInstallBytes, + signal, + onProgress: (ratio) => this.sendExtract(sessionId, ratio), + }); + const validation = validateModelLayout(stagedDir, spec.requiredFiles); + if (!validation.ok) { + throw new Error( + `extracted Parakeet model is incomplete: ${validation.missing.join(', ')}`, + ); + } + await commitStagedDirectory({ + stagedDir, + destDir, + backupDir, + onCleanupWarning: (message) => logMessage(message, 'warning'), + }); + } finally { + await fs.promises.rm(tmp, { force: true }).catch(() => {}); + // commit 成功后 staging 已被 rename;失败时只清新目录,保留原模型。 + await fs.promises + .rm(stagedDir, { recursive: true, force: true }) + .catch(() => {}); + } + } + + private async downloadArchive( + url: string, + dest: string, + sessionId: number, + signal: AbortSignal, + ): Promise { + try { + await downloadFileParallel({ + url, + destPath: dest, + signal, + headers: { 'User-Agent': 'SmartSub-Electron' }, + onProgress: (downloaded, total) => + this.update(sessionId, { downloaded, total }), + log: (message, level) => logMessage(message, level), + }); + } catch (error) { + if (error instanceof RangeNotSupportedError) { + await downloadFileSingle({ + url, + destPath: dest, + signal, + headers: { 'User-Agent': 'SmartSub-Electron' }, + onProgress: (downloaded, total) => + this.update(sessionId, { downloaded, total }), + }); + return; + } + throw error; + } + } +} + +let instance: ParakeetModelDownloader | null = null; + +export function getParakeetModelDownloader( + mainWindow?: BrowserWindow, +): ParakeetModelDownloader { + if (!instance) instance = new ParakeetModelDownloader(mainWindow); + else if (mainWindow) instance.setMainWindow(mainWindow); + return instance; +} diff --git a/main/helpers/qwenModelCatalog.ts b/main/helpers/qwenModelCatalog.ts index abb49b76..abacc954 100644 --- a/main/helpers/qwenModelCatalog.ts +++ b/main/helpers/qwenModelCatalog.ts @@ -309,7 +309,7 @@ export function getQwenModelFiles(id: QwenModelId): { }; } -/** 共享 silero VAD:随应用内置(extraResources/sherpa/vad/silero_vad.onnx),与 funasr/fireRed 共用同一份。 */ +/** 共享 silero VAD:随应用内置(extraResources/sherpa/vad/silero_vad.onnx),与其它本地 sherpa ASR 共用同一份。 */ export function getQwenVadModelPath(): string { const { getExtraResourcesPath } = require('./utils') as typeof import('./utils'); diff --git a/main/helpers/sherpaOnnx/sherpaConfig.ts b/main/helpers/sherpaOnnx/sherpaConfig.ts index ab601852..1f2788d8 100644 --- a/main/helpers/sherpaOnnx/sherpaConfig.ts +++ b/main/helpers/sherpaOnnx/sherpaConfig.ts @@ -54,10 +54,17 @@ export interface OfflineRecognizerConfig { encoder: string; decoder: string; }; + /** 通用离线 transducer 三件套;Parakeet TDT 需配合 modelType=nemo_transducer。 */ + transducer?: { + encoder: string; + decoder: string; + joiner: string; + }; tokens: string; numThreads: number; provider: string; debug: number; + modelType?: string; }; } @@ -203,6 +210,44 @@ export function buildFireRedRecognizerConfig( }; } +export interface ParakeetRecognizerParams { + num_threads: number; + provider: string; +} + +/** + * NVIDIA Parakeet TDT OfflineRecognizer 配置。 + * + * Parakeet 属 NeMo transducer:模型放在 `transducer` 块,tokens 走顶层, + * 并且必须显式传 `modelType: 'nemo_transducer'`,否则 sherpa 会按普通 + * transducer 解析 decoder 输出而加载失败。 + */ +export function buildParakeetRecognizerConfig( + files: { + encoder: string; + decoder: string; + joiner: string; + }, + tokens: string, + p: ParakeetRecognizerParams, +): OfflineRecognizerConfig { + return { + featConfig: { sampleRate: SAMPLE_RATE, featureDim: 80 }, + modelConfig: { + transducer: { + encoder: files.encoder, + decoder: files.decoder, + joiner: files.joiner, + }, + tokens, + numThreads: p.num_threads, + provider: p.provider, + debug: 0, + modelType: 'nemo_transducer', + }, + }; +} + export interface SegmentTiming { start: number; end: number; diff --git a/main/helpers/sherpaOnnx/sherpaFunasrRuntime.ts b/main/helpers/sherpaOnnx/sherpaFunasrRuntime.ts index dcd675e9..ccff4a2f 100644 --- a/main/helpers/sherpaOnnx/sherpaFunasrRuntime.ts +++ b/main/helpers/sherpaOnnx/sherpaFunasrRuntime.ts @@ -6,10 +6,16 @@ import { getSherpaLibDir, isSherpaLibInstalled } from './sherpaLibPaths'; import type { FunasrAddonParams } from '../engines/funasrParams'; import type { QwenAddonParams } from '../engines/qwenParams'; import type { FireRedAddonParams } from '../engines/fireRedParams'; +import type { ParakeetAddonParams } from '../engines/parakeetParams'; export interface SherpaModelRequest { vadModel: string; - modelType: 'sense_voice' | 'paraformer' | 'qwen3_asr' | 'fire_red_asr'; + modelType: + | 'sense_voice' + | 'paraformer' + | 'qwen3_asr' + | 'fire_red_asr' + | 'nemo_transducer'; /** sense_voice / paraformer:单模型文件 + tokens.txt;fire_red_asr 复用 tokens 承载 tokens.txt。 */ asrModel?: string; tokens?: string; @@ -25,8 +31,18 @@ export interface SherpaModelRequest { encoder: string; decoder: string; }; - /** funasr 用 FunasrAddonParams;qwen 用 QwenAddonParams;fireRed 用 FireRedAddonParams(共享 VAD/线程字段)。 */ - params: FunasrAddonParams | QwenAddonParams | FireRedAddonParams; + /** nemo_transducer:encoder + decoder + joiner 三件套(tokens 走 tokens 字段)。 */ + transducer?: { + encoder: string; + decoder: string; + joiner: string; + }; + /** 各 sherpa ASR 引擎参数;共同包含 provider、线程和 VAD 字段。 */ + params: + | FunasrAddonParams + | QwenAddonParams + | FireRedAddonParams + | ParakeetAddonParams; } export interface Segment { @@ -223,7 +239,7 @@ export function getSherpaFunasrRuntime(): SherpaFunasrRuntime { } /** - * 引擎无关的 sherpa ASR 运行时入口(D4):funasr 与 qwen 复用同一常驻 worker 与缓存。 - * worker 依 `SherpaModelRequest.modelType` 选择 sense_voice / paraformer / qwen3_asr 分支。 + * 引擎无关的 sherpa ASR 运行时入口(D4):所有本地 sherpa ASR 复用同一常驻 worker 与缓存。 + * worker 依 `SherpaModelRequest.modelType` 选择具体识别器配置分支。 */ export const getSherpaAsrRuntime = getSherpaFunasrRuntime; diff --git a/main/helpers/storagePaths.ts b/main/helpers/storagePaths.ts index ba8086a0..123ac639 100644 --- a/main/helpers/storagePaths.ts +++ b/main/helpers/storagePaths.ts @@ -25,6 +25,7 @@ export type StorageKind = | 'funasr' | 'qwen' | 'firered' + | 'parakeet' | 'tts'; /** 各用途在「统一根目录」与「userData 默认」下共用的子路径段(design D2)。 */ @@ -34,6 +35,7 @@ export const STORAGE_SUBPATHS: Record = { funasr: ['models', 'funasr'], qwen: ['models', 'qwen'], firered: ['models', 'firered'], + parakeet: ['models', 'parakeet'], tts: ['models', 'tts'], }; @@ -46,7 +48,7 @@ function normalized(value: string | undefined | null): string { } export function resolveStorageLocation(input: { - /** 该用途的单独覆盖值(settings 中既有的 7 个路径键之一) */ + /** 该用途的单独覆盖值(settings 中既有的模型路径键之一) */ override?: string | null; /** settings.storageRoot */ storageRoot?: string | null; @@ -80,6 +82,7 @@ export interface StoragePathSettings { funasrModelsPath?: string; qwenModelsPath?: string; fireRedModelsPath?: string; + parakeetModelsPath?: string; ttsModelsPath?: string; useCustomTempDir?: boolean; customTempDir?: string; @@ -91,6 +94,7 @@ const OVERRIDE_KEYS: Record = { funasr: 'funasrModelsPath', qwen: 'qwenModelsPath', firered: 'fireRedModelsPath', + parakeet: 'parakeetModelsPath', tts: 'ttsModelsPath', }; @@ -161,6 +165,7 @@ export const STORAGE_PATH_SETTING_KEYS = [ 'funasrModelsPath', 'qwenModelsPath', 'fireRedModelsPath', + 'parakeetModelsPath', 'ttsModelsPath', 'customTempDir', ] as const; diff --git a/main/helpers/store/types.ts b/main/helpers/store/types.ts index 34e70f4b..997a73a3 100644 --- a/main/helpers/store/types.ts +++ b/main/helpers/store/types.ts @@ -110,6 +110,8 @@ export type StoreType = { qwenModelsPath?: string; /** fireRed 模型根目录覆盖;缺省回退 userData/models/firered */ fireRedModelsPath?: string; + /** NVIDIA Parakeet 模型根目录覆盖;缺省回退 userData/models/parakeet */ + parakeetModelsPath?: string; /** TTS(配音) 模型根目录覆盖;缺省回退 userData/models/tts */ ttsModelsPath?: string; /** FunASR(SenseVoice via sherpa-onnx) 推理 provider;P1 仅 cpu 落地,cuda/coreml 预留 */ @@ -136,6 +138,10 @@ export type StoreType = { fireRedProvider?: 'cpu' | 'cuda'; /** FireRedASR-AED 解码线程数,默认 2 */ fireRedNumThreads?: number; + /** NVIDIA Parakeet TDT(sherpa-onnx) 推理 provider */ + parakeetProvider?: 'cpu' | 'cuda'; + /** NVIDIA Parakeet TDT 解码线程数,默认 2 */ + parakeetNumThreads?: number; /** 全局网络代理模式(none=直连;custom=手动 URL) */ proxyMode?: 'none' | 'custom'; /** custom 模式的代理 URL,如 http://user:pass@host:port */ diff --git a/main/helpers/subtitleSegmentation.ts b/main/helpers/subtitleSegmentation.ts index b044a705..5d1b0542 100644 --- a/main/helpers/subtitleSegmentation.ts +++ b/main/helpers/subtitleSegmentation.ts @@ -372,7 +372,7 @@ function splitTextByWidth(text: string, maxWidth: number): string[] { * 缺席时回退 Intl.Segmenter 词边界,绝境才按字符直切——不拆词), * 时间按各段文本宽度**比例插值**(拟造时间,非真实词时间)。 * - * 仅供**没有词级时间戳**的路径使用(FunASR / Qwen / FireRed / 旧加速包段级回退 / + * 仅供**没有词级时间戳**的路径使用(本地 sherpa ASR / 旧加速包段级回退 / * 云端段级、整段降级)。有词级时间戳的路径一律走 `composeWordCues`—— * groupTokenCues(含硬切回溯)在真实词时间上断句,质量严格优于比例插值。 */ diff --git a/main/helpers/systemInfoManager.ts b/main/helpers/systemInfoManager.ts index 440fc9ad..910b163a 100644 --- a/main/helpers/systemInfoManager.ts +++ b/main/helpers/systemInfoManager.ts @@ -1,5 +1,6 @@ import { app, ipcMain, BrowserWindow, dialog, shell } from 'electron'; import os from 'os'; +import { randomUUID } from 'crypto'; import { getModelsInstalled, getPath, deleteModel } from './whisper'; import { getFasterWhisperModelsInstalled, @@ -12,6 +13,7 @@ import { CT2_REQUIRED_FILES, CT2_IMPORT_SNAPSHOT_REV, } from './modelImport'; +import { commitStagedDirectory } from './download/atomicDirectoryInstall'; import { isRuntimeInstalled, readEngineManifest, @@ -76,6 +78,23 @@ import { getFireRedModelsRoot, getFireRedArchiveUrl, } from './fireRedModelCatalog'; +import { + getParakeetModelDownloader, + getParakeetProgressKey, +} from './parakeetModelDownloader'; +import { + PARAKEET_MODELS, + ParakeetModelId, + PARAKEET_DEFAULT_MODEL_ID, + ParakeetModelSource, + isParakeetModelInstalled, + isParakeetVadInstalled, + isParakeetReady, + deleteParakeetModel, + getInstalledParakeetModels, + getParakeetModelsRoot, + getParakeetArchiveUrl, +} from './parakeetModelCatalog'; import { getTtsModelDownloader, getTtsProgressKey } from './ttsModelDownloader'; import { TTS_MODELS, @@ -106,6 +125,7 @@ type FolderImportEngine = | 'funasr' | 'qwen' | 'fireRedAsr' + | 'parakeet' | 'fasterWhisper' | 'tts'; @@ -129,7 +149,7 @@ function validateImportLayout( /** * 解析「从文件夹导入」的校验集与目的地(按指定引擎+模型槽消歧)。 - * - sherpa 三引擎:落 `/`,校验集取 catalog requiredFiles; + * - sherpa ASR 引擎:落 `/`,校验集取 catalog requiredFiles; * - fasterWhisper:落合成快照目录,使 resolveCt2ModelSnapshotDir 命中,校验集为 CT2 关键文件。 * 返回 null 表示模型 id 非法/缺失。 */ @@ -162,6 +182,14 @@ function resolveImportPlan( destDir: path.join(getFireRedModelsRoot(), FIRERED_MODELS[id].dirName), }; } + if (engine === 'parakeet') { + const id = (modelId as ParakeetModelId) || PARAKEET_DEFAULT_MODEL_ID; + if (!PARAKEET_MODELS[id]) return null; + return { + requiredFiles: PARAKEET_MODELS[id].requiredFiles, + destDir: path.join(getParakeetModelsRoot(), PARAKEET_MODELS[id].dirName), + }; + } if (engine === 'fasterWhisper') { if (!modelId) return null; return { @@ -195,6 +223,7 @@ export function setupSystemInfoManager(mainWindow: BrowserWindow) { const funasrModelDownloader = getFunasrModelDownloader(mainWindow); const qwenModelDownloader = getQwenModelDownloader(mainWindow); const fireRedModelDownloader = getFireRedModelDownloader(mainWindow); + const parakeetModelDownloader = getParakeetModelDownloader(mainWindow); const ttsModelDownloader = getTtsModelDownloader(mainWindow); ipcMain.handle('getSystemInfo', async () => { @@ -224,6 +253,7 @@ export function setupSystemInfoManager(mainWindow: BrowserWindow) { funasr: sourceOf('funasr'), qwen: sourceOf('qwen'), firered: sourceOf('firered'), + parakeet: sourceOf('parakeet'), }, downloadingModels: Array.from(downloadingModels), buildInfo: getBuildInfo(), @@ -243,6 +273,10 @@ export function setupSystemInfoManager(mainWindow: BrowserWindow) { fireRedVadInstalled: isFireRedVadInstalled(), fireRedModelsInstalled: getInstalledFireRedModels(), fireRedModelsPath: getFireRedModelsRoot(), + parakeetEngineInstalled: isSherpaLibInstalled(), + parakeetVadInstalled: isParakeetVadInstalled(), + parakeetModelsInstalled: getInstalledParakeetModels(), + parakeetModelsPath: getParakeetModelsRoot(), }; }); @@ -442,6 +476,59 @@ export function setupSystemInfoManager(mainWindow: BrowserWindow) { }, ); + ipcMain.handle( + 'downloadParakeetModel', + async ( + _event, + { + model, + source, + }: { + model: ParakeetModelId; + source?: ParakeetModelSource; + }, + ) => { + if (downloadingModels.size > 0) { + return { success: false, error: 'anotherDownloadInProgress' }; + } + const progressKey = getParakeetProgressKey(model); + downloadingModels.add(progressKey); + try { + await parakeetModelDownloader.download(model, source); + downloadingModels.delete(progressKey); + return { success: true }; + } catch (error) { + logMessage(`parakeet model download error: ${error}`, 'error'); + downloadingModels.delete(progressKey); + return { success: false, error: String(error) }; + } + }, + ); + + ipcMain.handle('getParakeetModelStatus', async () => ({ + success: true, + engineInstalled: isSherpaLibInstalled(), + vadInstalled: isParakeetVadInstalled(), + ready: isParakeetReady(), + models: (Object.keys(PARAKEET_MODELS) as ParakeetModelId[]).map((id) => ({ + id, + installed: isParakeetModelInstalled(id), + })), + })); + + ipcMain.handle( + 'deleteParakeetModel', + async (_event, modelId: ParakeetModelId) => { + try { + getSherpaAsrRuntime().dispose(); + deleteParakeetModel(modelId); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }, + ); + ipcMain.handle( 'downloadTtsModel', async ( @@ -511,7 +598,7 @@ export function setupSystemInfoManager(mainWindow: BrowserWindow) { source, variant, }: { - scope: 'funasr' | 'qwen' | 'firered' | 'pyEngine' | 'tts'; + scope: 'funasr' | 'qwen' | 'firered' | 'parakeet' | 'pyEngine' | 'tts'; modelId?: string; source: string; variant?: PyEngineVariant; @@ -557,6 +644,17 @@ export function setupSystemInfoManager(mainWindow: BrowserWindow) { ), }; } + if (scope === 'parakeet') { + const spec = PARAKEET_MODELS[modelId as ParakeetModelId]; + if (!spec) return { success: false, error: 'unknownModel' }; + return { + success: true, + url: getParakeetArchiveUrl( + spec, + source === 'github' ? 'github' : 'ghproxy', + ), + }; + } if (scope === 'pyEngine') { const s = source === 'github' || source === 'gitcode' ? source : 'ghproxy'; @@ -594,6 +692,9 @@ export function setupSystemInfoManager(mainWindow: BrowserWindow) { qwenModelDownloader.cancel(); fireRedModelDownloader.cancel(); ttsModelDownloader.cancel(); + // Parakeet 等待当前会话真正退出并完成 finally 清理,避免取消后立即重试时 + // 旧任务清掉新任务的进度键/互斥状态。 + await parakeetModelDownloader.cancel(); // 不提前清空下载锁:各下载 session 在真正响应 abort 并退出后自行移除 key。 // 否则 UI 可立即启动新任务,让旧异步链复用新 controller / 混写进度与文件。 return true; @@ -658,31 +759,42 @@ export function setupSystemInfoManager(mainWindow: BrowserWindow) { }; } + const transactionId = randomUUID(); + const stagedDir = `${plan.destDir}.import-${transactionId}`; + const backupDir = `${plan.destDir}.backup-${transactionId}`; try { - // 覆盖模型目录前先释放对应 worker,避免 Windows 文件锁 - // (worker 会在下次使用时自动重建)。fasterWhisper 不走 sherpa worker。 + // 先复制到同父目录 staging 并复校验,失败时不触碰已有模型。 + await fse.copy(srcDir, stagedDir, { overwrite: true }); + const post = validateImportLayout(plan, stagedDir); + if (!post.ok) { + return { + success: false, + reason: 'invalid-layout', + missing: post.missing, + }; + } + + // 仅在 staging 完整后释放 worker 并原子替换;提交失败自动恢复旧目录。 if (engine === 'tts') { getSherpaTtsRuntime().dispose(); } else if (engine !== 'fasterWhisper') { getSherpaAsrRuntime().dispose(); } - await fse.ensureDir(plan.destDir); - await fse.copy(srcDir, plan.destDir, { overwrite: true }); + await commitStagedDirectory({ + stagedDir, + destDir: plan.destDir, + backupDir, + onCleanupWarning: (message) => logMessage(message, 'warning'), + }); + return { success: true }; } catch (error) { logMessage(`import model error: ${error}`, 'error'); return { success: false, error: String(error) }; + } finally { + // commit 成功后 staging 已被 rename;失败时清理新内容。backup 不在此删除, + // 因为极端回滚失败时它是用户旧模型的唯一副本。 + await fse.remove(stagedDir).catch(() => {}); } - - // 导入后复校验:拷贝后目的地必须齐备 - const post = validateImportLayout(plan, plan.destDir); - if (!post.ok) { - return { - success: false, - reason: 'invalid-layout', - missing: post.missing, - }; - } - return { success: true }; }, ); @@ -691,7 +803,14 @@ export function setupSystemInfoManager(mainWindow: BrowserWindow) { async ( _event, options?: { - pathType?: 'ggml' | 'ct2' | 'funasr' | 'qwen' | 'firered' | 'tts'; + pathType?: + | 'ggml' + | 'ct2' + | 'funasr' + | 'qwen' + | 'firered' + | 'parakeet' + | 'tts'; }, ) => { const modelsPath = @@ -703,9 +822,11 @@ export function setupSystemInfoManager(mainWindow: BrowserWindow) { ? getQwenModelsRoot() : options?.pathType === 'firered' ? getFireRedModelsRoot() - : options?.pathType === 'tts' - ? getTtsModelsRoot() - : (getPath('modelsPath') as string); + : options?.pathType === 'parakeet' + ? getParakeetModelsRoot() + : options?.pathType === 'tts' + ? getTtsModelsRoot() + : (getPath('modelsPath') as string); try { await fse.ensureDir(modelsPath); const err = await shell.openPath(modelsPath); diff --git a/main/helpers/taskProcessor.ts b/main/helpers/taskProcessor.ts index 2789dc6a..179aff14 100644 --- a/main/helpers/taskProcessor.ts +++ b/main/helpers/taskProcessor.ts @@ -527,7 +527,7 @@ async function processNextTasks(event) { } // 任务级并发统一遵循用户配置的 maxConcurrentTasks。 - // 受限引擎(faster-whisper / funasr / qwen / fireRedAsr 共享单 sidecar/worker) + // 受限引擎(faster-whisper / sherpa ASR 共享单 sidecar/worker) // 不再在此钳制整个队列:其「转写阶段」由 transcribeGate 按执行体组排队, // 排队期间同批其它文件的音频提取 / 翻译 / 云端转写照常并行(阶段流水线)。 const availableSlots = maxConcurrentTasks - activeTasksCount; diff --git a/package.json b/package.json index ddae5330..1c915648 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "smartsub", - "description": "视频转字幕,字幕翻译软件, 支持多种转写引擎 whisper.cpp, faster-whisper, FunASR, Qwen3-ASR, FireRedASR, Whisper CLI, 多种翻译服务, 支持硬件加速", + "description": "视频转字幕,字幕翻译软件, 支持多种转写引擎 whisper.cpp, faster-whisper, FunASR, Qwen3-ASR, FireRedASR, NVIDIA Parakeet, Whisper CLI, 多种翻译服务, 支持硬件加速", "version": "3.6.0", "author": "buxuku ", "main": "app/background.js", diff --git a/renderer/components/Models.tsx b/renderer/components/Models.tsx index d2a355ed..b0b8d267 100644 --- a/renderer/components/Models.tsx +++ b/renderer/components/Models.tsx @@ -42,6 +42,12 @@ interface IProps { fireRedModelsInstalled?: string[]; /** fireRed 运行库(与 funasr 同库)是否已安装 */ fireRedEngineInstalled?: boolean; + /** Parakeet 共享 silero VAD 是否就绪 */ + parakeetVadInstalled?: boolean; + /** Parakeet 已安装模型 id 列表 */ + parakeetModelsInstalled?: string[]; + /** Parakeet 运行库(与其它 sherpa ASR 共用)是否已安装 */ + parakeetEngineInstalled?: boolean; /** 是否把 localCli 作为独立分组列出(内置规范模型名,保 `${whisperModel}` 替换)。 */ includeLocalCli?: boolean; /** 云端听写服务商实例(每个已配置实例为一分组,实例名为组名)。 */ @@ -83,6 +89,9 @@ const Models = React.forwardRef< fireRedVadInstalled, fireRedModelsInstalled, fireRedEngineInstalled, + parakeetVadInstalled, + parakeetModelsInstalled, + parakeetEngineInstalled, includeLocalCli, asrProviders, engine, @@ -107,6 +116,9 @@ const Models = React.forwardRef< fireRedVadInstalled, fireRedModelsInstalled, fireRedEngineInstalled, + parakeetVadInstalled, + parakeetModelsInstalled, + parakeetEngineInstalled, }, { includeLocalCli, asrProviders }, ); diff --git a/renderer/components/resources/EngineModelTab.tsx b/renderer/components/resources/EngineModelTab.tsx index 0184022f..15318cb8 100644 --- a/renderer/components/resources/EngineModelTab.tsx +++ b/renderer/components/resources/EngineModelTab.tsx @@ -79,7 +79,12 @@ type EngineStatuses = Partial>; type StatusTone = 'ready' | 'pending' | 'downloading' | 'error'; /** sherpa 展示组覆盖的真实引擎 id(顺序即组内分区顺序)。 */ -const SHERPA_FAMILIES: SherpaFamilyKey[] = ['funasr', 'qwen', 'fireRedAsr']; +const SHERPA_FAMILIES: SherpaFamilyKey[] = [ + 'funasr', + 'qwen', + 'fireRedAsr', + 'parakeet', +]; function isQueueBusy(status: string | undefined): boolean { return status === 'running' || status === 'paused' || status === 'cancelling'; @@ -144,10 +149,11 @@ const EngineModelTab: React.FC = () => { const taskBusyRef = useRef(false); const [updateInfo, setUpdateInfo] = useState(null); const [checkingUpdate, setCheckingUpdate] = useState(false); - // 运行库(sherpa-onnx)随包内置,不再做安装检测;三族状态只看「是否已下载模型」。 + // 运行库(sherpa-onnx)随包内置,不再做安装检测;各族状态只看「是否已下载模型」。 const [funasrModelsReady, setFunasrModelsReady] = useState(false); const [qwenModelsReady, setQwenModelsReady] = useState(false); const [fireRedModelsReady, setFireRedModelsReady] = useState(false); + const [parakeetModelsReady, setParakeetModelsReady] = useState(false); // 云端听写实例的单一事实源:左栏状态点/计数与右栏面板共用(见 useAsrProviders)。 const asr = useAsrProviders(); // 「添加自定义」对话框(云组末尾入口):命名 + 可选 Base URL,新建 OpenAI 兼容实例。 @@ -220,6 +226,11 @@ const EngineModelTab: React.FC = () => { if (frr?.success) { setFireRedModelsReady(!!frr.ready); } + + const pr = await window?.ipc?.invoke('getParakeetModelStatus'); + if (pr?.success) { + setParakeetModelsReady(!!pr.ready); + } } catch (error) { console.error('Failed to refresh engine status:', error); } @@ -453,7 +464,7 @@ const EngineModelTab: React.FC = () => { ? ['auto', 'cpu', 'cuda'] : ['auto', 'cpu']; - // sherpa 展示组三族就绪态(共用内置运行库,差异在模型);供合并面板、左栏状态点、徽标聚合。 + // sherpa 展示组各族就绪态(共用内置运行库,差异在模型)。 const sherpaFamilies = SHERPA_FAMILIES.map((engine) => { if (engine === 'funasr') { return { @@ -469,10 +480,17 @@ const EngineModelTab: React.FC = () => { status: engineStatuses.qwen, }; } + if (engine === 'fireRedAsr') { + return { + engine, + modelsReady: fireRedModelsReady, + status: engineStatuses.fireRedAsr, + }; + } return { engine, - modelsReady: fireRedModelsReady, - status: engineStatuses.fireRedAsr, + modelsReady: parakeetModelsReady, + status: engineStatuses.parakeet, }; }); const sherpaAnyReady = sherpaFamilies.some((f) => f.modelsReady); diff --git a/renderer/components/resources/ModelLibrarySection.tsx b/renderer/components/resources/ModelLibrarySection.tsx index a0a20afe..0a49b6aa 100644 --- a/renderer/components/resources/ModelLibrarySection.tsx +++ b/renderer/components/resources/ModelLibrarySection.tsx @@ -61,6 +61,7 @@ import type { TranscriptionEngine } from '../../../types/engine'; import FunasrModelSection from '@/components/resources/FunasrModelSection'; import QwenModelSection from '@/components/resources/QwenModelSection'; import FireRedModelSection from '@/components/resources/FireRedModelSection'; +import ParakeetModelSection from '@/components/resources/ParakeetModelSection'; type FasterWhisperModelEntry = { id: string; @@ -887,6 +888,7 @@ const ModelLibrarySection: React.FC = ({ const isFunasr = engine === 'funasr'; const isQwen = engine === 'qwen'; const isFireRed = engine === 'fireRedAsr'; + const isParakeet = engine === 'parakeet'; const isLocalCli = engine === 'localCli'; // 当前引擎的路径覆盖键与来源(默认 / 统一目录 / 单独设置) @@ -898,7 +900,9 @@ const ModelLibrarySection: React.FC = ({ ? 'qwenModelsPath' : isFireRed ? 'fireRedModelsPath' - : 'modelsPath'; + : isParakeet + ? 'parakeetModelsPath' + : 'modelsPath'; const modelPathSource = systemInfo?.modelPathSources?.[ isFasterWhisper @@ -909,7 +913,9 @@ const ModelLibrarySection: React.FC = ({ ? 'qwen' : isFireRed ? 'firered' - : 'ggml' + : isParakeet + ? 'parakeet' + : 'ggml' ]; const storageRootSet = !!systemInfo?.storageRoot; @@ -996,7 +1002,9 @@ const ModelLibrarySection: React.FC = ({ ? 'qwen' : isFireRed ? 'firered' - : 'ggml', + : isParakeet + ? 'parakeet' + : 'ggml', }); if (!result?.success) { toast.error( @@ -1096,7 +1104,7 @@ const ModelLibrarySection: React.FC = ({ // HuggingFace 系下载源(官方/国内镜像):ggml/ct2/FunASR 共用同一持久化偏好。 // 统一为「点击下载时再选源」——通过 Context 下发给真正发起下载的叶子组件, - // 由其就地弹出气泡选源,零常驻占位。qwen/firered 自管各自源(弹窗内选)。 + // 由其就地弹出气泡选源,零常驻占位。其余 Sherpa 模型族自管各自源。 const downloadSourceConfig: DownloadSourceConfig | null = isBuiltin || isFasterWhisper || isFunasr ? { @@ -1141,7 +1149,7 @@ const ModelLibrarySection: React.FC = ({ /> )} - {!isLocalCli && !isFunasr && !isQwen && !isFireRed && ( + {!isLocalCli && !isFunasr && !isQwen && !isFireRed && !isParakeet && (
@@ -1187,7 +1195,8 @@ const ModelLibrarySection: React.FC = ({ isFasterWhisper || isFunasr || isQwen || - isFireRed) && ( + isFireRed || + isParakeet) && (
@@ -1205,7 +1214,9 @@ const ModelLibrarySection: React.FC = ({ ? systemInfo?.qwenModelsPath : isFireRed ? systemInfo?.fireRedModelsPath - : systemInfo?.modelsPath} + : isParakeet + ? systemInfo?.parakeetModelsPath + : systemInfo?.modelsPath} {modelPathSource && ( = ({ ) : isFireRed ? ( + ) : isParakeet ? ( + ) : installedOnly && !hasAnyInstalled ? (

{t('noInstalledModels')} diff --git a/renderer/components/resources/ParakeetModelSection.tsx b/renderer/components/resources/ParakeetModelSection.tsx new file mode 100644 index 00000000..5de8237b --- /dev/null +++ b/renderer/components/resources/ParakeetModelSection.tsx @@ -0,0 +1,288 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'next-i18next'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Download, Trash2, X, Mic, Upload } from 'lucide-react'; +import { toast } from 'sonner'; +import DownloadSourcePopover, { + type DownloadSourceConfig, +} from '@/components/resources/engines/DownloadSourcePopover'; +import SherpaModelRow from '@/components/resources/SherpaModelRow'; +import { importModelFromFolder } from 'lib/importModel'; +import { resolveModelDownloadUrl } from 'lib/resolveModelDownloadUrl'; + +type ParakeetModelId = 'parakeet-tdt-0.6b-v3'; +type ParakeetModelSource = 'ghproxy' | 'github'; + +const PARAKEET_MODEL_SOURCES: ParakeetModelSource[] = ['ghproxy', 'github']; +const PARAKEET_SOURCE_STORAGE_KEY = 'parakeetModelDownloadSource'; + +function readParakeetModelSource(): ParakeetModelSource { + if (typeof window === 'undefined') return 'ghproxy'; + const value = window.localStorage.getItem(PARAKEET_SOURCE_STORAGE_KEY); + return value === 'github' || value === 'ghproxy' ? value : 'ghproxy'; +} + +interface ParakeetModelStatus { + engineInstalled: boolean; + vadInstalled: boolean; + ready: boolean; + models: { id: ParakeetModelId; installed: boolean }[]; +} + +const MODEL_ID: ParakeetModelId = 'parakeet-tdt-0.6b-v3'; +const PROGRESS_KEY = `parakeet:${MODEL_ID}`; + +const ParakeetModelSection: React.FC<{ onUpdate?: () => void }> = ({ + onUpdate, +}) => { + const { t } = useTranslation('resources'); + const { t: commonT } = useTranslation('common'); + const [status, setStatus] = useState(null); + const [progress, setProgress] = useState>({}); + const [phase, setPhase] = useState>({}); + const [downloading, setDownloading] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [source, setSource] = useState('ghproxy'); + + useEffect(() => { + setSource(readParakeetModelSource()); + }, []); + + const handleSelectSource = useCallback((next: ParakeetModelSource) => { + setSource(next); + if (typeof window !== 'undefined') { + window.localStorage.setItem(PARAKEET_SOURCE_STORAGE_KEY, next); + } + }, []); + + const load = useCallback(async () => { + try { + const result = await window?.ipc?.invoke('getParakeetModelStatus'); + if (result?.success) setStatus(result as ParakeetModelStatus); + } catch { + // 保持上次状态 + } + }, []); + + useEffect(() => { + void load(); + const unsubscribe = window?.ipc?.on( + 'downloadProgress', + (key: string, value: number) => { + if (typeof key !== 'string' || !key.startsWith('parakeet:')) return; + setProgress((previous) => ({ ...previous, [key]: value })); + if (value >= 1) { + void load(); + onUpdate?.(); + } + }, + ); + const unsubscribeDetail = window?.ipc?.on( + 'modelDownloadDetail', + (key: string, detail: { status?: string }) => { + if (typeof key !== 'string' || !key.startsWith('parakeet:')) return; + setPhase((previous) => ({ + ...previous, + [key]: detail?.status ?? '', + })); + }, + ); + return () => { + unsubscribe?.(); + unsubscribeDetail?.(); + }; + }, [load, onUpdate]); + + const installed = + status?.models.find((model) => model.id === MODEL_ID)?.installed ?? false; + + const sourceConfig: DownloadSourceConfig = { + value: source, + options: PARAKEET_MODEL_SOURCES.map((item) => ({ + value: item, + label: t(`engines.parakeet.modelSources.${item}`), + })), + onChange: (next) => handleSelectSource(next as ParakeetModelSource), + label: t('engines.parakeet.downloadSource'), + confirmLabel: commonT('startDownload'), + hint: t(`engines.parakeet.modelSourceHint.${source}`), + getCopyUrl: (next) => resolveModelDownloadUrl('parakeet', next, MODEL_ID), + }; + + const handleDownload = async () => { + setShowConfirm(false); + setDownloading(true); + try { + const result = await window?.ipc?.invoke('downloadParakeetModel', { + model: MODEL_ID, + source, + }); + if (result?.success) { + await load(); + onUpdate?.(); + } else { + toast.error( + result?.error === 'anotherDownloadInProgress' + ? t('engines.parakeet.anotherDownload') + : result?.error || 'Failed to download model', + ); + } + } catch (error) { + toast.error(String(error)); + } finally { + setDownloading(false); + setProgress((previous) => ({ ...previous, [PROGRESS_KEY]: 0 })); + } + }; + + const handleCancel = async () => { + await window?.ipc?.invoke('cancelModelDownload'); + setDownloading(false); + }; + + const handleImport = async () => { + const outcome = await importModelFromFolder('parakeet', MODEL_ID); + if (outcome.kind === 'success') { + toast.success(t('importModelSuccess'), { duration: 2000 }); + await load(); + onUpdate?.(); + } else if (outcome.kind === 'invalid-layout') { + toast.error( + t('importInvalidLayout', { files: outcome.missing.join(', ') }), + ); + } else if (outcome.kind === 'error') { + toast.error(t('importModelFailed', { error: outcome.message })); + } + }; + + const handleDelete = async () => { + setShowDeleteConfirm(false); + const result = await window?.ipc?.invoke('deleteParakeetModel', MODEL_ID); + if (result?.success) { + await load(); + onUpdate?.(); + } else { + toast.error(result?.error || 'Failed to delete model'); + } + }; + + return ( +

+
+
+ +

+ {t('engines.parakeet.modelsTitle')} +

+
+ + + + + {commonT('cancel')} + + ) : installed ? ( + + ) : ( +
+ + + + +
+ ) + } + /> +
+
+
+ + + + + {commonT('confirmDeleteModel')} + + {commonT('deleteModelDesc')} + + + + + + {commonT('cancel')} + + + + {commonT('delete')} + + + + +
+ ); +}; + +export default ParakeetModelSection; diff --git a/renderer/components/resources/engines/EngineIcon.tsx b/renderer/components/resources/engines/EngineIcon.tsx index 020619a4..b4982bcb 100644 --- a/renderer/components/resources/engines/EngineIcon.tsx +++ b/renderer/components/resources/engines/EngineIcon.tsx @@ -2,7 +2,7 @@ import React from 'react'; import type { TranscriptionEngine } from '../../../../types/engine'; interface EngineIconProps { - /** 真实引擎 id,或合并展示组 'sherpa'(FunASR · Qwen · FireRed)。 */ + /** 真实引擎 id,或合并展示组 'sherpa'。 */ engine: TranscriptionEngine | 'sherpa'; className?: string; } @@ -13,7 +13,7 @@ interface EngineIconProps { * - builtin(whisper.cpp,内置本地):芯片内的声波 * - fasterWhisper(主打速度):闪电 * - funasr(阿里达摩院):橙色声波(语音识别) - * - sherpa(FunASR · Qwen · FireRed 合并组):堆叠的声波层,示意「多模型共用一套运行库」 + * - sherpa(本地多模型合并组):堆叠的声波层,示意「多模型共用一套运行库」 * - localCli(本地命令行):终端提示符 */ const EngineIcon: React.FC = ({ engine, className }) => { @@ -100,6 +100,29 @@ const EngineIcon: React.FC = ({ engine, className }) => { ); } + if (engine === 'parakeet') { + return ( + + ); + } if (engine === 'cloud') { return ( { @@ -63,6 +63,7 @@ const SherpaAdvancedSettings: React.FC = () => { s.funasrNumThreads, s.qwenNumThreads, s.fireRedNumThreads, + s.parakeetNumThreads, ].find((x) => typeof x === 'number'); if (typeof persisted === 'number') setNumThreads(persisted); } catch { @@ -79,11 +80,12 @@ const SherpaAdvancedSettings: React.FC = () => { const handleThreadsChange = async (value: string) => { const n = Number(value); setNumThreads(n); - // 三族共用同一运行库,线程数统一应用到三引擎设置。 + // 各族共用同一运行库,线程数统一应用到全部 Sherpa ASR 设置。 await Promise.all([ window?.ipc?.invoke('set-funasr-settings', { numThreads: n }), window?.ipc?.invoke('set-qwen-settings', { numThreads: n }), window?.ipc?.invoke('set-firered-settings', { numThreads: n }), + window?.ipc?.invoke('set-parakeet-settings', { numThreads: n }), ]); }; @@ -133,9 +135,9 @@ const SherpaAdvancedSettings: React.FC = () => { }; /** - * sherpa 系引擎(FunASR · Qwen · FireRed)合并管理面板。 + * sherpa 系引擎合并管理面板。 * - * 三者共用同一 sherpa-onnx 原生运行库(已随应用内置),差异仅在模型与少量参数。 + * 各引擎共用同一 sherpa-onnx 原生运行库(已随应用内置),差异仅在模型与少量参数。 * 运行库内置故不再做安装检测:状态只看「是否已下载模型」。 * 顶部一次性声明运行库已内置;下方按模型族分区(仅模型清单); * 全部高级设置(线程数 + ITN)合并到底部单独一处。 @@ -170,7 +172,7 @@ const SherpaEngineGroupPanel: React.FC = ({ {t('engines.sherpa.desc')}

- {/* 共享运行库卡:三族同一份内置运行库,恒为就绪,只此一处 */} + {/* 共享运行库卡:各族同一份内置运行库,恒为就绪,只此一处 */}
{t('engines.sherpa.builtinRuntime')} @@ -189,7 +191,7 @@ const SherpaEngineGroupPanel: React.FC = ({

)} - {/* 三族分区:仅模型清单(复用 ModelLibrarySection 的下载/导入/删除/换路径) */} + {/* 各族分区:仅模型清单(复用 ModelLibrarySection 的下载/导入/删除/换路径) */}
{families.map((f, index) => ( = ({ ))}
- {/* 合并的高级设置:线程数(三族统一)+ ITN(仅 FunASR) */} + {/* 合并的高级设置:线程数(各族统一)+ ITN(仅 FunASR) */} diff --git a/renderer/components/resources/engines/useSherpaRuntime.ts b/renderer/components/resources/engines/useSherpaRuntime.ts index 99fc3ab1..f2d412fb 100644 --- a/renderer/components/resources/engines/useSherpaRuntime.ts +++ b/renderer/components/resources/engines/useSherpaRuntime.ts @@ -8,7 +8,7 @@ export interface SherpaRuntime { } /** - * FunASR / Qwen / FireRed 共用的 sherpa-onnx 原生运行库已随安装包内置(不再运行时下载)。 + * 本地 sherpa ASR 引擎共用的原生运行库已随安装包内置(不再运行时下载)。 * 此 hook 仅查询内置状态(installed + 内置版本),供各引擎面板展示「已随应用内置」。 * 状态上提到常驻挂载的父组件(EngineModelTab)统一持有,避免各面板重复查询。 */ diff --git a/renderer/components/tasks/InlineConfigBar.tsx b/renderer/components/tasks/InlineConfigBar.tsx index c1704fc6..8a3c7257 100644 --- a/renderer/components/tasks/InlineConfigBar.tsx +++ b/renderer/components/tasks/InlineConfigBar.tsx @@ -162,6 +162,9 @@ const InlineConfigBar: React.FC = ({ fireRedVadInstalled={systemInfo?.fireRedVadInstalled} fireRedModelsInstalled={systemInfo?.fireRedModelsInstalled} fireRedEngineInstalled={systemInfo?.fireRedEngineInstalled} + parakeetVadInstalled={systemInfo?.parakeetVadInstalled} + parakeetModelsInstalled={systemInfo?.parakeetModelsInstalled} + parakeetEngineInstalled={systemInfo?.parakeetEngineInstalled} includeLocalCli={includeLocalCli} /> ) : ( diff --git a/renderer/lib/engineModels.ts b/renderer/lib/engineModels.ts index 461a9708..0b21eba8 100644 --- a/renderer/lib/engineModels.ts +++ b/renderer/lib/engineModels.ts @@ -37,6 +37,12 @@ export interface EngineModelInfo { fireRedModelsInstalled?: string[]; /** fireRed 运行库(sherpa-onnx,与 funasr 同库)是否已安装 */ fireRedEngineInstalled?: boolean; + /** Parakeet 共享 silero VAD 是否就绪 */ + parakeetVadInstalled?: boolean; + /** Parakeet 已安装的模型 id 列表 */ + parakeetModelsInstalled?: string[]; + /** Parakeet 运行库(sherpa-onnx,与其它本地 sherpa ASR 共用)是否已安装 */ + parakeetEngineInstalled?: boolean; } /** 解析当前转写引擎,兼容旧的 useLocalWhisper 开关 */ @@ -73,6 +79,9 @@ export function getInstalledModelsForEngine( if (engine === 'fireRedAsr') { return info?.fireRedModelsInstalled ?? []; } + if (engine === 'parakeet') { + return info?.parakeetModelsInstalled ?? []; + } return info?.modelsInstalled ?? []; } @@ -102,6 +111,9 @@ export function getSelectableModelsForEngine( if (engine === 'fireRedAsr') { return info?.fireRedModelsInstalled ?? []; } + if (engine === 'parakeet') { + return info?.parakeetModelsInstalled ?? []; + } return info?.modelsInstalled ?? []; } @@ -129,6 +141,12 @@ export function hasModelsForEngine( (info?.fireRedModelsInstalled?.length ?? 0) > 0 ); } + if (engine === 'parakeet') { + return ( + !!info?.parakeetVadInstalled && + (info?.parakeetModelsInstalled?.length ?? 0) > 0 + ); + } return getInstalledModelsForEngine(info, useLocalWhisper).length > 0; } @@ -176,9 +194,7 @@ export function encodeEngineModel( } /** 解析分组下拉选项 value 为 (引擎,模型[,云实例]);非法返回 null。 */ -export function decodeEngineModel( - value: string | undefined, -): { +export function decodeEngineModel(value: string | undefined): { engine: TranscriptionEngine; model: string; asrProviderId?: string; @@ -226,8 +242,8 @@ function isFasterWhisperRunnable(info: EngineModelInfo | undefined): boolean { * 仅纳入「引擎运行时已安装」的引擎——只下了模型但没装对应引擎不可转写,故从任务选择中过滤掉。 * - builtin: ggml 已装模型(内置运行时,始终可运行) * - fasterWhisper: ct2 已装模型,且引擎包已安装(`pythonEngineStatus.state==='ready'`) - * - funasr / qwen / fireRedAsr: 需 VAD 就绪 + 至少一个模型即可。 - * 三族共用的 sherpa-onnx 运行库现随安装包内置(见 sherpaLibPaths / fetch-sherpa-native), + * - funasr / qwen / fireRedAsr / parakeet: 需 VAD 就绪 + 至少一个模型即可。 + * 各族共用的 sherpa-onnx 运行库现随安装包内置(见 sherpaLibPaths / fetch-sherpa-native), * 不再单独安装,故口径与引擎页(`is*Ready()` = 内置 VAD + 模型)一致, * 不再附加 `*EngineInstalled` 条件——否则会出现「引擎页显示已就绪、任务页下拉却不列出」的不一致。 * - localCli: 用户自备模型/命令,无"已装模型"概念;仅当 `includeLocalCli` 时以 @@ -263,6 +279,11 @@ export function getEngineModelGroups( groups.push({ engine: 'fireRedAsr', models: fireRedModels }); } + const parakeetModels = info?.parakeetModelsInstalled ?? []; + if (info?.parakeetVadInstalled && parakeetModels.length) { + groups.push({ engine: 'parakeet', models: parakeetModels }); + } + if (opts?.includeLocalCli) { groups.push({ engine: 'localCli', models: models.map((m) => m.name) }); } @@ -287,7 +308,7 @@ export function getEngineModelGroups( /** * 跨引擎就绪判断:"任意引擎装有任意可运行模型即视为就绪"。 * 用于新手引导 / 全景概览 / 任务页"去下载模型"引导。 - * 与 getEngineModelGroups 同口径:fw 还需引擎包已安装;funasr/qwen/fireRedAsr 的 + * 与 getEngineModelGroups 同口径:fw 还需引擎包已安装;本地 sherpa ASR 的 * sherpa-onnx 运行库随包内置(见 getEngineModelGroups 注释),只看内置 VAD + 模型; * localCli 不计入(自备模型,无可下载模型;其可用性由是否配置命令决定,另行处理)。 */ @@ -319,6 +340,12 @@ export function hasAnyModelAnyEngine( ) { return true; } + if ( + info?.parakeetVadInstalled && + (info?.parakeetModelsInstalled?.length ?? 0) > 0 + ) { + return true; + } return false; } diff --git a/renderer/lib/engineViews.ts b/renderer/lib/engineViews.ts index 1e88710d..eb3f89b0 100644 --- a/renderer/lib/engineViews.ts +++ b/renderer/lib/engineViews.ts @@ -3,7 +3,7 @@ import { CLOUD_VIEW_PREFIX } from '../../types/asrProvider'; /** * 「引擎与模型」左栏视图 id 体系。 * - * 本地四视图为固定枚举('sherpa' 是 FunASR · Qwen · FireRed 的 UI 合并组); + * 本地四视图为固定枚举('sherpa' 是各本地 sherpa ASR 模型族的 UI 合并组); * 云端听写为逐条目平级入口,视图 id 形如 `cloud:`(品牌/孤儿)、 * `cloud::`(预设槽位)、`cloud::i:` * (自定义实例),见 types/asrProvider 的 cloudViewId 家族 / buildCloudViews。 diff --git a/renderer/lib/importModel.ts b/renderer/lib/importModel.ts index f9f38d00..8ca491a3 100644 --- a/renderer/lib/importModel.ts +++ b/renderer/lib/importModel.ts @@ -10,7 +10,7 @@ export type ImportOutcome = | { kind: 'error'; message: string }; export async function importModelFromFolder( - engine: 'funasr' | 'qwen' | 'fireRedAsr' | 'fasterWhisper', + engine: 'funasr' | 'qwen' | 'fireRedAsr' | 'parakeet' | 'fasterWhisper', modelId: string, ): Promise { try { diff --git a/renderer/lib/resolveModelDownloadUrl.ts b/renderer/lib/resolveModelDownloadUrl.ts index 57a170f5..1dd2dee2 100644 --- a/renderer/lib/resolveModelDownloadUrl.ts +++ b/renderer/lib/resolveModelDownloadUrl.ts @@ -5,7 +5,12 @@ * 导致与真实下载链接漂移。解析失败(未知模型/源、IPC 异常)统一返回 null, * 由调用方(气泡复制按钮)给出失败提示。 */ -export type ModelUrlScope = 'funasr' | 'qwen' | 'firered' | 'pyEngine'; +export type ModelUrlScope = + | 'funasr' + | 'qwen' + | 'firered' + | 'parakeet' + | 'pyEngine'; export async function resolveModelDownloadUrl( scope: ModelUrlScope, diff --git a/renderer/lib/subtitleOutcome.ts b/renderer/lib/subtitleOutcome.ts index 29542c0f..ae0ef237 100644 --- a/renderer/lib/subtitleOutcome.ts +++ b/renderer/lib/subtitleOutcome.ts @@ -17,7 +17,7 @@ export const SUBTITLE_OUTCOME_TIERS: Exclude[] = [ 'clean', ]; -const SHERPA_ENGINES = new Set(['funasr', 'qwen', 'fireRedAsr']); +const SHERPA_ENGINES = new Set(['funasr', 'qwen', 'fireRedAsr', 'parakeet']); /** sherpa 系:VAD 结构性常开、无上下文/抗重复概念。 */ export function isSherpaEngine(engine?: string): boolean { diff --git a/renderer/public/locales/en/common.json b/renderer/public/locales/en/common.json index 95985615..7a6fb528 100644 --- a/renderer/public/locales/en/common.json +++ b/renderer/public/locales/en/common.json @@ -333,6 +333,7 @@ "funasr": "FunASR", "qwen": "Qwen3-ASR", "fireRedAsr": "FireRedASR", + "parakeet": "Parakeet TDT", "localCli": "Local command", "cloud": "Cloud ASR" } diff --git a/renderer/public/locales/en/resources.json b/renderer/public/locales/en/resources.json index 4ddc5bd1..7072782a 100644 --- a/renderer/public/locales/en/resources.json +++ b/renderer/public/locales/en/resources.json @@ -83,18 +83,18 @@ }, "sherpa": { "name": "Local multi-model engine", - "subtitle": "FunASR · Qwen · FireRed", - "tags": ["FunASR", "Qwen3-ASR", "FireRedASR"], - "desc": "FunASR, Qwen3-ASR and FireRedASR share one bundled sherpa-onnx runtime; they differ only in models and a few parameters. Pick and download a model from any family below to start — fully offline on CPU, no GPU or Python required.", - "builtinRuntime": "The runtime is bundled with the app (shared by FunASR · Qwen · FireRed) — no separate download needed.", + "subtitle": "FunASR · Qwen · FireRed · Parakeet", + "tags": ["FunASR", "Qwen3-ASR", "FireRedASR", "Parakeet TDT"], + "desc": "FunASR, Qwen3-ASR, FireRedASR and NVIDIA Parakeet share one bundled sherpa-onnx runtime; they differ only in models and a few parameters. Pick and download a model from any family below to start — fully offline, no Python required.", + "builtinRuntime": "The runtime is bundled with the app and shared by all four model families — no separate download needed.", "installedVersion": "Bundled v{{version}}", "needsModels": "Download a model from any family to get started", "advanced": "Advanced settings", "numThreads": "Inference threads", - "numThreadsHint": "Threads used for CPU inference — higher is faster but uses more CPU (shared by all three families)", + "numThreadsHint": "Threads used for CPU inference — higher is faster but uses more CPU (shared by all four families)", "itn": "Inverse text normalization (ITN)", "itnHint": "Converts spoken numbers, dates, etc. into standard written form and restores punctuation", - "itnFunasrOnly": "Only supported by FunASR (SenseVoice); Qwen and FireRed handle normalization internally." + "itnFunasrOnly": "Only supported by FunASR (SenseVoice); Qwen, FireRed and Parakeet handle text formatting internally." }, "funasr": { "name": "FunASR", @@ -259,6 +259,34 @@ } } }, + "parakeet": { + "name": "NVIDIA Parakeet", + "builtinRuntime": "The runtime is bundled with the app and shared with the other sherpa ASR engines.", + "desc": "NVIDIA Parakeet TDT 0.6B v3 running locally through sherpa-onnx. It supports 25 European languages, punctuation and capitalization. The int8 model runs on CPU without Python; segment timestamps come from VAD.", + "scenario": "Best for: English and European-language transcription, CC-BY-4.0", + "needsModels": "Models needed", + "anotherDownload": "Another download is in progress. Please wait for it to finish.", + "downloading": "Downloading…", + "extracting": "Extracting… (this can take a while)", + "downloadSource": "Download source", + "modelsTitle": "Models", + "modelDownload": "Download", + "modelDelete": "Delete", + "modelSources": { + "ghproxy": "GitHub Mirror (China)", + "github": "GitHub" + }, + "modelSourceHint": { + "ghproxy": "Downloads the official sherpa-onnx release through gh-proxy.com, then extracts it", + "github": "Direct official sherpa-onnx GitHub release; recommended outside mainland China" + }, + "models": { + "parakeet-tdt-0.6b-v3": { + "name": "Parakeet TDT 0.6B v3 (int8, ~670MB)", + "desc": "25 European languages with punctuation and capitalization; optimized NeMo transducer model for accurate English transcription." + } + } + }, "localCli": { "name": "Local CLI", "desc": "Use a self-installed Whisper-compatible command-line tool", diff --git a/renderer/public/locales/en/tasks.json b/renderer/public/locales/en/tasks.json index b83ef5a3..00b7b978 100644 --- a/renderer/public/locales/en/tasks.json +++ b/renderer/public/locales/en/tasks.json @@ -146,7 +146,7 @@ "title": "Custom", "desc": "Manually tune context length, VAD, repetition reduction and other low-level parameters.", "sectionTitle": "Custom parameters", - "sherpaNote": "For the current engine (FunASR / Qwen / FireRed), VAD is always on and there is no context-length / repetition concept. To fine-tune VAD sensitivity, use the Settings page." + "sherpaNote": "For the current engine (FunASR / Qwen / FireRed / Parakeet), VAD is always on and there is no context-length / repetition concept. To fine-tune VAD sensitivity, use the Settings page." }, "compare": { "toggle": "See how the effects differ", diff --git a/renderer/public/locales/zh/common.json b/renderer/public/locales/zh/common.json index 65a7739e..7d727017 100644 --- a/renderer/public/locales/zh/common.json +++ b/renderer/public/locales/zh/common.json @@ -333,6 +333,7 @@ "funasr": "FunASR", "qwen": "Qwen3-ASR", "fireRedAsr": "FireRedASR", + "parakeet": "Parakeet TDT", "localCli": "本地命令", "cloud": "云端听写" } diff --git a/renderer/public/locales/zh/resources.json b/renderer/public/locales/zh/resources.json index 9c2fd0d7..272f862f 100644 --- a/renderer/public/locales/zh/resources.json +++ b/renderer/public/locales/zh/resources.json @@ -83,18 +83,18 @@ }, "sherpa": { "name": "本地多模型引擎", - "subtitle": "FunASR · Qwen · FireRed", - "tags": ["FunASR", "Qwen3-ASR", "FireRedASR"], - "desc": "FunASR、Qwen3-ASR、FireRedASR 共用同一套 sherpa-onnx 本地运行库(已随应用内置),区别只在模型与少量参数。在下方按需选择并下载对应模型即可使用,全程 CPU 离线、无需显卡或 Python。", - "builtinRuntime": "运行库已随应用内置(FunASR · Qwen · FireRed 共用),无需单独下载。", + "subtitle": "FunASR · Qwen · FireRed · Parakeet", + "tags": ["FunASR", "Qwen3-ASR", "FireRedASR", "Parakeet TDT"], + "desc": "FunASR、Qwen3-ASR、FireRedASR、NVIDIA Parakeet 共用同一套 sherpa-onnx 本地运行库(已随应用内置),区别只在模型与少量参数。在下方按需选择并下载对应模型即可使用,全程离线、无需 Python。", + "builtinRuntime": "运行库已随应用内置,由四个模型族共用,无需单独下载。", "installedVersion": "内置 v{{version}}", "needsModels": "下载任一族模型即可开始使用", "advanced": "高级设置", "numThreads": "推理线程数", - "numThreadsHint": "CPU 推理使用的线程数,越大越快但占用更多 CPU(三族共用同一运行库,统一生效)", + "numThreadsHint": "CPU 推理使用的线程数,越大越快但占用更多 CPU(四族共用同一运行库,统一生效)", "itn": "逆文本规整(ITN)", "itnHint": "将口语化的数字、日期等转换为标准书写形式,并自动补全标点", - "itnFunasrOnly": "仅 FunASR(SenseVoice)支持;Qwen、FireRed 已在内部处理规整。" + "itnFunasrOnly": "仅 FunASR(SenseVoice)支持;Qwen、FireRed、Parakeet 已在内部处理文本格式。" }, "funasr": { "name": "FunASR", @@ -259,6 +259,34 @@ } } }, + "parakeet": { + "name": "NVIDIA Parakeet", + "builtinRuntime": "运行库已随应用内置,并与其它 sherpa ASR 引擎共用。", + "desc": "NVIDIA Parakeet TDT 0.6B v3 本地引擎,基于 sherpa-onnx 运行,支持 25 种欧洲语言、自带标点与大小写。int8 模型可在 CPU 上运行,无需 Python;段级时间戳来自 VAD。", + "scenario": "适合:英文及欧洲语言高质量转写,CC-BY-4.0", + "needsModels": "需下载模型", + "anotherDownload": "已有下载任务进行中,请等待完成后再试。", + "downloading": "下载中…", + "extracting": "解包中…(可能需要一会儿)", + "downloadSource": "下载源", + "modelsTitle": "模型", + "modelDownload": "下载", + "modelDelete": "删除", + "modelSources": { + "ghproxy": "GitHub 国内加速", + "github": "GitHub" + }, + "modelSourceHint": { + "ghproxy": "经 gh-proxy.com 下载 sherpa-onnx 官方整包,下载后自动解包", + "github": "直连 sherpa-onnx 官方 GitHub Release,适合海外网络" + }, + "models": { + "parakeet-tdt-0.6b-v3": { + "name": "Parakeet TDT 0.6B v3(int8,约 670MB)", + "desc": "支持 25 种欧洲语言、自带标点与大小写;NeMo transducer 架构,英文识别准确率高。" + } + } + }, "localCli": { "name": "本地命令行", "desc": "使用自行安装的 Whisper 兼容 CLI", diff --git a/renderer/public/locales/zh/tasks.json b/renderer/public/locales/zh/tasks.json index 21bba3c6..ef3536f9 100644 --- a/renderer/public/locales/zh/tasks.json +++ b/renderer/public/locales/zh/tasks.json @@ -146,7 +146,7 @@ "title": "自定义", "desc": "手动调整上下文长度、VAD、抗重复等底层参数。", "sectionTitle": "自定义参数", - "sherpaNote": "当前引擎(FunASR / Qwen / FireRed)的 VAD 为结构性常开,且无「上下文长度 / 抗重复」概念。如需细调 VAD 灵敏度,请到「设置」页调整。" + "sherpaNote": "当前引擎(FunASR / Qwen / FireRed / Parakeet)的 VAD 为结构性常开,且无「上下文长度 / 抗重复」概念。如需细调 VAD 灵敏度,请到「设置」页调整。" }, "compare": { "toggle": "看看不同效果的字幕差异", diff --git a/scripts/fetch-sherpa-native.mjs b/scripts/fetch-sherpa-native.mjs index 70e6bd18..001d3915 100644 --- a/scripts/fetch-sherpa-native.mjs +++ b/scripts/fetch-sherpa-native.mjs @@ -2,7 +2,7 @@ /** * 构建期拉取 sherpa-onnx 原生库到 extraResources/sherpa/native//。 * - * funasr / qwen / fireRedAsr 三引擎共用 sherpa-onnx 原生运行库。过去它在运行时下载 + * 本地 sherpa ASR 引擎共用 sherpa-onnx 原生运行库。过去它在运行时下载 * 到 userData(下载/重签/自检失败面大);现改为**随安装包内置**(像 whisper.cpp 的 * addon.node 一样走 extraResources,asar 内 .node 不可 dlopen 的限制只针对 asar, * extraResources 不受限)。 @@ -58,7 +58,13 @@ function releaseUrl(asset) { async function main() { const platformKey = getPlatformKey(); const asset = assetName(platformKey); - const outDir = path.join(root, 'extraResources', 'sherpa', 'native', platformKey); + const outDir = path.join( + root, + 'extraResources', + 'sherpa', + 'native', + platformKey, + ); const tmp = path.join(os.tmpdir(), asset); console.log(`Fetching ${asset} ...`); diff --git a/scripts/test-engine-units.ts b/scripts/test-engine-units.ts index 0491e109..d5c3262f 100644 --- a/scripts/test-engine-units.ts +++ b/scripts/test-engine-units.ts @@ -73,6 +73,7 @@ import { resolveQwenSelection, } from '../main/helpers/qwenModelCatalog'; import { FIRERED_MODELS } from '../main/helpers/fireRedModelCatalog'; +import { PARAKEET_MODELS } from '../main/helpers/parakeetModelCatalog'; import { CT2_REQUIRED_FILES, CT2_REQUIRED_CONFIG_ARRAYS, @@ -89,12 +90,19 @@ import { prepareDownloadTarget, validateDownloadResponse, } from '../main/helpers/download/resumeIntegrity'; +import { commitStagedDirectory } from '../main/helpers/download/atomicDirectoryInstall'; +import { DownloadSessionTracker } from '../main/helpers/download/downloadSession'; +import { + downloadFileSingle, + SINGLE_DOWNLOAD_CANCELLED, +} from '../main/helpers/download/singleFileDownloader'; import { fetchJson } from '../main/helpers/download/fetchJson'; import { buildVadConfig, buildRecognizerConfig, buildQwenRecognizerConfig, buildFireRedRecognizerConfig, + buildParakeetRecognizerConfig, segmentTiming, progressPercent, } from '../main/helpers/sherpaOnnx/sherpaConfig'; @@ -105,10 +113,13 @@ import { FIRERED_HARD_MAX_SPEECH_S, FIRERED_DEFAULT_MAX_SPEECH_S, } from '../main/helpers/engines/fireRedParams'; +import { buildParakeetParams } from '../main/helpers/engines/parakeetParams'; import { getSelectableModelsForEngine, getInstalledModelsForEngine, + getEngineModelGroups, hasModelsForEngine, + hasAnyModelAnyEngine, } from '../renderer/lib/engineModels'; import { formatFunasrDownloadFailureToast, @@ -556,6 +567,7 @@ eq( 'funasr', 'qwen', 'fireRedAsr', + 'parakeet', 'cloud', undefined, ].some(supportsFasterWhisperAdvancedParams), @@ -1379,6 +1391,140 @@ eq( 'sherpa: fire_red_asr has no qwen3Asr block', ); +// --- engineModels/catalog: NVIDIA Parakeet awareness --- +const parakeetReady = { + transcriptionEngine: 'parakeet' as const, + parakeetEngineInstalled: true, + parakeetVadInstalled: true, + parakeetModelsInstalled: ['parakeet-tdt-0.6b-v3'], +}; +eq( + getSelectableModelsForEngine(parakeetReady), + ['parakeet-tdt-0.6b-v3'], + 'engineModels: parakeet selectable = installed models', +); +eq( + getInstalledModelsForEngine(parakeetReady), + ['parakeet-tdt-0.6b-v3'], + 'engineModels: parakeet installed = installed models', +); +eq( + hasModelsForEngine(parakeetReady), + true, + 'engineModels: parakeet ready w/ vad+model', +); +eq( + getEngineModelGroups(parakeetReady), + [ + { + engine: 'parakeet', + models: ['parakeet-tdt-0.6b-v3'], + }, + ], + 'engineModels: parakeet appears in task model groups', +); +eq( + hasAnyModelAnyEngine(parakeetReady), + true, + 'engineModels: parakeet satisfies cross-engine readiness', +); +eq( + hasModelsForEngine({ + transcriptionEngine: 'parakeet', + parakeetVadInstalled: false, + parakeetModelsInstalled: ['parakeet-tdt-0.6b-v3'], + }), + false, + 'engineModels: parakeet not ready without vad', +); +eq( + hasModelsForEngine({ + transcriptionEngine: 'parakeet', + parakeetVadInstalled: true, + parakeetModelsInstalled: [], + }), + false, + 'engineModels: parakeet not ready without model', +); +eq( + PARAKEET_MODELS['parakeet-tdt-0.6b-v3'].requiredFiles, + ['encoder.int8.onnx', 'decoder.int8.onnx', 'joiner.int8.onnx', 'tokens.txt'], + 'parakeet: catalog validates the complete int8 transducer layout', +); +eq( + { + upstreamModel: PARAKEET_MODELS['parakeet-tdt-0.6b-v3'].upstreamModel, + license: PARAKEET_MODELS['parakeet-tdt-0.6b-v3'].license, + languageCount: PARAKEET_MODELS['parakeet-tdt-0.6b-v3'].languageCount, + supportsPunctuation: + PARAKEET_MODELS['parakeet-tdt-0.6b-v3'].supportsPunctuation, + }, + { + upstreamModel: 'nvidia/parakeet-tdt-0.6b-v3', + license: 'CC-BY-4.0', + languageCount: 25, + supportsPunctuation: true, + }, + 'parakeet: catalog exposes upstream capability and license metadata', +); + +const PARAKEET_RP = { num_threads: 4, provider: 'cpu' }; +const parakeetRecognizerConfig = buildParakeetRecognizerConfig( + { + encoder: '/m/encoder.int8.onnx', + decoder: '/m/decoder.int8.onnx', + joiner: '/m/joiner.int8.onnx', + }, + '/m/tokens.txt', + PARAKEET_RP, +); +eq( + parakeetRecognizerConfig.modelConfig.transducer, + { + encoder: '/m/encoder.int8.onnx', + decoder: '/m/decoder.int8.onnx', + joiner: '/m/joiner.int8.onnx', + }, + 'sherpa: parakeet maps encoder+decoder+joiner', +); +eq( + parakeetRecognizerConfig.modelConfig.tokens, + '/m/tokens.txt', + 'sherpa: parakeet uses top-level tokens', +); +eq( + parakeetRecognizerConfig.modelConfig.modelType, + 'nemo_transducer', + 'sherpa: parakeet explicitly selects nemo_transducer', +); +eq( + buildParakeetParams({}), + { + provider: 'cpu', + num_threads: 2, + vad_threshold: 0.5, + vad_min_silence_duration_ms: 100, + vad_min_speech_duration_ms: 250, + vad_max_speech_duration_s: 0, + }, + 'parakeet: default params reuse shared VAD defaults', +); +eq( + buildParakeetParams({ + parakeetProvider: 'cuda', + parakeetNumThreads: 8, + }), + { + provider: 'cuda', + num_threads: 8, + vad_threshold: 0.5, + vad_min_silence_duration_ms: 100, + vad_min_speech_duration_ms: 250, + vad_max_speech_duration_s: 0, + }, + 'parakeet: provider and threads passthrough', +); + // --- fireRedParams: 默认值 + 段长安全闸(design D8) --- eq( buildFireRedParams({}), @@ -1471,6 +1617,28 @@ eq( fs.rmSync(tmp, { recursive: true, force: true }); } +// --- downloadSession: stale finally 不能清掉新会话 --- +{ + const tracker = new DownloadSessionTracker(); + const oldSession = tracker.begin(); + const newSession = tracker.begin(); + eq( + tracker.finish(oldSession), + false, + 'download session: stale completion does not own active session', + ); + eq( + tracker.owns(newSession), + true, + 'download session: new session survives stale completion', + ); + eq( + tracker.finish(newSession), + true, + 'download session: active completion clears its own session', + ); +} + // --- modelImport: resolveOverridePath(覆盖优先/空值回退) --- eq( resolveOverridePath('/custom/models', '/default/models'), @@ -2844,7 +3012,7 @@ eq( 'outcome/fw: clean → reduceRepetition on', ); - // sherpa(funasr/qwen/fireRedAsr):只映射 VAD 灵敏度,绝不关 VAD / 设 ctx / 抗重复 + // sherpa:只映射 VAD 灵敏度,绝不关 VAD / 设 ctx / 抗重复 const sherpaAccurate = resolveEffectiveSettings( { transcriptionEngine: 'funasr', subtitleOutcome: 'accurate' }, {}, @@ -2890,6 +3058,14 @@ eq( 0.5, 'outcome/sherpa(fireRed): balanced → VAD standard threshold', ); + eq( + resolveEffectiveSettings( + { transcriptionEngine: 'parakeet', subtitleOutcome: 'clean' }, + {}, + ).vadThreshold, + 0.65, + 'outcome/sherpa(parakeet): clean → VAD conservative threshold', + ); // custom 档:回读用户底层值(builtin 从 formData.maxContext 取) eq( @@ -3062,6 +3238,7 @@ eq( eq(isSherpaEngineId('funasr'), true, 'isSherpa: funasr'); eq(isSherpaEngineId('qwen'), true, 'isSherpa: qwen'); eq(isSherpaEngineId('fireRedAsr'), true, 'isSherpa: fireRedAsr'); + eq(isSherpaEngineId('parakeet'), true, 'isSherpa: parakeet'); eq(isSherpaEngineId('builtin'), false, 'isSherpa: builtin no'); eq(isSherpaEngineId('fasterWhisper'), false, 'isSherpa: fasterWhisper no'); eq( @@ -6178,7 +6355,7 @@ async function runAsyncConcurrencyTests(): Promise { bAcquired = true; return r; }); - const pC = acquireTranscribeSlot('fireRedAsr').then((r) => { + const pC = acquireTranscribeSlot('parakeet').then((r) => { cAcquired = true; return r; }); @@ -6249,6 +6426,154 @@ async function runAsyncConcurrencyTests(): Promise { ); } + // 模型目录事务提交:提交失败恢复旧目录,成功后替换旧目录。 + { + const tmp = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'model-atomic-')); + const dest = nodePath.join(tmp, 'model'); + const missingStaged = nodePath.join(tmp, 'missing-stage'); + const rollbackBackup = nodePath.join(tmp, 'rollback-backup'); + fs.mkdirSync(dest); + fs.writeFileSync(nodePath.join(dest, 'marker.txt'), 'old'); + let rollbackError: unknown = null; + await commitStagedDirectory({ + stagedDir: missingStaged, + destDir: dest, + backupDir: rollbackBackup, + }).catch((error) => { + rollbackError = error; + }); + eq( + rollbackError instanceof Error, + true, + 'atomic install: missing staging fails the commit', + ); + eq( + fs.readFileSync(nodePath.join(dest, 'marker.txt'), 'utf8'), + 'old', + 'atomic install: failed commit restores the existing model', + ); + + const staged = nodePath.join(tmp, 'valid-stage'); + const successBackup = nodePath.join(tmp, 'success-backup'); + fs.mkdirSync(staged); + fs.writeFileSync(nodePath.join(staged, 'marker.txt'), 'new'); + await commitStagedDirectory({ + stagedDir: staged, + destDir: dest, + backupDir: successBackup, + }); + eq( + fs.readFileSync(nodePath.join(dest, 'marker.txt'), 'utf8'), + 'new', + 'atomic install: valid staging replaces the existing model', + ); + eq( + fs.existsSync(successBackup), + false, + 'atomic install: successful commit removes the backup', + ); + fs.rmSync(tmp, { recursive: true, force: true }); + } + + // 单连接下载:完整响应成功;截断、停滞和取消都必须可靠拒绝。 + { + const tmp = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'single-download-')); + const server = http.createServer((req, res) => { + if (req.url === '/ok') { + res.writeHead(200, { 'Content-Length': '5' }); + res.end('hello'); + return; + } + if (req.url === '/truncated') { + res.writeHead(200, { 'Content-Length': '10' }); + res.end('short'); + return; + } + if (req.url === '/stall') { + res.writeHead(200, { 'Content-Length': '5' }); + res.flushHeaders(); + return; + } + res.writeHead(404); + res.end(); + }); + const port = await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('test server did not expose a TCP port')); + return; + } + resolve(address.port); + }); + }); + const base = `http://127.0.0.1:${port}`; + try { + const okPath = nodePath.join(tmp, 'ok.bin'); + await downloadFileSingle({ + url: `${base}/ok`, + destPath: okPath, + timeoutMs: 100, + }); + eq( + fs.readFileSync(okPath, 'utf8'), + 'hello', + 'single download: complete response closes successfully', + ); + + let truncatedError: unknown = null; + await downloadFileSingle({ + url: `${base}/truncated`, + destPath: nodePath.join(tmp, 'truncated.bin'), + timeoutMs: 100, + }).catch((error) => { + truncatedError = error; + }); + eq( + truncatedError instanceof Error, + true, + 'single download: truncated content-length is rejected', + ); + + let timeoutError: unknown = null; + await downloadFileSingle({ + url: `${base}/stall`, + destPath: nodePath.join(tmp, 'timeout.bin'), + timeoutMs: 40, + }).catch((error) => { + timeoutError = error; + }); + eq( + String(timeoutError).includes('timed out'), + true, + 'single download: stalled response hits the inactivity timeout', + ); + + const abort = new AbortController(); + let cancelError: unknown = null; + const pending = downloadFileSingle({ + url: `${base}/stall`, + destPath: nodePath.join(tmp, 'cancel.bin'), + signal: abort.signal, + timeoutMs: 1000, + }).catch((error) => { + cancelError = error; + }); + setTimeout(() => abort.abort(), 20); + await pending; + eq( + cancelError instanceof Error && + cancelError.message === SINGLE_DOWNLOAD_CANCELLED, + true, + 'single download: abort rejects with the shared cancellation marker', + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(tmp, { recursive: true, force: true }); + } + } + // 云端服务商闸:并发上限跨调用共享 { const gate = getCloudProviderGate('test-provider-a'); diff --git a/scripts/test-storage-paths.ts b/scripts/test-storage-paths.ts index a7b32904..94090925 100644 --- a/scripts/test-storage-paths.ts +++ b/scripts/test-storage-paths.ts @@ -105,6 +105,7 @@ function run(): void { funasr: ['models', 'funasr'], qwen: ['models', 'qwen'], firered: ['models', 'firered'], + parakeet: ['models', 'parakeet'], tts: ['models', 'tts'], }; (Object.keys(expectedSubpaths) as StorageKind[]).forEach((kind) => { diff --git a/types/engine.ts b/types/engine.ts index bedf0fef..1d30b798 100644 --- a/types/engine.ts +++ b/types/engine.ts @@ -4,6 +4,7 @@ export type TranscriptionEngine = | 'funasr' | 'qwen' | 'fireRedAsr' + | 'parakeet' | 'localCli' /** 云端听写(在线 ASR):单一适配器,按 asrProviderId 实例分发到具体 service。 */ | 'cloud'; @@ -79,7 +80,7 @@ export interface RemoteEngineManifest { runtime?: RemoteRuntimeInfo; } -/** 可独立下载的 Python 引擎运行时标识。faster-whisper 是唯一 Python 引擎(funasr/qwen/firered 已改用内置 sherpa-onnx 原生库)。 */ +/** 可独立下载的 Python 引擎运行时标识。faster-whisper 是唯一 Python 引擎(其余本地 ASR 使用内置 sherpa-onnx)。 */ export type PyEngineId = 'faster-whisper'; export interface PyEngineUpdateInfo { diff --git a/types/types.ts b/types/types.ts index 3b3b3f43..a744005d 100644 --- a/types/types.ts +++ b/types/types.ts @@ -39,6 +39,14 @@ export interface ISystemInfo { fireRedModelsInstalled?: string[]; /** fireRed 模型根目录(固定路径,仅展示用,不可更改) */ fireRedModelsPath?: string; + /** Parakeet 引擎包(sherpa-onnx,与其它本地 sherpa ASR 共用)是否已安装 */ + parakeetEngineInstalled?: boolean; + /** Parakeet 共用 silero VAD 是否已安装 */ + parakeetVadInstalled?: boolean; + /** 已安装的 Parakeet 模型 id */ + parakeetModelsInstalled?: string[]; + /** Parakeet 模型根目录 */ + parakeetModelsPath?: string; /** userData 默认存储基座(「默认路径含中文」警示判定用) */ userDataPath?: string; /** 统一存储根目录原始设置值('' = 未设置) */ @@ -50,6 +58,7 @@ export interface ISystemInfo { funasr: StoragePathSource; qwen: StoragePathSource; firered: StoragePathSource; + parakeet: StoragePathSource; }; }