Skip to content

feat: 集成 SenseVoice 字幕引擎,支持多模型选择#775

Open
BluerAngala wants to merge 3 commits into
webadderallorg:mainfrom
BluerAngala:feat/sensevoice-asr-engine
Open

feat: 集成 SenseVoice 字幕引擎,支持多模型选择#775
BluerAngala wants to merge 3 commits into
webadderallorg:mainfrom
BluerAngala:feat/sensevoice-asr-engine

Conversation

@BluerAngala

@BluerAngala BluerAngala commented Jul 21, 2026

Copy link
Copy Markdown

解决的问题

1. 字幕引擎扩展(非硬编码)

原 Recordly 只支持 whisper.cpp,模型和引擎都硬编码。本次新增模型注册表 + 引擎抽象,支持多模型自由切换。

  • 新增 electron/ipc/captions/models.ts 定义模型注册表,每个模型标注 engine 类型(whisper / sensevoice)
  • 新增 electron/ipc/captions/engine.ts 引擎抽象接口
  • 新增 electron/ipc/captions/sensevoice.ts SenseVoice 引擎实现(基于 sherpa-onnx)

2. 中文语音识别显著提升

集成阿里通义 SenseVoice 模型,中文识别效果远超 Whisper Large V3。

  • 内置 SenseVoice Small int8 量化版(239 MB)
  • 通过 npm run download:models 或 postinstall 自动下载
  • 默认选中 SenseVoice,用户可在设置面板自由切换

3. 字幕时间轴对齐

SenseVoice 返回逐词时间戳,按时间间隙 + 字长自动切分:

  • 间隙 ≥1s 或累计 ≥15 字切分为新字幕
  • 自然语音停顿拆分,而非仅按静音检测
  • 支持 ITN(逆文本正则化),模型自动添加标点

4. 伴音文件路径修复

resolveCaptionAudioCandidates 之前只扫描视频文件和摄像头。新增从视频同目录查找 *.mic.wav / *.system.wav 伴音文件,优先作为音频源。

5. macOS 16 兼容性

ffmpeg-static 在 macOS 16 因未签名二进制被系统拦截。getFfmpegBinaryPath 新增 spawnSync 验证,自动 fallback 到系统 /opt/homebrew/bin/ffmpeg。

6. 字幕边距优化

字幕框 padding 从纯 fontSize 比例改为 Math.max(比例, 最小值),避免字体缩小时边距消失。

变更文件

  • electron/ipc/captions/models.ts — 模型注册表(新增)
  • electron/ipc/captions/engine.ts — 引擎接口(新增)
  • electron/ipc/captions/sensevoice.ts — SenseVoice 引擎(新增)
  • electron/ipc/captions/sherpa-onnx.d.ts — sherpa-onnx 类型声明(新增)
  • electron/ipc/captions/generate.ts — 引擎分发、音频候选
  • electron/ipc/captions/whisper.ts — 多模型下载/状态管理
  • electron/ipc/register/captions.ts — 新增多模型 IPC handlers
  • electron/preload.ts — 多模型 API 桥接
  • electron/electron-env.d.ts — 类型定义
  • electron/ipc/ffmpeg/binary.ts — macOS 16 执行性验证
  • electron-builder.json5 — 内置模型打包
  • scripts/download-bundled-models.mjs — 模型下载脚本(新增)
  • src/components/video-editor/VideoEditor.tsx — 模型选择状态
  • src/components/video-editor/SettingsPanel.tsx — 模型选择 UI
  • src/components/video-editor/editorPreferences.ts — 持久化选中模型
  • src/components/video-editor/captionStyle.ts — 边距最小值保护
  • vite.config.ts — sherpa-onnx externals
  • package.json — sherpa-onnx 依赖

依赖变更

  • 新增: sherpa-onnx, sherpa-onnx-darwin-arm64
  • 移除: onnxruntime-node

Summary by CodeRabbit

  • New Features

    • Added support for multiple auto-captioning models, including SenseVoice and Whisper.
    • Added model selection, download, deletion, status, and progress tracking in caption settings.
    • Bundled caption models can now be included with app builds.
    • Caption generation can use the selected model and prioritize companion audio when available.
    • Saved editor presets now retain the selected caption model.
  • Bug Fixes

    • Improved validation and fallback behavior for bundled FFmpeg and FFprobe binaries.
    • Improved caption spacing and text width calculations.

- 模型从 ggml-small (466MB) 升级到 ggml-large-v3 (3.1GB),中文识别准确率大幅提升
- 下载源从 huggingface.co 改为 hf-mirror.com,解决国内无法下载的问题
- 更新 UI toast 消息
此次提交实现了完整的字幕生成系统重构:
1.  添加SenseVoice语音识别引擎,基于sherpa-onnx实现
2.  重构模型管理系统,支持多模型切换、下载、删除和状态追踪
3.  新增中文本地化翻译与模型选择UI界面
4.  优化ffmpeg二进制文件可执行性检查,适配macOS安全机制
5.  更新构建配置与.gitignore,新增模型打包与忽略规则
6.  新增开发文档索引与模型下载脚本
7.  修复多处代码规范与路径处理问题
- 新增模型注册表 support/models.ts,支持 SenseVoice(通义) + Whisper 多模型
- 集成 sherpa-onnx 运行 SenseVoice ONNX 模型,本地离线推理
- 按时间间隙(≥1s)+字符数(15字)自动切分长句为标准字幕长度
- 内置 SenseVoice int8 模型(239MB),postinstall 自动下载
- 修复 macOS 16 ffmpeg 权限兼容、伴音文件查找、路径解析
- 字幕边距增加最小值保护,视觉优化
@github-actions github-actions Bot added the Slop label Jul 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ This pull request has been flagged by Anti-Slop.
Our automated checks detected patterns commonly associated with
low-quality or automated/AI submissions (failure count reached).
No automatic closure — a maintainer will review it.
If this is legitimate work, please add more context, link issues, or ping us.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This change replaces the Whisper-small-only caption workflow with a multi-model system, adds bundled SenseVoice support, updates caption IPC and editor controls, persists model selection, refreshes macOS binaries, and adds developer documentation and packaging configuration.

Changes

Caption model workflow

Layer / File(s) Summary
Model catalog and engine contracts
electron/ipc/captions/engine.ts, electron/ipc/captions/models.ts, electron/ipc/captions/sherpa-onnx.d.ts, electron/electron-env.d.ts, electron/preload.ts
Defines caption model metadata, model path resolution, engine contracts, Sherpa types, and the renderer-facing multi-model API.
Model download and bundling flow
electron/ipc/captions/whisper.ts, scripts/download-bundled-models.mjs, package.json, electron-builder.json5, .gitignore
Adds model-ID-based downloads, auxiliary files, progress events, bundled model downloads, and model resource packaging.
SenseVoice generation and IPC routing
electron/ipc/captions/sensevoice.ts, electron/ipc/captions/generate.ts, electron/ipc/register/captions.ts, electron/ipc/ffmpeg/binary.ts
Adds SenseVoice transcription and cue generation, companion-audio selection, model dispatch, picker updates, and revised caption IPC responses.
Editor model selection and persistence
src/components/video-editor/VideoEditor.tsx, src/components/video-editor/SettingsPanel.tsx, src/components/video-editor/editorPreferences.ts, src/i18n/locales/*/settings.json
Adds model selection, per-model status and progress handling, download/delete controls, persisted selection, and translated UI text.

Native macOS artifact refresh

Layer / File(s) Summary
Regenerated macOS binaries
electron/native/bin/darwin-arm64/*, electron/native/bin/darwin-x64/*
Replaces compiled cursor-monitor, system-cursor, and window-list binaries with updated Mach-O and Swift/Objective-C metadata.

Documentation and build configuration

Layer / File(s) Summary
Developer docs and packaging configuration
dev_docs/INDEX.md, .gitignore, electron-builder.json5
Adds the developer index, ignores internal documentation paths, and updates macOS packaging targets.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant VideoEditor
  participant electronAPI
  participant registerCaptionHandlers
  participant downloadModel
  participant SenseVoiceEngine

  VideoEditor->>electronAPI: downloadModel(modelId)
  electronAPI->>registerCaptionHandlers: invoke download-model
  registerCaptionHandlers->>downloadModel: download model files
  downloadModel-->>VideoEditor: model-download-progress

  VideoEditor->>electronAPI: generateAutoCaptions(modelId)
  electronAPI->>registerCaptionHandlers: invoke generate-auto-captions
  registerCaptionHandlers->>SenseVoiceEngine: generate captions from extracted audio
  SenseVoiceEngine-->>VideoEditor: caption cues and success
Loading

Possibly related PRs

Suggested labels: Checked

Suggested reviewers: webadderall

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it misses several required template sections, including Type of Change, Related Issue(s), Testing Guide, and screenshots/video. Add the missing template sections and fill in testing steps, related issues, and a screenshot/video for the new UI.
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: SenseVoice integration and multi-model caption selection.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Checkov (3.3.8)
package.json

Traceback (most recent call last):
File "/usr/local/bin/checkov", line 2, in
from checkov.main import Checkov
ModuleNotFoundError: No module named 'checkov'

src/i18n/locales/en/settings.json

Traceback (most recent call last):
File "/usr/local/bin/checkov", line 2, in
from checkov.main import Checkov
ModuleNotFoundError: No module named 'checkov'

src/i18n/locales/zh-CN/settings.json

Traceback (most recent call last):
File "/usr/local/bin/checkov", line 2, in
from checkov.main import Checkov
ModuleNotFoundError: No module named 'checkov'


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ This pull request has been flagged by Anti-Slop.
Our automated checks detected patterns commonly associated with
low-quality or automated/AI submissions (failure count reached).
No automatic closure — a maintainer will review it.
If this is legitimate work, please add more context, link issues, or ping us.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/components/video-editor/VideoEditor.tsx (2)

2901-2980: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

handleGenerateAutoCaptions is missing selectedModelId from its dependency array, and the guard text is misleading for non-Whisper models.

selectedModelId is read at line 2948 (modelId: selectedModelId) but is not listed in the useCallback deps (2971-2980). Whenever a render changes selectedModelId without also changing one of the other listed deps (e.g. whisperModelPath staying the same for some edge cases), this memoized callback keeps referencing the previous selectedModelId and will send a stale modelId to generateAutoCaptions, causing generation to run with the wrong engine/model. This is exactly the kind of contract this PR is built around (Whisper vs. SenseVoice dispatch keyed off modelId, per electron/ipc/captions/generate.ts), so it deserves a hard dependency rather than relying on whisperModelPath as an implicit proxy.

Separately, line 2938's guard text ("Select a Whisper model or download the model first") is now inaccurate since the default/selected model can be SenseVoice — this will confuse users who have SenseVoice selected.

🔧 Suggested fixes
-			toast.error("Select a Whisper model or download the model first");
+			toast.error("Select a model or download it first");
			return;
		}
...
	}, [
		autoCaptionSettings.language,
		isGeneratingCaptions,
		webcam.sourcePath,
		syncActiveVideoSource,
		videoPath,
		videoSourcePath,
		whisperExecutablePath,
		whisperModelPath,
+		selectedModelId,
	]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/video-editor/VideoEditor.tsx` around lines 2901 - 2980, Add
selectedModelId to the dependency array of handleGenerateAutoCaptions so
generateAutoCaptions always receives the current model identifier. Update the
missing whisperModelPath guard message to refer to selecting or downloading the
selected caption model, accurately covering SenseVoice and other supported
models.

2752-2810: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Stale selectedModelId closure in this effect (mount-only deps).

The effect has [] as its dependency array, but both the onModelDownloadProgress listener and the async model-status loop compare against selectedModelId (lines 2765, 2799). Since the effect only runs once on mount, these comparisons always use the selectedModelId value captured at first render — not the current selection. If the user switches models via handleSelectModel and a model-download-progress/status event later arrives for the newly selected model, this code will silently skip updating whisperModelPath because it's still comparing against the original (e.g. default "sensevoice-small") id.

Use a ref updated on every render (or add selectedModelId to deps and accept re-subscription) so the comparison always sees the latest selection.

🔧 Suggested fix using a ref
+	const selectedModelIdRef = useRef(selectedModelId);
+	useEffect(() => {
+		selectedModelIdRef.current = selectedModelId;
+	}, [selectedModelId]);
+
 	useEffect(() => {
 		const unsubscribe = window.electronAPI.onModelDownloadProgress((state) => {
 			setModelDownloadProgress((prev) => ({
 				...prev,
 				[state.modelId]: { status: state.status, progress: state.progress },
 			}));
 			if (state.status === "downloaded" && state.path) {
 				setModelStatuses((prev) => ({
 					...prev,
 					[state.modelId]: { exists: true, path: state.path },
 				}));
-				if (state.modelId === selectedModelId) {
+				if (state.modelId === selectedModelIdRef.current) {
 					setWhisperModelPath(state.path);
 				}
 			}
 			...
 				if (result.success) {
 					setModelStatuses((prev) => ({
 						...prev,
 						[model.id]: { exists: result.exists, path: result.path },
 					}));
-					if (result.exists && result.path && model.id === selectedModelId) {
+					if (result.exists && result.path && model.id === selectedModelIdRef.current) {
 						setWhisperModelPath(result.path);
 					}
 				}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/video-editor/VideoEditor.tsx` around lines 2752 - 2810, Fix
the stale selectedModelId capture in the mount-only model-loading effect by
maintaining a ref that is updated with the latest selection on every render. Use
that ref in both the onModelDownloadProgress callback and the async model-status
loop when deciding whether to update whisperModelPath, while keeping the
effect’s single subscription behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dev_docs/INDEX.md`:
- Around line 4-7: Add blank lines immediately before and after the Markdown
table under the “文章” heading in INDEX.md, preserving the table contents and
formatting while satisfying MD058.

In `@electron-builder.json5`:
- Around line 57-76: Remove the duplicate mac configuration keys and merge the
entries into one mac block: retain the entitlements settings once, and combine
the dmg and zip targets for x64/arm64 with the dir target for arm64 in a single
target array.

In `@electron/ipc/captions/generate.ts`:
- Around line 210-236: Wrap the SenseVoice extraction and generation flow in a
try/finally block so wavPath is removed for both expected failures and
unexpected throws, while preserving the existing result handling and return
values. Also update the wavPath filename construction to include a random
suffix, matching the whisper path and preventing concurrent-run collisions.

In `@electron/ipc/captions/sensevoice.ts`:
- Around line 44-66: Update tokensToWords so each word starts at its own token
timestamp, with the first word starting at timestamps[0] rather than an
inter-token midpoint. Preserve each finalized word’s midpoint-based endMs, but
set the next word’s startMs from the current token’s timestamp so SenseVoice
gapToPrev can detect pauses.
- Around line 92-125: Wrap the recognizer lifecycle in an outer try/finally so
recognizer.free() always runs, including when sherpa.readWave(audioPath) fails.
Keep stream.free() in its existing inner cleanup block, and ensure wave loading
occurs inside the outer guarded scope around the recognizer usage.

In `@electron/ipc/captions/whisper.ts`:
- Around line 166-176: Update the auxiliary-file loop around
model.auxiliaryFiles to remove the unconditional duplicate download and use the
same atomic .download temporary-file, download, and rename workflow as the
primary model. Check readability only on the final auxPath, download to the
temporary path when absent, and rename it to auxPath only after a successful
download so interrupted transfers cannot leave a seemingly valid partial file.
- Around line 71-74: Update the redirect handling in request to resolve
response.headers.location against currentUrl before recursively calling request,
using URL resolution that supports both absolute and relative Location values.
Pass the resolved URL to the next request while preserving redirectCount,
resolve, and reject behavior.

In `@electron/ipc/constants.ts`:
- Line 22: Update the Whisper model constants in constants.ts: remove unused
Whisper exports, or rename WHISPER_SMALL_MODEL_PATH to accurately represent the
ggml-large-v3.bin model and update all references accordingly. Ensure no
exported symbol retains a misleading “SMALL” name for the large-v3 model.

In `@package.json`:
- Around line 54-55: Remove the sherpa-onnx-darwin-arm64 entry from dependencies
in package.json, leaving sherpa-onnx as the sole direct dependency so its
built-in optional dependency mechanism selects the correct platform binary.

In `@scripts/download-bundled-models.mjs`:
- Around line 129-137: Update the download flow around downloadFile to download
each file only once to tempDest, then atomically rename tempDest to dest using
rename from node:fs/promises. Remove the rm, writeFile, and second downloadFile
operations while preserving the existing error handling and cleanup behavior.

---

Outside diff comments:
In `@src/components/video-editor/VideoEditor.tsx`:
- Around line 2901-2980: Add selectedModelId to the dependency array of
handleGenerateAutoCaptions so generateAutoCaptions always receives the current
model identifier. Update the missing whisperModelPath guard message to refer to
selecting or downloading the selected caption model, accurately covering
SenseVoice and other supported models.
- Around line 2752-2810: Fix the stale selectedModelId capture in the mount-only
model-loading effect by maintaining a ref that is updated with the latest
selection on every render. Use that ref in both the onModelDownloadProgress
callback and the async model-status loop when deciding whether to update
whisperModelPath, while keeping the effect’s single subscription behavior
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 142722ac-2dd1-4f64-9ef9-fabe23fcfc50

📥 Commits

Reviewing files that changed from the base of the PR and between 360b160 and 8249afa.

⛔ Files ignored due to path filters (2)
  • dev_docs/journal.db is excluded by !**/*.db
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (29)
  • .gitignore
  • dev_docs/INDEX.md
  • electron-builder.json5
  • electron/electron-env.d.ts
  • electron/ipc/captions/engine.ts
  • electron/ipc/captions/generate.ts
  • electron/ipc/captions/models.ts
  • electron/ipc/captions/sensevoice.ts
  • electron/ipc/captions/sherpa-onnx.d.ts
  • electron/ipc/captions/whisper.ts
  • electron/ipc/constants.ts
  • electron/ipc/ffmpeg/binary.ts
  • electron/ipc/register/captions.ts
  • electron/native/bin/darwin-arm64/recordly-native-cursor-monitor
  • electron/native/bin/darwin-arm64/recordly-system-cursors
  • electron/native/bin/darwin-arm64/recordly-window-list
  • electron/native/bin/darwin-x64/recordly-native-cursor-monitor
  • electron/native/bin/darwin-x64/recordly-system-cursors
  • electron/native/bin/darwin-x64/recordly-window-list
  • electron/preload.ts
  • package.json
  • scripts/download-bundled-models.mjs
  • src/components/video-editor/SettingsPanel.tsx
  • src/components/video-editor/VideoEditor.tsx
  • src/components/video-editor/captionStyle.ts
  • src/components/video-editor/editorPreferences.ts
  • src/i18n/locales/en/settings.json
  • src/i18n/locales/zh-CN/settings.json
  • vite.config.ts

Comment thread dev_docs/INDEX.md
Comment on lines +4 to +7
## 文章
| 日期 | 标题 | 类型 |
|------|------|------|

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Surround the table with blank lines.

Add a blank line before and after the table to satisfy Markdown linting rule MD058.

Proposed fix
 ## 文章
+
 | 日期 | 标题 | 类型 |
 |------|------|------|
+
 
 ## 命令
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## 文章
| 日期 | 标题 | 类型 |
|------|------|------|
## 文章
| 日期 | 标题 | 类型 |
|------|------|------|
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 5-5: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dev_docs/INDEX.md` around lines 4 - 7, Add blank lines immediately before and
after the Markdown table under the “文章” heading in INDEX.md, preserving the
table contents and formatting while satisfying MD058.

Source: Linters/SAST tools

Comment thread electron-builder.json5
Comment on lines 57 to +76
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.inherit.plist",
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64"]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
}
{
"target": "dmg",
"arch": ["x64", "arm64"]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
}
],
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.inherit.plist",
"target": [
{
"target": "dir",
"arch": ["arm64"]
}
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Duplicate mac keys clobber the dmg/zip targets.

entitlements, entitlementsInherit, and target are each declared twice in the mac block. With last-key-wins JSON parsing, the effective target becomes only [{ "target": "dir", "arch": ["arm64"] }], silently dropping the dmg/zip (x64 + arm64) distributables from release builds. Merge into a single target array.

🛠️ Proposed fix
   "mac": {
     "hardenedRuntime": true,
     "entitlements": "build/entitlements.mac.plist",
     "entitlementsInherit": "build/entitlements.mac.inherit.plist",
     "target": [
-        {
-            "target": "dmg",
-            "arch": ["x64", "arm64"]
-        },
-        {
-            "target": "zip",
-            "arch": ["x64", "arm64"]
-        }
-    ],
-    "entitlements": "build/entitlements.mac.plist",
-    "entitlementsInherit": "build/entitlements.mac.inherit.plist",
-	"target": [
-		{
-			"target": "dir",
-			"arch": ["arm64"]
-		}
-	],
+        { "target": "dmg", "arch": ["x64", "arm64"] },
+        { "target": "zip", "arch": ["x64", "arm64"] },
+        { "target": "dir", "arch": ["arm64"] }
+    ],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.inherit.plist",
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64"]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
}
{
"target": "dmg",
"arch": ["x64", "arm64"]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
}
],
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.inherit.plist",
"target": [
{
"target": "dir",
"arch": ["arm64"]
}
],
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.inherit.plist",
"target": [
{ "target": "dmg", "arch": ["x64", "arm64"] },
{ "target": "zip", "arch": ["x64", "arm64"] },
{ "target": "dir", "arch": ["arm64"] }
],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron-builder.json5` around lines 57 - 76, Remove the duplicate mac
configuration keys and merge the entries into one mac block: retain the
entitlements settings once, and combine the dmg and zip targets for x64/arm64
with the dir target for arm64 in a single target array.

Comment on lines +210 to +236
const ffmpegPath = getFfmpegBinaryPath();
const tempDir = app.getPath("temp");
const wavPath = path.join(tempDir, `sensevoice-${Date.now()}.wav`);

// Use the same audio extraction as whisper path
const audioSource = await extractCaptionAudioSource({
videoPath: options.videoPath,
ffmpegPath,
wavPath,
});

const result = await engine.generate({
audioPath: wavPath,
modelPath: options.whisperModelPath,
model,
language: options.language,
tempDir,
});

if (!result.success) {
await fs.rm(wavPath, { force: true }).catch(() => undefined);
throw new Error(result.error || "SenseVoice caption generation failed.");
}

await fs.rm(wavPath, { force: true }).catch(() => undefined);

return { success: true, cues: result.cues, audioSourceLabel: audioSource.label };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

SenseVoice temp WAV can leak on unexpected throw.

Cleanup of wavPath only runs after a successful/failed result, but if extractCaptionAudioSource or engine.generate throws, the temp WAV is never removed. The whisper path guards this with finally; wrap the SenseVoice extraction/generation in try/finally for parity. Minor: the sensevoice-${Date.now()}.wav name (line 212) can collide under concurrent runs — the whisper path appends a random suffix.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFile, spawnSync } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/captions/generate.ts` around lines 210 - 236, Wrap the
SenseVoice extraction and generation flow in a try/finally block so wavPath is
removed for both expected failures and unexpected throws, while preserving the
existing result handling and return values. Also update the wavPath filename
construction to include a random suffix, matching the whisper path and
preventing concurrent-run collisions.

Comment on lines +44 to +66
const nextTsMs =
i + 1 < tokens.length ? Math.round(timestamps[i + 1] * 1000) : tsMs + 200;
const endMs = Math.round((tsMs + nextTsMs) / 2);

// Finalize previous word
if (buffer) {
words.push({ text: buffer, startMs, endMs });
}

// Start new word
if (tok.startsWith(" ")) {
buffer = tok.slice(1);
} else {
buffer = tok;
}
startMs = endMs;
}

// Finalize last word
if (buffer) {
const lastTs = Math.round(timestamps[tokens.length - 1] * 1000);
words.push({ text: buffer, startMs, endMs: lastTs + 200 });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file list =="
git ls-files | rg '(^|/)electron/ipc/captions/sensevoice\.ts$|(^|/)electron/.+captions|(^|/)sensevoice'

echo
echo "== outline sensevoice.ts =="
ast-grep outline electron/ipc/captions/sensevoice.ts --view expanded || true

echo
echo "== relevant lines from sensevoice.ts =="
nl -ba electron/ipc/captions/sensevoice.ts | sed -n '1,220p'

echo
echo "== search downstream cue builder / gapToPrev =="
rg -n "gapToPrev|split at pauses|SenseVoice|tokensToWords|recognizer\.free|free\(\)" electron/ipc/captions -S

Repository: webadderallorg/Recordly

Length of output: 1107


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== sensevoice.ts (1-220) =="
sed -n '1,220p' electron/ipc/captions/sensevoice.ts

echo
echo "== generate.ts (1-260) =="
sed -n '1,260p' electron/ipc/captions/generate.ts

echo
echo "== engine.ts (1-220) =="
sed -n '1,220p' electron/ipc/captions/engine.ts

Repository: webadderallorg/Recordly

Length of output: 15019


Word timings are collapsed together, so pause-based splits never trigger.

tokensToWords sets each new word’s startMs to the previous word’s endMs, so gapToPrev in the SenseVoice cue builder is always 0. That means the ≥1s pause split never fires and captions only break on the length cap. The first word also starts at the first inter-token midpoint, not the first token timestamp. Preserve the next word’s actual start time instead of chaining starts from the prior end.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/captions/sensevoice.ts` around lines 44 - 66, Update
tokensToWords so each word starts at its own token timestamp, with the first
word starting at timestamps[0] rather than an inter-token midpoint. Preserve
each finalized word’s midpoint-based endMs, but set the next word’s startMs from
the current token’s timestamp so SenseVoice gapToPrev can detect pauses.

Comment on lines +92 to +125
const recognizer = sherpa.createOfflineRecognizer({
featConfig: { sampleRate: 16000, featureDim: 80 },
modelConfig: {
senseVoice: {
model: modelPath,
language: svLanguage,
useInverseTextNormalization: 1,
},
tokens: tokensPath,
},
decodingMethod: "greedy_search",
});

const wave = sherpa.readWave(audioPath);

let resultText = "";
let resultTokens: string[] = [];
let resultTimestamps: number[] = [];
let recognizerError: string | null = null;

const stream = recognizer.createStream();
try {
stream.acceptWaveform(wave.sampleRate, wave.samples);
recognizer.decode(stream);
const raw = recognizer.getResult(stream) as Record<string, unknown>;
resultText = (raw.text as string) ?? "";
resultTokens = (raw.tokens as string[]) ?? [];
resultTimestamps = (raw.timestamps as number[]) ?? [];
} catch (error) {
recognizerError = error instanceof Error ? error.message : String(error);
} finally {
stream.free();
}
recognizer.free();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="electron/ipc/captions/sensevoice.ts"

echo '--- file outline ---'
ast-grep outline "$file" --view expanded || true

echo
echo '--- relevant lines ---'
nl -ba "$file" | sed -n '1,220p'

echo
echo '--- search for readWave / free usage ---'
rg -n "readWave|free\(\)|createOfflineRecognizer|createStream\(" "$file" "electron/ipc/captions" || true

Repository: webadderallorg/Recordly

Length of output: 652


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="electron/ipc/captions/sensevoice.ts"

echo '--- relevant lines 70-170 ---'
sed -n '70,170p' "$file"

echo
echo '--- all free/readWave occurrences in repository ---'
rg -n "readWave|free\(\)|createOfflineRecognizer|createStream\(" electron/ipc/captions || true

Repository: webadderallorg/Recordly

Length of output: 4031


Wrap the recognizer in an outer cleanup block. sherpa.readWave(audioPath) runs before try/finally, so a malformed WAV can skip recognizer.free() and leak the native resource. Move wave loading inside the guarded scope or add an outer try/finally.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/captions/sensevoice.ts` around lines 92 - 125, Wrap the
recognizer lifecycle in an outer try/finally so recognizer.free() always runs,
including when sherpa.readWave(audioPath) fails. Keep stream.free() in its
existing inner cleanup block, and ensure wave loading occurs inside the outer
guarded scope around the recognizer usage.

Comment on lines +71 to 74
if (statusCode >= 300 && statusCode < 400 && response.headers.location) {
response.resume();
if (redirectCount >= 5) {
reject(new Error("Too many redirects while downloading Whisper model."));
return;
}

const nextUrl = new URL(location, currentUrl).toString();
void request(nextUrl, redirectCount + 1)
.then(resolve)
.catch(reject);
return;
return request(response.headers.location, redirectCount + 1).then(resolve, reject);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Resolve relative HTTP redirects to prevent invalid URL crashes.

While many CDNs return absolute URLs for redirects, HTTP spec permits relative URLs (e.g., /downloads/file.bin) in the Location header. Passing a relative URL directly to https.get will throw a synchronous ERR_INVALID_URL crash.

Parse the redirect URL relative to the currentUrl before making the next request.

🛠️ Proposed fix
 				if (statusCode >= 300 && statusCode < 400 && response.headers.location) {
 					response.resume();
-					return request(response.headers.location, redirectCount + 1).then(resolve, reject);
+					const nextUrl = new URL(response.headers.location, currentUrl).toString();
+					return request(nextUrl, redirectCount + 1).then(resolve, reject);
 				}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (statusCode >= 300 && statusCode < 400 && response.headers.location) {
response.resume();
if (redirectCount >= 5) {
reject(new Error("Too many redirects while downloading Whisper model."));
return;
}
const nextUrl = new URL(location, currentUrl).toString();
void request(nextUrl, redirectCount + 1)
.then(resolve)
.catch(reject);
return;
return request(response.headers.location, redirectCount + 1).then(resolve, reject);
}
if (statusCode >= 300 && statusCode < 400 && response.headers.location) {
response.resume();
const nextUrl = new URL(response.headers.location, currentUrl).toString();
return request(nextUrl, redirectCount + 1).then(resolve, reject);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/captions/whisper.ts` around lines 71 - 74, Update the redirect
handling in request to resolve response.headers.location against currentUrl
before recursively calling request, using URL resolution that supports both
absolute and relative Location values. Pass the resolved URL to the next request
while preserving redirectCount, resolve, and reject behavior.

Comment on lines +166 to +176
for (const aux of model.auxiliaryFiles) {
const auxPath = path.join(storageDir, aux.fileName);
try {
await fs.access(auxPath, fsConstants.R_OK);
continue; // Already exists
} catch {
await downloadFileWithProgress(aux.url, auxPath, () => undefined);
}
await downloadFileWithProgress(aux.url, auxPath, () => {});
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Fix duplicate download logic and prevent partial file corruption for auxiliary files.

This block contains two distinct critical issues:

  1. Duplicate redundant download: There is an unconditional await downloadFileWithProgress(aux.url, auxPath, () => {}); immediately after the try/catch block. If the fs.access check fails, the file is downloaded inside the catch block, and then mistakenly downloaded a second time.
  2. Permanent partial-file corruption: Auxiliary files are downloaded directly to their final destination path (auxPath). If a network error or interruption occurs mid-download, a truncated file is left behind. On the next launch, fs.access(auxPath, fsConstants.R_OK) will erroneously detect the file as fully valid, skip the download, and pass a corrupted model to the transcription engine, breaking caption generation.

Auxiliary files must use the same atomic .download temp-file + rename mechanism as the primary model.

🛡️ Proposed fix
 		if (model.auxiliaryFiles) {
 			for (const aux of model.auxiliaryFiles) {
 				const auxPath = path.join(storageDir, aux.fileName);
+				const tempAuxPath = `${auxPath}.download`;
 				try {
 					await fs.access(auxPath, fsConstants.R_OK);
 					continue; // Already exists
 				} catch {
-					await downloadFileWithProgress(aux.url, auxPath, () => undefined);
+					await fs.rm(tempAuxPath, { force: true }).catch(() => undefined);
+					await downloadFileWithProgress(aux.url, tempAuxPath, () => undefined);
+					await fs.rename(tempAuxPath, auxPath);
 				}
-				await downloadFileWithProgress(aux.url, auxPath, () => {});
 			}
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const aux of model.auxiliaryFiles) {
const auxPath = path.join(storageDir, aux.fileName);
try {
await fs.access(auxPath, fsConstants.R_OK);
continue; // Already exists
} catch {
await downloadFileWithProgress(aux.url, auxPath, () => undefined);
}
await downloadFileWithProgress(aux.url, auxPath, () => {});
}
}
if (model.auxiliaryFiles) {
for (const aux of model.auxiliaryFiles) {
const auxPath = path.join(storageDir, aux.fileName);
const tempAuxPath = `${auxPath}.download`;
try {
await fs.access(auxPath, fsConstants.R_OK);
continue; // Already exists
} catch {
await fs.rm(tempAuxPath, { force: true }).catch(() => undefined);
await downloadFileWithProgress(aux.url, tempAuxPath, () => undefined);
await fs.rename(tempAuxPath, auxPath);
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/captions/whisper.ts` around lines 166 - 176, Update the
auxiliary-file loop around model.auxiliaryFiles to remove the unconditional
duplicate download and use the same atomic .download temporary-file, download,
and rename workflow as the primary model. Check readability only on the final
auxPath, download to the temporary path when absent, and rename it to auxPath
only after a successful download so interrupted transfers cannot leave a
seemingly valid partial file.

Comment thread electron/ipc/constants.ts
"https://hf-mirror.com/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin";
export const WHISPER_MODEL_DIR = path.join(USER_DATA_PATH, "whisper");
export const WHISPER_SMALL_MODEL_PATH = path.join(WHISPER_MODEL_DIR, "ggml-small.bin");
export const WHISPER_SMALL_MODEL_PATH = path.join(WHISPER_MODEL_DIR, "ggml-large-v3.bin");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP '\bWHISPER_(SMALL_MODEL_PATH|MODEL_DOWNLOAD_URL|MODEL_DIR)\b' -g '!**/constants.ts'

Repository: webadderallorg/Recordly

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## files\n'
git ls-files 'electron/ipc/constants.ts' 'electron/ipc/captions/models.ts' 'electron/ipc/**' 'src/**' | sed -n '1,200p'

printf '\n## constants.ts\n'
cat -n electron/ipc/constants.ts

printf '\n## models.ts\n'
cat -n electron/ipc/captions/models.ts

printf '\n## references to WHISPER constants\n'
rg -n '\bWHISPER_(SMALL_MODEL_PATH|MODEL_DOWNLOAD_URL|MODEL_DIR)\b' .

printf '\n## references to ggml-large-v3.bin\n'
rg -n 'ggml-large-v3\.bin|small model|WHISPER_SMALL_MODEL_PATH' electron src .

Repository: webadderallorg/Recordly

Length of output: 20219


Drop the stale Whisper exports. WHISPER_SMALL_MODEL_PATH points to ggml-large-v3.bin; either rename it to match the large-v3 model or remove the unused Whisper constants.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/constants.ts` at line 22, Update the Whisper model constants in
constants.ts: remove unused Whisper exports, or rename WHISPER_SMALL_MODEL_PATH
to accurately represent the ggml-large-v3.bin model and update all references
accordingly. Ensure no exported symbol retains a misleading “SMALL” name for the
large-v3 model.

Comment thread package.json
Comment on lines +54 to +55
"sherpa-onnx": "^1.13.4",
"sherpa-onnx-darwin-arm64": "^1.13.4",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Remove platform-specific binding from dependencies to fix cross-platform installation.

Adding sherpa-onnx-darwin-arm64 directly to dependencies will cause a hard installation failure (npm ERR! code EBADPLATFORM) when running npm install on Windows, Linux, or Intel Macs, because that package enforces "os": ["darwin"] and "cpu": ["arm64"] constraints in its own package.json.

sherpa-onnx already handles platform-specific binaries securely via its own optionalDependencies mechanism, which automatically selects and installs the correct binary for the user's OS and architecture.

🐛 Proposed fix
 		"ffprobe-static": "^3.1.0",
 		"sherpa-onnx": "^1.13.4",
-		"sherpa-onnx-darwin-arm64": "^1.13.4",
 		"uiohook-napi": "^1.5.4"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"sherpa-onnx": "^1.13.4",
"sherpa-onnx-darwin-arm64": "^1.13.4",
"sherpa-onnx": "^1.13.4",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` around lines 54 - 55, Remove the sherpa-onnx-darwin-arm64 entry
from dependencies in package.json, leaving sherpa-onnx as the sole direct
dependency so its built-in optional dependency mechanism selects the correct
platform binary.

Comment on lines +129 to +137
const tempDest = `${dest}.download`;
try {
await downloadFile(file.url, tempDest);
// Rename temp -> final
await rm(tempDest, { force: true });
await writeFile(tempDest, ""); // touch
await rm(tempDest);
// Re-download to proper path
await downloadFile(file.url, dest);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Model is downloaded twice; the temp file is discarded instead of renamed.

The comment says "Rename temp -> final", but the code downloads to tempDest, deletes it, touches/deletes again, then re-downloads the same URL to dest. Every bundled file (including the ~239 MB model.int8.onnx) is fetched twice. Download once to the temp path and atomically rename it into place.

♻️ Proposed fix
 			const tempDest = `${dest}.download`;
 			try {
 				await downloadFile(file.url, tempDest);
-				// Rename temp -> final
-				await rm(tempDest, { force: true });
-				await writeFile(tempDest, ""); // touch
-				await rm(tempDest);
-				// Re-download to proper path
-				await downloadFile(file.url, dest);
+				// Atomically move temp -> final
+				await rename(tempDest, dest);
 			} catch (error) {

Add rename to the node:fs/promises import on line 16.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/download-bundled-models.mjs` around lines 129 - 137, Update the
download flow around downloadFile to download each file only once to tempDest,
then atomically rename tempDest to dest using rename from node:fs/promises.
Remove the rm, writeFile, and second downloadFile operations while preserving
the existing error handling and cleanup behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant