14 Commits

Author SHA1 Message Date
himeditator
6bff978b88 feat(engine): 替换重采样模型、SOSV 添加标点恢复模型
- 将 samplerate 库替换为 resampy 库,提高重采样质量
- Shepra-ONNX SenseVoice 添加中文和英语标点恢复模型
2025-09-06 23:15:33 +08:00
himeditator
eba2c5ca45 feat(engine): 重构字幕引擎,新增 Sherpa-ONNX SenseVoice 语音识别模型
- 重构字幕引擎,将音频采集改为在新线程上进行
- 重构 audio2text 中的类,调整运行逻辑
- 更新 main 函数,添加对 Sosv 模型的支持
- 修改 AudioStream 类,默认使用 16000Hz 采样率
2025-09-06 20:49:46 +08:00
himeditator
2b7ce06f04 feat(translation): 添加非实时翻译功能用户界面组件 2025-09-04 23:41:22 +08:00
himeditator
14987cbfc5 feat(vosk): 为 Vosk 模型添加非实时翻译功能 (#14)
- 添加 Ollama 大模型翻译和 Google 翻译(非实时),支持多种语言
- 为 Vosk 引擎添加非实时翻译
- 为新增的翻译功能添加和修改接口
- 修改 Electron 构建配置,之后不同平台构建无需修改构建文件
2025-09-02 23:19:53 +08:00
himeditator
56fdc348f8 fix(engine): 解决在引擎状态不为 running 时强制关闭字幕引擎失败的问题
- 合并了 CaptionEngine 类中的 kill 和 forceKill 方法,删除了状态警告中的提前  return
- 更新了 README 文件中的macOS兼容性说明,添加了配置链接
2025-08-30 20:57:26 +08:00
Chen Janai
f42458124e Merge pull request #17 from xuemian168/main
feat(engine): 添加启动超时功能和强制终止引擎的支持
2025-08-28 12:25:33 +08:00
himeditator
2352bcee5d feat(engine): 优化超时启动功能的小问题
- 更新接口文档
- 修改国际化文本使得内容不超过标签长度
- 解决强制关闭按钮点击无效的问题
2025-08-28 12:22:19 +08:00
xuemian
051a497f3a feat(engine): 添加启动超时功能和强制终止引擎的支持
- 在 ControlWindow 中添加了 'control.engine.forceKill' 事件处理,允许强制终止引擎。
- 在 CaptionEngine 中实现了启动超时机制,若引擎启动超时,将自动强制停止并发送错误消息。
- 更新了国际化文件,添加了与启动超时相关的提示信息。
- 在 EngineControl 组件中添加了启动超时的输入选项,允许用户设置超时时间。
- 更新了相关类型定义以支持新的启动超时配置。
2025-08-28 10:24:08 +10:00
himeditator
34362fea3d feat(auto-caption): 发布 v0.7.0 版本 2025-08-20 00:53:06 +08:00
himeditator
771f7ad002 feat(log): 添加软件日志功能
- 新增 SoftwareLog 相关接口和数据结构
- 实现日志数据的收集和展示
- 添加日志相关的国际化支持
- 优化控制页面布局,支持日志切换显示
2025-08-19 22:23:54 +08:00
himeditator
01936d5f12 feat(renderer): 添加界面主题颜色功能,添加复制最新字幕选项(#13)
- 新增界面主题颜色功能,支持自定义主题颜色
- 使用 antd 滑块替代原生 input 元素
- 添加复制字幕记录功能,可选择复制最近的字幕记录
2025-08-18 16:03:46 +08:00
himeditator
1c0bf1f9c4 refactor(engine): 修改虚拟环境设置,修改音频工具函数
- 更新虚拟环境目录名为 .venv
- 调整音频块采集速率默认值为 10
- 为 AudioStream 类添加重设音频块大小的方法
- 更新依赖文件 requirements.txt
2025-08-03 16:40:26 +08:00
himeditator
38b4b15cec feat(engine): 添加字幕窗口宽度记忆功能并优化字幕引擎关闭逻辑
- 添加 captionWindowWidth 属性,用于保存字幕窗口宽度
- 修改 CaptionEngine 中的 stop 和 kill 方法,优化字幕引擎关闭逻辑
- 更新 README,添加预备模型列表
2025-08-02 15:57:07 +08:00
Chen Janai
64ea2f0daf Merge pull request #12 from HiMeditator/dev-0.6.0-engine
Release v0.6.0 with new caption engine structure
2025-07-30 00:32:00 +08:00
70 changed files with 1698 additions and 663 deletions

4
.gitignore vendored
View File

@@ -6,7 +6,9 @@ out
*.log*
__pycache__
.venv
subenv
test.py
engine/build
engine/models
engine/notebook
.repomap
.virtualme

View File

@@ -3,12 +3,8 @@
<h1 align="center">auto-caption</h1>
<p>Auto Caption 是一个跨平台的实时字幕显示软件。</p>
<p>
<a href="https://github.com/HiMeditator/auto-caption/releases">
<img src="https://img.shields.io/badge/release-0.6.0-blue">
</a>
<a href="https://github.com/HiMeditator/auto-caption/issues">
<img src="https://img.shields.io/github/issues/HiMeditator/auto-caption?color=orange">
</a>
<a href="https://github.com/HiMeditator/auto-caption/releases"><img src="https://img.shields.io/badge/release-0.7.0-blue"></a>
<a href="https://github.com/HiMeditator/auto-caption/issues"><img src="https://img.shields.io/github/issues/HiMeditator/auto-caption?color=orange"></a>
<img src="https://img.shields.io/github/languages/top/HiMeditator/auto-caption?color=royalblue">
<img src="https://img.shields.io/github/repo-size/HiMeditator/auto-caption?color=green">
<img src="https://img.shields.io/github/stars/HiMeditator/auto-caption?style=social">
@@ -18,7 +14,7 @@
| <a href="./README_en.md">English</a>
| <a href="./README_ja.md">日本語</a> |
</p>
<p><i>v0.6.0 版本已经发布,对字幕引擎代码进行了大重构,提升了代码的可扩展性。更多的字幕引擎正在尝试开发中...</i></p>
<p><i>v0.7.0 版本已经发布,优化了软件界面,添加了日志记录显示。本地的字幕引擎正在尝试开发中,预计以 Python 代码的形式进行发布...</i></p>
</div>
![](./assets/media/main_zh.png)
@@ -37,6 +33,15 @@
[更新日志](./docs/CHANGELOG.md)
## ✨ 特性
- 生成音频输出或麦克风输入的字幕
- 跨平台Windows、macOS、Linux、多界面语言中文、英语、日语支持
- 丰富的字幕样式设置(字体、字体大小、字体粗细、字体颜色、背景颜色等)
- 灵活的字幕引擎选择(阿里云 Gummy 云端模型、本地 Vosk 模型、自己开发的模型)
- 多语言识别与翻译(见下文“⚙️ 自带字幕引擎说明”)
- 字幕记录展示与导出(支持导出 `.srt``.json` 格式)
## 📖 基本使用
软件已经适配了 Windows、macOS 和 Linux 平台。测试过的平台信息如下:
@@ -44,7 +49,7 @@
| 操作系统版本 | 处理器架构 | 获取系统音频输入 | 获取系统音频输出 |
| ------------------ | ---------- | ---------------- | ---------------- |
| Windows 11 24H2 | x64 | ✅ | ✅ |
| macOS Sequoia 15.5 | arm64 | ✅需要额外配置 | ✅ |
| macOS Sequoia 15.5 | arm64 | ✅ [需要额外配置](./docs/user-manual/zh.md#macos-获取系统音频输出) | ✅ |
| Ubuntu 24.04.2 | x64 | ✅ | ✅ |
| Kali Linux 2022.3 | x64 | ✅ | ✅ |
| Kylin Server V10 SP3 | x64 | ✅ | ✅ |
@@ -66,18 +71,9 @@ macOS 平台和 Linux 平台获取系统音频输出需要进行额外设置,
**如果你觉得上述字幕引擎不能满足你的需求,而且你会 Python那么你可以考虑开发自己的字幕引擎。详细说明请参考[字幕引擎说明文档](./docs/engine-manual/zh.md)。**
## ✨ 特性
- 跨平台、多界面语言支持
- 丰富的字幕样式设置
- 灵活的字幕引擎选择
- 多语言识别与翻译
- 字幕记录展示与导出
- 生成音频输出或麦克风输入的字幕
## ⚙️ 自带字幕引擎说明
目前软件自带 2 个字幕引擎,正在规划 1 个新的引擎。它们的详细信息如下。
目前软件自带 2 个字幕引擎,正在规划新的引擎。它们的详细信息如下。
### Gummy 字幕引擎(云端)
@@ -108,9 +104,14 @@ $$
基于 [vosk-api](https://github.com/alphacep/vosk-api) 开发。目前只支持生成音频对应的原文,不支持生成翻译内容。
### FunASR 字幕引擎(本地)
### 新规划字幕引擎
如果可行,将基于 [FunASR](https://github.com/modelscope/FunASR) 进行开发。还未进行调研和可行性验证
以下为备选模型,将根据模型效果和集成难易程度选择
- [faster-whisper](https://github.com/SYSTRAN/faster-whisper)
- [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx)
- [SenseVoice](https://github.com/FunAudioLLM/SenseVoice)
- [FunASR](https://github.com/modelscope/FunASR)
## 🚀 项目运行
@@ -128,29 +129,24 @@ npm install
```bash
# in ./engine folder
python -m venv subenv
python -m venv .venv
# or
python3 -m venv subenv
python3 -m venv .venv
```
然后激活虚拟环境:
```bash
# Windows
subenv/Scripts/activate
.venv/Scripts/activate
# Linux or macOS
source subenv/bin/activate
source .venv/bin/activate
```
然后安装依赖(这一步在 macOS 和 Linux 可能会报错,一般是因为构建失败,需要根据报错信息进行处理):
```bash
# Windows
pip install -r requirements_win.txt
# macOS
pip install -r requirements_darwin.txt
# Linux
pip install -r requirements_linux.txt
pip install -r requirements.txt
```
如果在 Linux 系统上安装 `samplerate` 模块报错,可以尝试使用以下命令单独安装:
@@ -169,12 +165,12 @@ pyinstaller ./main.spec
```
# Windows
vosk_path = str(Path('./subenv/Lib/site-packages/vosk').resolve())
vosk_path = str(Path('./.venv/Lib/site-packages/vosk').resolve())
# Linux or macOS
vosk_path = str(Path('./subenv/lib/python3.x/site-packages/vosk').resolve())
vosk_path = str(Path('./.venv/lib/python3.x/site-packages/vosk').resolve())
```
此时项目构建完成,进入 `engine/dist` 文件夹可见对应的可执行文件。即可进行后续操作。
此时项目构建完成,进入 `engine/dist` 文件夹可见对应的可执行文件。即可进行后续操作。
### 运行项目
@@ -192,15 +188,3 @@ npm run build:mac
# For Linux
npm run build:linux
```
注意,根据不同的平台需要修改项目根目录下 `electron-builder.yml` 文件中的配置内容:
```yml
extraResources:
# For Windows
- from: ./engine/dist/main.exe
to: ./engine/main.exe
# For macOS and Linux
# - from: ./engine/dist/main
# to: ./engine/main
```

View File

@@ -3,12 +3,8 @@
<h1 align="center">auto-caption</h1>
<p>Auto Caption is a cross-platform real-time caption display software.</p>
<p>
<a href="https://github.com/HiMeditator/auto-caption/releases">
<img src="https://img.shields.io/badge/release-0.6.0-blue">
</a>
<a href="https://github.com/HiMeditator/auto-caption/issues">
<img src="https://img.shields.io/github/issues/HiMeditator/auto-caption?color=orange">
</a>
<a href="https://github.com/HiMeditator/auto-caption/releases"><img src="https://img.shields.io/badge/release-0.7.0-blue"></a>
<a href="https://github.com/HiMeditator/auto-caption/issues"><img src="https://img.shields.io/github/issues/HiMeditator/auto-caption?color=orange"></a>
<img src="https://img.shields.io/github/languages/top/HiMeditator/auto-caption?color=royalblue">
<img src="https://img.shields.io/github/repo-size/HiMeditator/auto-caption?color=green">
<img src="https://img.shields.io/github/stars/HiMeditator/auto-caption?style=social">
@@ -18,7 +14,7 @@
| <b>English</b>
| <a href="./README_ja.md">日本語</a> |
</p>
<p><i>Version 0.6.0 has been released, featuring a major refactor of the subtitle engine code to improve code extensibility. More subtitle engines are being developed...</i></p>
<p><i>Version 0.7.0 has been released, imporving the software interface and adding software log display. The local caption engine is under development and is expected to be released in the form of Python code...</i></p>
</div>
![](./assets/media/main_en.png)
@@ -37,6 +33,15 @@
[Changelog](./docs/CHANGELOG.md)
## ✨ Features
- Generate captions from audio output or microphone input
- Cross-platform (Windows, macOS, Linux) and multi-language interface (Chinese, English, Japanese) support
- Rich caption style settings (font, font size, font weight, font color, background color, etc.)
- Flexible caption engine selection (Alibaba Cloud Gummy cloud model, local Vosk model, self-developed model)
- Multi-language recognition and translation (see below "⚙️ Built-in Subtitle Engines")
- Subtitle record display and export (supports exporting `.srt` and `.json` formats)
## 📖 Basic Usage
The software has been adapted for Windows, macOS, and Linux platforms. The tested platform information is as follows:
@@ -44,7 +49,7 @@ The software has been adapted for Windows, macOS, and Linux platforms. The teste
| OS Version | Architecture | System Audio Input | System Audio Output |
| ------------------ | ------------ | ------------------ | ------------------- |
| Windows 11 24H2 | x64 | ✅ | ✅ |
| macOS Sequoia 15.5 | arm64 | ✅ Additional config required | ✅ |
| macOS Sequoia 15.5 | arm64 | ✅ [Additional config required](./docs/user-manual/en.md#capturing-system-audio-output-on-macos) | ✅ |
| Ubuntu 24.04.2 | x64 | ✅ | ✅ |
| Kali Linux 2022.3 | x64 | ✅ | ✅ |
| Kylin Server V10 SP3 | x64 | ✅ | ✅ |
@@ -66,18 +71,9 @@ To use the Vosk local caption engine, first download your required model from [V
**If you find the above caption engines don't meet your needs and you know Python, you may consider developing your own caption engine. For detailed instructions, please refer to the [Caption Engine Documentation](./docs/engine-manual/en.md).**
## ✨ Features
- Cross-platform, multi-language UI support
- Rich caption style settings
- Flexible caption engine selection
- Multi-language recognition and translation
- Caption recording display and export
- Generate captions for audio output or microphone input
## ⚙️ Built-in Subtitle Engines
Currently, the software comes with 2 subtitle engines, with 1 new engine planned. Details are as follows.
Currently, the software comes with 2 subtitle engines, with new engines under development. Their detailed information is as follows.
### Gummy Subtitle Engine (Cloud)
@@ -108,9 +104,14 @@ The engine only uploads data when receiving audio streams, so the actual upload
Developed based on [vosk-api](https://github.com/alphacep/vosk-api). Currently only supports generating original text from audio, does not support translation content.
### FunASR Subtitle Engine (Local)
### Planned New Subtitle Engines
If feasible, will be developed based on [FunASR](https://github.com/modelscope/FunASR). Not yet researched or verified for feasibility.
The following are candidate models that will be selected based on model performance and ease of integration.
- [faster-whisper](https://github.com/SYSTRAN/faster-whisper)
- [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx)
- [SenseVoice](https://github.com/FunAudioLLM/SenseVoice)
- [FunASR](https://github.com/modelscope/FunASR)
## 🚀 Project Setup
@@ -128,29 +129,24 @@ First enter the `engine` folder and execute the following commands to create a v
```bash
# in ./engine folder
python -m venv subenv
python -m venv .venv
# or
python3 -m venv subenv
python3 -m venv .venv
```
Then activate the virtual environment:
```bash
# Windows
subenv/Scripts/activate
.venv/Scripts/activate
# Linux or macOS
source subenv/bin/activate
source .venv/bin/activate
```
Then install dependencies (this step might result in errors on macOS and Linux, usually due to build failures, and you need to handle them based on the error messages):
```bash
# Windows
pip install -r requirements_win.txt
# macOS
pip install -r requirements_darwin.txt
# Linux
pip install -r requirements_linux.txt
pip install -r requirements.txt
```
If you encounter errors when installing the `samplerate` module on Linux systems, you can try installing it separately with this command:
@@ -169,9 +165,9 @@ Note that the path to the `vosk` library in `main-vosk.spec` might be incorrect
```
# Windows
vosk_path = str(Path('./subenv/Lib/site-packages/vosk').resolve())
vosk_path = str(Path('./.venv/Lib/site-packages/vosk').resolve())
# Linux or macOS
vosk_path = str(Path('./subenv/lib/python3.x/site-packages/vosk').resolve())
vosk_path = str(Path('./.venv/lib/python3.x/site-packages/vosk').resolve())
```
After the build completes, you can find the executable file in the `engine/dist` folder. Then proceed with subsequent operations.
@@ -192,15 +188,3 @@ npm run build:mac
# For Linux
npm run build:linux
```
Note: You need to modify the configuration content in the `electron-builder.yml` file in the project root directory according to different platforms:
```yml
extraResources:
# For Windows
- from: ./engine/dist/main.exe
to: ./engine/main.exe
# For macOS and Linux
# - from: ./engine/dist/main
# to: ./engine/main
```

View File

@@ -3,12 +3,8 @@
<h1 align="center">auto-caption</h1>
<p>Auto Caption はクロスプラットフォームのリアルタイム字幕表示ソフトウェアです。</p>
<p>
<a href="https://github.com/HiMeditator/auto-caption/releases">
<img src="https://img.shields.io/badge/release-0.6.0-blue">
</a>
<a href="https://github.com/HiMeditator/auto-caption/issues">
<img src="https://img.shields.io/github/issues/HiMeditator/auto-caption?color=orange">
</a>
<a href="https://github.com/HiMeditator/auto-caption/releases"><img src="https://img.shields.io/badge/release-0.7.0-blue"></a>
<a href="https://github.com/HiMeditator/auto-caption/issues"><img src="https://img.shields.io/github/issues/HiMeditator/auto-caption?color=orange"></a>
<img src="https://img.shields.io/github/languages/top/HiMeditator/auto-caption?color=royalblue">
<img src="https://img.shields.io/github/repo-size/HiMeditator/auto-caption?color=green">
<img src="https://img.shields.io/github/stars/HiMeditator/auto-caption?style=social">
@@ -18,7 +14,7 @@
| <a href="./README_en.md">English</a>
| <b>日本語</b> |
</p>
<p><i>v0.6.0 バージョンがリリースされ、字幕エンジンコードが大規模にリファクタリングされ、コードの拡張性が向上しました。より多くの字幕エンジンの開発が試みられています...</i></p>
<p><i>バージョン 0.7.0 がリリースされ、ソフトウェアインターフェースが最適化され、ログ記録表示機能が追加されました。ローカルの字幕エンジンは現在開発中であり、Pythonコードの形式でリリースされる予定です...</i></p>
</div>
![](./assets/media/main_ja.png)
@@ -37,19 +33,28 @@
[更新履歴](./docs/CHANGELOG.md)
## ✨ 特徴
- 音声出力またはマイク入力からの字幕生成
- クロスプラットフォームWindows、macOS、Linux、多言語インターフェース中国語、英語、日本語対応
- 豊富な字幕スタイル設定(フォント、フォントサイズ、フォント太さ、フォント色、背景色など)
- 柔軟な字幕エンジン選択(阿里雲 Gummy クラウドモデル、ローカル Vosk モデル、独自開発モデル)
- 多言語認識と翻訳(下記「⚙️ 字幕エンジン説明」参照)
- 字幕記録表示とエクスポート(`.srt` および `.json` 形式のエクスポートに対応)
## 📖 基本使い方
このソフトウェアはWindows、macOS、Linuxプラットフォームに対応しています。テスト済みのプラットフォーム情報は以下の通りです
このソフトウェアは Windows、macOS、Linux プラットフォームに対応しています。テスト済みのプラットフォーム情報は以下の通りです:
| OS バージョン | アーキテクチャ | システムオーディオ入力 | システムオーディオ出力 |
| ------------------ | ------------ | ------------------ | ------------------- |
| Windows 11 24H2 | x64 | ✅ | ✅ |
| macOS Sequoia 15.5 | arm64 | ✅ 追加設定が必要 | ✅ |
| macOS Sequoia 15.5 | arm64 | ✅ [追加設定が必要](./docs/user-manual/ja.md#macos-でのシステムオーディオ出力の取得方法) | ✅ |
| Ubuntu 24.04.2 | x64 | ✅ | ✅ |
| Kali Linux 2022.3 | x64 | ✅ | ✅ |
| Kylin Server V10 SP3 | x64 | ✅ | ✅ |
macOSおよびLinuxプラットフォームでシステムオーディオ出力を取得するには追加設定が必要です。詳細は[Auto Captionユーザーマニュアル](./docs/user-manual/ja.md)をご覧ください。
macOS および Linux プラットフォームでシステムオーディオ出力を取得するには追加設定が必要です。詳細は[Auto Captionユーザーマニュアル](./docs/user-manual/ja.md)をご覧ください。
> 阿里雲の国際版サービスでは Gummy モデルを提供していないため、現在中国以外のユーザーは Gummy 字幕エンジンを使用できません。
@@ -66,18 +71,9 @@ Vosk ローカル字幕エンジンを使用するには、まず [Vosk Models](
**上記の字幕エンジンがご要望を満たさず、かつ Python の知識をお持ちの場合、独自の字幕エンジンを開発することも可能です。詳細な説明は[字幕エンジン説明書](./docs/engine-manual/ja.md)をご参照ください。**
## ✨ 特徴
- クロスプラットフォーム、多言語 UI サポート
- 豊富な字幕スタイル設定
- 柔軟な字幕エンジン選択
- 多言語認識と翻訳
- 字幕記録の表示とエクスポート
- オーディオ出力またはマイク入力からの字幕生成
## ⚙️ 字幕エンジン説明
現在ソフトウェアには2つの字幕エンジンが組み込まれており、1つの新しいエンジン計画中です。詳細は以下の通りです。
現在ソフトウェアには2つの字幕エンジンが搭載されており、新しいエンジン計画されています。それらの詳細情報は以下の通りです。
### Gummy 字幕エンジン(クラウド)
@@ -108,9 +104,14 @@ $$
[vosk-api](https://github.com/alphacep/vosk-api) をベースに開発されています。現在は音声に対応する原文の生成のみをサポートしており、翻訳コンテンツはサポートしていません。
### FunASR字幕エンジンローカル
### 新規計画字幕エンジン
可能であれば、[FunASR](https://github.com/modelscope/FunASR) をベースに開発予定です。まだ調査と実現可能性の検証を行っていません
以下は候補モデルであり、モデルの性能と統合の容易さに基づいて選択されます
- [faster-whisper](https://github.com/SYSTRAN/faster-whisper)
- [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx)
- [SenseVoice](https://github.com/FunAudioLLM/SenseVoice)
- [FunASR](https://github.com/modelscope/FunASR)
## 🚀 プロジェクト実行
@@ -128,29 +129,24 @@ npm install
```bash
# ./engine フォルダ内
python -m venv subenv
python -m venv .venv
# または
python3 -m venv subenv
python3 -m venv .venv
```
次に仮想環境をアクティブにします:
```bash
# Windows
subenv/Scripts/activate
.venv/Scripts/activate
# Linux または macOS
source subenv/bin/activate
source .venv/bin/activate
```
次に依存関係をインストールします(このステップでは macOS と Linux でエラーが発生する可能性があります。通常はビルド失敗によるもので、エラーメッセージに基づいて対処する必要があります):
```bash
# Windows
pip install -r requirements_win.txt
# macOS
pip install -r requirements_darwin.txt
# Linux
pip install -r requirements_linux.txt
pip install -r requirements.txt
```
Linux システムで `samplerate` モジュールのインストールに問題が発生した場合、以下のコマンドで個別にインストールを試すことができます:
@@ -169,9 +165,9 @@ pyinstaller ./main.spec
```
# Windows
vosk_path = str(Path('./subenv/Lib/site-packages/vosk').resolve())
vosk_path = str(Path('./.venv/Lib/site-packages/vosk').resolve())
# Linux または macOS
vosk_path = str(Path('./subenv/lib/python3.x/site-packages/vosk').resolve())
vosk_path = str(Path('./.venv/lib/python3.x/site-packages/vosk').resolve())
```
これでプロジェクトのビルドが完了し、`engine/dist` フォルダ内に対応する実行可能ファイルが確認できます。その後、次の操作に進むことができます。
@@ -192,15 +188,3 @@ npm run build:mac
# Linux 用
npm run build:linux
```
注意: プラットフォームに応じて、プロジェクトルートディレクトリにある `electron-builder.yml` ファイルの設定内容を変更する必要があります:
```yml
extraResources:
# Windows 用
- from: ./engine/dist/main.exe
to: ./engine/main.exe
# macOS と Linux 用
# - from: ./engine/dist/main
# to: ./engine/main
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

After

Width:  |  Height:  |  Size: 416 KiB

View File

@@ -137,3 +137,34 @@
- 合并 Gummy 和 Vosk 引擎为单个可执行文件
- 字幕引擎和主程序添加 Socket 通信,完全避免字幕引擎成为孤儿进程
## v0.7.0
2025-08-20
### 新增功能
- 添加字幕窗口宽度记忆,重新打开时与上次字幕窗口宽度一致
- 在尝试关闭字幕引擎 4s 后字幕引擎仍未关闭,则强制关闭字幕引擎
- 添加复制最新字幕选项用户可以选择只复制最近1~3条字幕 (#13)
- 添加主题颜色设置,支持六种颜色:蓝色、绿色、橙色、紫色、粉色、暗色/明色
- 添加日志记录显示:可以查看软件的字幕引擎输出的日志记录
### 优化体验
- 优化软件用户界面的部分组件
- 更清晰的日志输出
## v0.8.0
2025-09-??
### 新增功能
- 字幕引擎添加超时关闭功能:如果在规定时间字幕引擎没有启动成功会自动关闭、在字幕引擎启动过程中也可选择关闭字幕引擎
- 添加非实时翻译功能:支持调用 Ollama 本地模型进行翻译、支持调用 Google 翻译 API 进行翻译
### 优化体验
- 带有额外信息的标签颜色改为与主题色一致

View File

@@ -18,9 +18,13 @@
- [x] 添加字幕记录按时间降序排列选择 *2025/07/26*
- [x] 重构字幕引擎 *2025/07/28*
- [x] 优化前端界面提示消息 *2025/07/29*
- [x] 复制字幕记录可选择只复制最近的字幕记录 *2025/08/18*
- [x] 添加颜色主题设置 *2025/08/18*
- [x] 前端页面添加日志内容展示 *2025/08/19*
## 待完成
- [ ] 调研更多的云端模型火山、OpenAI、Google等
- [ ] 验证 / 添加基于 sherpa-onnx 的字幕引擎
## 后续计划

View File

@@ -58,6 +58,18 @@ Electron 主进程通过 TCP Socket 向 Python 进程发送数据。发送的数
Python 端监听到的音频流转换为的字幕数据。
### `translation`
```js
{
command: "translation",
time_s: string,
translation: string
}
```
语音识别的内容的翻译,可以根据起始时间确定对应的字幕。
### `print`
```js
@@ -67,7 +79,7 @@ Python 端监听到的音频流转换为的字幕数据。
}
```
输出 Python 端打印的内容。
输出 Python 端打印的内容,不计入日志
### `info`
@@ -78,7 +90,29 @@ Python 端监听到的音频流转换为的字幕数据。
}
```
Python 端打印的提示信息,比起 `print`,该信息更希望 Electron 端的关注
Python 端打印的提示信息,会计入日志
### `warn`
```js
{
command: "warn",
content: string
}
```
Python 端打印的警告信息,会计入日志。
### `error`
```js
{
command: "error",
content: string
}
```
Python 端打印的错误信息,该错误信息会在前端弹窗显示。
### `usage`

View File

@@ -84,7 +84,7 @@
### `control.uiTheme.change`
**介绍:** 前端修改界面主题,将修改同步给后端
**介绍:** 前端修改界面主题,将修改同步给后端
**发起方:** 前端控制窗口
@@ -92,6 +92,16 @@
**数据类型:** `UITheme`
### `control.uiColor.change`
**介绍:** 前端修改界面主题颜色,将修改同步给后端
**发起方:** 前端控制窗口
**接收方:** 后端控制窗口实例
**数据类型:** `string`
### `control.leftBarWidth.change`
**介绍:** 前端修改边栏宽度,将修改同步给后端
@@ -172,6 +182,16 @@
**数据类型:** 无数据
### `control.engine.forceKill`
**介绍:** 强制关闭启动超时的字幕引擎
**发起方:** 前端控制窗口
**接收方:** 后端控制窗口实例
**数据类型:** 无数据
### `caption.windowHeight.change`
**介绍:** 字幕窗口宽度发生改变
@@ -274,6 +294,16 @@
**数据类型:** `Controls`
### `control.softwareLog.add`
**介绍:** 添加一条新的日志数据
**发起方:** 后端
**接收方:** 前端控制窗口
**数据类型:** `SoftwareLog`
### `both.styles.set`
**介绍:** 后端将最新字幕样式发送给前端,前端进行设置

View File

@@ -155,7 +155,7 @@ if __name__ == "__main__":
# Common parameters
parser.add_argument('-e', '--caption_engine', default='gummy', help='Caption engine: gummy or vosk')
parser.add_argument('-a', '--audio_type', default=0, help='Audio stream source: 0 for output, 1 for input')
parser.add_argument('-c', '--chunk_rate', default=20, help='Number of audio stream chunks collected per second')
parser.add_argument('-c', '--chunk_rate', default=10, help='Number of audio stream chunks collected per second')
parser.add_argument('-p', '--port', default=8080, help='The port to run the server on, 0 for no server')
# Gummy-specific parameters
parser.add_argument('-s', '--source_language', default='en', help='Source language code')

View File

@@ -157,7 +157,7 @@ if __name__ == "__main__":
# 共通
parser.add_argument('-e', '--caption_engine', default='gummy', help='字幕エンジン: gummyまたはvosk')
parser.add_argument('-a', '--audio_type', default=0, help='オーディオストリームソース: 0は出力、1は入力')
parser.add_argument('-c', '--chunk_rate', default=20, help='1秒あたりに収集するオーディオストリームブロックの数')
parser.add_argument('-c', '--chunk_rate', default=10, help='1秒あたりに収集するオーディオストリームブロックの数')
parser.add_argument('-p', '--port', default=8080, help='サーバーを実行するポート、0はサーバーなし')
# gummy専用
parser.add_argument('-s', '--source_language', default='en', help='ソース言語コード')

View File

@@ -156,7 +156,7 @@ if __name__ == "__main__":
# both
parser.add_argument('-e', '--caption_engine', default='gummy', help='Caption engine: gummy or vosk')
parser.add_argument('-a', '--audio_type', default=0, help='Audio stream source: 0 for output, 1 for input')
parser.add_argument('-c', '--chunk_rate', default=20, help='Number of audio stream chunks collected per second')
parser.add_argument('-c', '--chunk_rate', default=10, help='Number of audio stream chunks collected per second')
parser.add_argument('-p', '--port', default=8080, help='The port to run the server on, 0 for no server')
# gummy only
parser.add_argument('-s', '--source_language', default='en', help='Source language code')

View File

@@ -1,5 +1,5 @@
appId: com.himeditator.autocaption
productName: auto-caption
productName: Auto Caption
directories:
buildResources: build
files:
@@ -13,13 +13,15 @@ files:
- '!engine/*'
- '!docs/*'
- '!assets/*'
- '!.repomap/*'
- '!.virtualme/*'
extraResources:
# For Windows
- from: ./engine/dist/main.exe
to: ./engine/main.exe
# For macOS and Linux
# - from: ./engine/dist/main
# to: ./engine/main
- from: ./engine/dist/main
to: ./engine/main
win:
executableName: auto-caption
icon: build/icon.png

View File

@@ -1,3 +1,3 @@
from dashscope.common.error import InvalidParameter
from .gummy import GummyRecognizer
from .vosk import VoskRecognizer
from .vosk import VoskRecognizer
from .sosv import SosvRecognizer

View File

@@ -5,9 +5,10 @@ from dashscope.audio.asr import (
TranslationRecognizerRealtime
)
import dashscope
from dashscope.common.error import InvalidParameter
from datetime import datetime
from utils import stdout_cmd, stdout_obj, stderr
from utils import stdout_cmd, stdout_obj, stdout_err
from utils import shared_data
class Callback(TranslationRecognizerCallback):
"""
@@ -90,9 +91,23 @@ class GummyRecognizer:
"""启动 Gummy 引擎"""
self.translator.start()
def send_audio_frame(self, data):
"""发送音频帧,擎将自动识别将识别结果输出到标准输出中"""
self.translator.send_audio_frame(data)
def translate(self):
"""持续读取共享数据中的音频帧,并进行语音识别将识别结果输出到标准输出中"""
global shared_data
restart_count = 0
while shared_data.status == 'running':
chunk = shared_data.chunk_queue.get()
try:
self.translator.send_audio_frame(chunk)
except InvalidParameter as e:
restart_count += 1
if restart_count > 5:
stdout_err(str(e))
shared_data.status = "kill"
stdout_cmd('kill')
break
else:
stdout_cmd('info', f'Gummy engine stopped, restart attempt: {restart_count}...')
def stop(self):
"""停止 Gummy 引擎"""

176
engine/audio2text/sosv.py Normal file
View File

@@ -0,0 +1,176 @@
"""
Shepra-ONNX SenseVoice Model
This code file references the following:
https://github.com/k2-fsa/sherpa-onnx/blob/master/python-api-examples/simulate-streaming-sense-voice-microphone.py
"""
import time
from datetime import datetime
import sherpa_onnx
import threading
import numpy as np
from utils import shared_data
from utils import stdout_cmd, stdout_obj
from utils import google_translate, ollama_translate
class SosvRecognizer:
"""
使用 Sense Voice 非流式模型处理流式音频数据,并在标准输出中输出 Auto Caption 软件可读取的 JSON 字符串数据
初始化参数:
model_path: Shepra ONNX Sense Voice 识别模型路径
vad_model: Silero VAD 模型路径
source: 识别源语言(auto, zh, en, ja, ko, yue)
target: 翻译目标语言
trans_model: 翻译模型名称
ollama_name: Ollama 模型名称
"""
def __init__(self, model_path: str, source: str, target: str | None, trans_model: str, ollama_name: str):
if model_path.startswith('"'):
model_path = model_path[1:]
if model_path.endswith('"'):
model_path = model_path[:-1]
self.model_path = model_path
self.ext = ""
if self.model_path[-4:] == "int8":
self.ext = ".int8"
self.source = source
self.target = target
if trans_model == 'google':
self.trans_func = google_translate
else:
self.trans_func = ollama_translate
self.ollama_name = ollama_name
self.time_str = ''
self.cur_id = 0
self.prev_content = ''
def start(self):
"""启动 Sense Voice 模型"""
self.recognizer = sherpa_onnx.OfflineRecognizer.from_sense_voice(
model=f"{self.model_path}/sensevoice/model{self.ext}.onnx",
tokens=f"{self.model_path}/sensevoice/tokens.txt",
language=self.source,
num_threads = 2,
)
vad_config = sherpa_onnx.VadModelConfig()
vad_config.silero_vad.model = f"{self.model_path}/silero_vad.onnx"
vad_config.silero_vad.threshold = 0.5
vad_config.silero_vad.min_silence_duration = 0.1
vad_config.silero_vad.min_speech_duration = 0.25
vad_config.silero_vad.max_speech_duration = 8
vad_config.sample_rate = 16000
self.window_size = vad_config.silero_vad.window_size
self.vad = sherpa_onnx.VoiceActivityDetector(vad_config, buffer_size_in_seconds=100)
if self.source == 'en':
model_config = sherpa_onnx.OnlinePunctuationModelConfig(
cnn_bilstm=f"{self.model_path}/punct-en/model{self.ext}.onnx",
bpe_vocab=f"{self.model_path}/punct-en/bpe.vocab"
)
punct_config = sherpa_onnx.OnlinePunctuationConfig(
model_config=model_config,
)
self.punct = sherpa_onnx.OnlinePunctuation(punct_config)
else:
punct_config = sherpa_onnx.OfflinePunctuationConfig(
model=sherpa_onnx.OfflinePunctuationModelConfig(
ct_transformer=f"{self.model_path}/punct/model{self.ext}.onnx"
),
)
self.punct = sherpa_onnx.OfflinePunctuation(punct_config)
self.buffer = []
self.offset = 0
self.started = False
self.started_time = .0
self.time_str = datetime.now().strftime('%H:%M:%S.%f')[:-3]
stdout_cmd('info', 'Shepra ONNX Sense Voice recognizer started.')
def send_audio_frame(self, data: bytes):
"""
发送音频帧给 SOSV 引擎,引擎将自动识别并将识别结果输出到标准输出中
Args:
data: 音频帧数据,采样率必须为 16000Hz
"""
caption = {}
caption['command'] = 'caption'
caption['translation'] = ''
data_np = np.frombuffer(data, dtype=np.int16).astype(np.float32)
self.buffer = np.concatenate([self.buffer, data_np])
while self.offset + self.window_size < len(self.buffer):
self.vad.accept_waveform(self.buffer[self.offset: self.offset + self.window_size])
if not self.started and self.vad.is_speech_detected():
self.started = True
self.started_time = time.time()
self.offset += self.window_size
if not self.started:
if len(self.buffer) > 10 * self.window_size:
self.offset -= len(self.buffer) - 10 * self.window_size
self.buffer = self.buffer[-10 * self.window_size:]
if self.started and time.time() - self.started_time > 0.2:
stream = self.recognizer.create_stream()
stream.accept_waveform(16000, self.buffer)
self.recognizer.decode_stream(stream)
text = stream.result.text.strip()
if text and self.prev_content != text:
caption['index'] = self.cur_id
caption['text'] = text
caption['time_s'] = self.time_str
caption['time_t'] = datetime.now().strftime('%H:%M:%S.%f')[:-3]
self.prev_content = text
stdout_obj(caption)
self.started_time = time.time()
while not self.vad.empty():
stream = self.recognizer.create_stream()
stream.accept_waveform(16000, self.vad.front.samples)
self.vad.pop()
self.recognizer.decode_stream(stream)
text = stream.result.text.strip()
if self.source == 'en':
text_with_punct = self.punct.add_punctuation_with_case(text)
else:
text_with_punct = self.punct.add_punctuation(text)
caption['index'] = self.cur_id
caption['text'] = text_with_punct
caption['time_s'] = self.time_str
caption['time_t'] = datetime.now().strftime('%H:%M:%S.%f')[:-3]
if text:
stdout_obj(caption)
if self.target:
th = threading.Thread(
target=self.trans_func,
args=(self.ollama_name, self.target, caption['text'], self.time_str),
daemon=True
)
th.start()
self.cur_id += 1
self.prev_content = ''
self.time_str = datetime.now().strftime('%H:%M:%S.%f')[:-3]
self.buffer = []
self.offset = 0
self.started = False
self.started_time = .0
def translate(self):
"""持续读取共享数据中的音频帧,并进行语音识别,将识别结果输出到标准输出中"""
global shared_data
while shared_data.status == 'running':
chunk = shared_data.chunk_queue.get()
self.send_audio_frame(chunk)
def stop(self):
"""停止 Sense Voice 模型"""
stdout_cmd('info', 'Shepra ONNX Sense Voice recognizer closed.')

View File

@@ -1,8 +1,11 @@
import json
import threading
import time
from datetime import datetime
from vosk import Model, KaldiRecognizer, SetLogLevel
from utils import stdout_cmd, stdout_obj
from utils import shared_data
from utils import stdout_cmd, stdout_obj, google_translate, ollama_translate
class VoskRecognizer:
@@ -11,14 +14,23 @@ class VoskRecognizer:
初始化参数:
model_path: Vosk 识别模型路径
target: 翻译目标语言
trans_model: 翻译模型名称
ollama_name: Ollama 模型名称
"""
def __init__(self, model_path: str):
def __init__(self, model_path: str, target: str | None, trans_model: str, ollama_name: str):
SetLogLevel(-1)
if model_path.startswith('"'):
model_path = model_path[1:]
if model_path.endswith('"'):
model_path = model_path[:-1]
self.model_path = model_path
self.target = target
if trans_model == 'google':
self.trans_func = google_translate
else:
self.trans_func = ollama_translate
self.ollama_name = ollama_name
self.time_str = ''
self.cur_id = 0
self.prev_content = ''
@@ -48,7 +60,16 @@ class VoskRecognizer:
caption['time_s'] = self.time_str
caption['time_t'] = datetime.now().strftime('%H:%M:%S.%f')[:-3]
self.prev_content = ''
if content == '': return
self.cur_id += 1
if self.target:
th = threading.Thread(
target=self.trans_func,
args=(self.ollama_name, self.target, caption['text'], self.time_str),
daemon=True
)
th.start()
else:
content = json.loads(self.recognizer.PartialResult()).get('partial', '')
if content == '' or content == self.prev_content:
@@ -63,6 +84,13 @@ class VoskRecognizer:
stdout_obj(caption)
def translate(self):
"""持续读取共享数据中的音频帧,并进行语音识别,将识别结果输出到标准输出中"""
global shared_data
while shared_data.status == 'running':
chunk = shared_data.chunk_queue.get()
self.send_audio_frame(chunk)
def stop(self):
"""停止 Vosk 引擎"""
stdout_cmd('info', 'Vosk recognizer closed.')

View File

@@ -1,86 +1,153 @@
import wave
import argparse
from utils import stdout_cmd, stderr
from utils import thread_data, start_server
import threading
from utils import stdout, stdout_cmd
from utils import shared_data, start_server
from utils import merge_chunk_channels, resample_chunk_mono
from audio2text import InvalidParameter, GummyRecognizer
from audio2text import GummyRecognizer
from audio2text import VoskRecognizer
from audio2text import SosvRecognizer
from sysaudio import AudioStream
def audio_recording(stream: AudioStream, resample: bool, save = False, path = ''):
global shared_data
stream.open_stream()
wf = None
if save:
if path != '':
path += '/'
wf = wave.open(f'{path}record.wav', 'wb')
wf.setnchannels(stream.CHANNELS)
wf.setsampwidth(stream.SAMP_WIDTH)
wf.setframerate(stream.CHUNK_RATE)
while shared_data.status == 'running':
raw_chunk = stream.read_chunk()
if save: wf.writeframes(raw_chunk) # type: ignore
if raw_chunk is None: continue
if resample:
chunk = resample_chunk_mono(raw_chunk, stream.CHANNELS, stream.RATE, 16000)
else:
chunk = merge_chunk_channels(raw_chunk, stream.CHANNELS)
shared_data.chunk_queue.put(chunk)
if save: wf.close() # type: ignore
stream.close_stream_signal()
def main_gummy(s: str, t: str, a: int, c: int, k: str):
global thread_data
"""
Parameters:
s: Source language
t: Target language
k: Aliyun Bailian API key
"""
stream = AudioStream(a, c)
if t == 'none':
engine = GummyRecognizer(stream.RATE, s, None, k)
else:
engine = GummyRecognizer(stream.RATE, s, t, k)
stream.open_stream()
engine.start()
restart_count = 0
while thread_data.status == "running":
try:
chunk = stream.read_chunk()
if chunk is None: continue
chunk_mono = merge_chunk_channels(chunk, stream.CHANNELS)
try:
engine.send_audio_frame(chunk_mono)
except InvalidParameter as e:
restart_count += 1
if restart_count > 8:
stderr(str(e))
thread_data.status = "kill"
break
else:
stdout_cmd('info', f'Gummy engine stopped, trying to restart #{restart_count}')
except KeyboardInterrupt:
break
stream.close_stream()
stream_thread = threading.Thread(
target=audio_recording,
args=(stream, False),
daemon=True
)
stream_thread.start()
try:
engine.translate()
except KeyboardInterrupt:
stdout("Keyboard interrupt detected. Exiting...")
engine.stop()
def main_vosk(a: int, c: int, m: str):
global thread_data
def main_vosk(a: int, c: int, vosk: str, t: str, tm: str, omn: str):
"""
Parameters:
a: Audio source: 0 for output, 1 for input
c: Chunk number in 1 second
vosk: Vosk model path
t: Target language
tm: Translation model type, ollama or google
omn: Ollama model name
"""
stream = AudioStream(a, c)
engine = VoskRecognizer(m)
if t == 'none':
engine = VoskRecognizer(vosk, None, tm, omn)
else:
engine = VoskRecognizer(vosk, t, tm, omn)
stream.open_stream()
engine.start()
stream_thread = threading.Thread(
target=audio_recording,
args=(stream, True),
daemon=True
)
stream_thread.start()
try:
engine.translate()
except KeyboardInterrupt:
stdout("Keyboard interrupt detected. Exiting...")
engine.stop()
while thread_data.status == "running":
try:
chunk = stream.read_chunk()
if chunk is None: continue
chunk_mono = resample_chunk_mono(chunk, stream.CHANNELS, stream.RATE, 16000)
engine.send_audio_frame(chunk_mono)
except KeyboardInterrupt:
break
stream.close_stream()
def main_sosv(a: int, c: int, sosv: str, s: str, t: str, tm: str, omn: str):
"""
Parameters:
a: Audio source: 0 for output, 1 for input
c: Chunk number in 1 second
sosv: Sherpa-ONNX SenseVoice model path
s: Source language
t: Target language
tm: Translation model type, ollama or google
omn: Ollama model name
"""
stream = AudioStream(a, c)
if t == 'none':
engine = SosvRecognizer(sosv, s, None, tm, omn)
else:
engine = SosvRecognizer(sosv, s, t, tm, omn)
engine.start()
stream_thread = threading.Thread(
target=audio_recording,
args=(stream, True),
daemon=True
)
stream_thread.start()
try:
engine.translate()
except KeyboardInterrupt:
stdout("Keyboard interrupt detected. Exiting...")
engine.stop()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Convert system audio stream to text')
# both
# all
parser.add_argument('-e', '--caption_engine', default='gummy', help='Caption engine: gummy or vosk')
parser.add_argument('-a', '--audio_type', default=0, help='Audio stream source: 0 for output, 1 for input')
parser.add_argument('-c', '--chunk_rate', default=20, help='Number of audio stream chunks collected per second')
parser.add_argument('-p', '--port', default=8080, help='The port to run the server on, 0 for no server')
parser.add_argument('-c', '--chunk_rate', default=10, help='Number of audio stream chunks collected per second')
parser.add_argument('-p', '--port', default=0, help='The port to run the server on, 0 for no server')
parser.add_argument('-t', '--target_language', default='zh', help='Target language code, "none" for no translation')
# gummy and sosv
parser.add_argument('-s', '--source_language', default='auto', help='Source language code')
# gummy only
parser.add_argument('-s', '--source_language', default='en', help='Source language code')
parser.add_argument('-t', '--target_language', default='zh', help='Target language code')
parser.add_argument('-k', '--api_key', default='', help='API KEY for Gummy model')
# vosk and sosv
parser.add_argument('-tm', '--translation_model', default='ollama', help='Model for translation: ollama or google')
parser.add_argument('-omn', '--ollama_name', default='', help='Ollama model name for translation')
# vosk only
parser.add_argument('-m', '--model_path', default='', help='The path to the vosk model.')
parser.add_argument('-vosk', '--vosk_model', default='', help='The path to the vosk model.')
# sosv only
parser.add_argument('-sosv', '--sosv_model', default=None, help='The SenseVoice model path')
args = parser.parse_args()
args = parser.parse_args()
if int(args.port) == 0:
thread_data.status = "running"
shared_data.status = "running"
else:
start_server(int(args.port))
if args.caption_engine == 'gummy':
main_gummy(
args.source_language,
@@ -93,10 +160,23 @@ if __name__ == "__main__":
main_vosk(
int(args.audio_type),
int(args.chunk_rate),
args.model_path
args.vosk_model,
args.target_language,
args.translation_model,
args.ollama_name
)
elif args.caption_engine == 'sosv':
main_sosv(
int(args.audio_type),
int(args.chunk_rate),
args.sosv_model,
args.source_language,
args.target_language,
args.translation_model,
args.ollama_name
)
else:
raise ValueError('Invalid caption engine specified.')
if thread_data.status == "kill":
stdout_cmd('kill')
if shared_data.status == "kill":
stdout_cmd('kill')

View File

@@ -4,9 +4,9 @@ from pathlib import Path
import sys
if sys.platform == 'win32':
vosk_path = str(Path('./subenv/Lib/site-packages/vosk').resolve())
vosk_path = str(Path('./.venv/Lib/site-packages/vosk').resolve())
else:
vosk_path = str(Path('./subenv/lib/python3.12/site-packages/vosk').resolve())
vosk_path = str(Path('./.venv/lib/python3.12/site-packages/vosk').resolve())
a = Analysis(
['main.py'],

10
engine/requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
dashscope
numpy
resampy
vosk
pyinstaller
pyaudio; sys_platform == 'darwin'
pyaudiowpatch; sys_platform == 'win32'
googletrans
ollama
sherpa_onnx

View File

@@ -1,6 +0,0 @@
dashscope
numpy
samplerate
PyAudio
vosk
pyinstaller

View File

@@ -1,5 +0,0 @@
dashscope
numpy
vosk
pyinstaller
samplerate # pip install samplerate --only-binary=:all:

View File

@@ -1,6 +0,0 @@
dashscope
numpy
samplerate
PyAudioWPatch
vosk
pyinstaller

View File

@@ -22,9 +22,9 @@ class AudioStream:
初始化参数:
audio_type: 0-系统音频输出流(需配合 BlackHole1-系统音频输入流
chunk_rate: 每秒采集音频块的数量,默认为20
chunk_rate: 每秒采集音频块的数量,默认为10
"""
def __init__(self, audio_type=0, chunk_rate=20):
def __init__(self, audio_type=0, chunk_rate=10):
self.audio_type = audio_type
self.mic = pyaudio.PyAudio()
if self.audio_type == 0:
@@ -37,8 +37,13 @@ class AudioStream:
self.FORMAT = pyaudio.paInt16
self.SAMP_WIDTH = pyaudio.get_sample_size(self.FORMAT)
self.CHANNELS = int(self.device["maxInputChannels"])
self.RATE = int(self.device["defaultSampleRate"])
self.CHUNK = self.RATE // chunk_rate
self.DEFAULT_RATE = int(self.device["defaultSampleRate"])
self.CHUNK_RATE = chunk_rate
self.RATE = 16000
self.CHUNK = self.RATE // self.CHUNK_RATE
self.open_stream()
self.close_stream()
def get_info(self):
dev_info = f"""
@@ -66,16 +71,27 @@ class AudioStream:
打开并返回系统音频输出流
"""
if self.stream: return self.stream
self.stream = self.mic.open(
format = self.FORMAT,
channels = int(self.CHANNELS),
rate = self.RATE,
input = True,
input_device_index = int(self.INDEX)
)
try:
self.stream = self.mic.open(
format = self.FORMAT,
channels = int(self.CHANNELS),
rate = self.RATE,
input = True,
input_device_index = int(self.INDEX)
)
except OSError:
self.RATE = self.DEFAULT_RATE
self.CHUNK = self.RATE // self.CHUNK_RATE
self.stream = self.mic.open(
format = self.FORMAT,
channels = int(self.CHANNELS),
rate = self.RATE,
input = True,
input_device_index = int(self.INDEX)
)
return self.stream
def read_chunk(self):
def read_chunk(self) -> bytes | None:
"""
读取音频数据
"""

View File

@@ -41,9 +41,9 @@ class AudioStream:
初始化参数:
audio_type: 0-系统音频输出流不支持不会生效1-系统音频输入流(默认)
chunk_rate: 每秒采集音频块的数量,默认为20
chunk_rate: 每秒采集音频块的数量,默认为10
"""
def __init__(self, audio_type=1, chunk_rate=20):
def __init__(self, audio_type=1, chunk_rate=10):
self.audio_type = audio_type
if self.audio_type == 0:
@@ -55,7 +55,8 @@ class AudioStream:
self.FORMAT = 16
self.SAMP_WIDTH = 2
self.CHANNELS = 2
self.RATE = 48000
self.RATE = 16000
self.CHUNK_RATE = chunk_rate
self.CHUNK = self.RATE // chunk_rate
def get_info(self):
@@ -78,7 +79,7 @@ class AudioStream:
启动音频捕获进程
"""
self.process = subprocess.Popen(
["parec", "-d", self.source, "--format=s16le", "--rate=48000", "--channels=2"],
["parec", "-d", self.source, "--format=s16le", "--rate=16000", "--channels=2"],
stdout=subprocess.PIPE
)

View File

@@ -46,9 +46,9 @@ class AudioStream:
初始化参数:
audio_type: 0-系统音频输出流默认1-系统音频输入流
chunk_rate: 每秒采集音频块的数量,默认为20
chunk_rate: 每秒采集音频块的数量,默认为10
"""
def __init__(self, audio_type=0, chunk_rate=20):
def __init__(self, audio_type=0, chunk_rate=10, chunk_size=-1):
self.audio_type = audio_type
self.mic = pyaudio.PyAudio()
if self.audio_type == 0:
@@ -61,8 +61,13 @@ class AudioStream:
self.FORMAT = pyaudio.paInt16
self.SAMP_WIDTH = pyaudio.get_sample_size(self.FORMAT)
self.CHANNELS = int(self.device["maxInputChannels"])
self.RATE = int(self.device["defaultSampleRate"])
self.CHUNK = self.RATE // chunk_rate
self.DEFAULT_RATE = int(self.device["defaultSampleRate"])
self.CHUNK_RATE = chunk_rate
self.RATE = 16000
self.CHUNK = self.RATE // self.CHUNK_RATE
self.open_stream()
self.close_stream()
def get_info(self):
dev_info = f"""
@@ -90,13 +95,24 @@ class AudioStream:
打开并返回系统音频输出流
"""
if self.stream: return self.stream
self.stream = self.mic.open(
format = self.FORMAT,
channels = self.CHANNELS,
rate = self.RATE,
input = True,
input_device_index = self.INDEX
)
try:
self.stream = self.mic.open(
format = self.FORMAT,
channels = self.CHANNELS,
rate = self.RATE,
input = True,
input_device_index = self.INDEX
)
except OSError:
self.RATE = self.DEFAULT_RATE
self.CHUNK = self.RATE // self.CHUNK_RATE
self.stream = self.mic.open(
format = self.FORMAT,
channels = self.CHANNELS,
rate = self.RATE,
input = True,
input_device_index = self.INDEX
)
return self.stream
def read_chunk(self) -> bytes | None:

View File

@@ -1,101 +0,0 @@
import argparse
from utils import stdout_cmd, stderr
from utils import thread_data, start_server
from utils import merge_chunk_channels, resample_chunk_mono
from audio2text import InvalidParameter, GummyRecognizer
from audio2text import VoskRecognizer
from sysaudio import AudioStream
def main_gummy(s: str, t: str, a: int, c: int, k: str):
global thread_data
stream = AudioStream(a, c)
if t == 'none':
engine = GummyRecognizer(stream.RATE, s, None, k)
else:
engine = GummyRecognizer(stream.RATE, s, t, k)
stream.open_stream()
engine.start()
restart_count = 0
while thread_data.status == "running":
try:
chunk = stream.read_chunk()
if chunk is None: continue
chunk_mono = merge_chunk_channels(chunk, stream.CHANNELS)
try:
engine.send_audio_frame(chunk_mono)
except InvalidParameter as e:
restart_count += 1
if restart_count > 8:
stderr(str(e))
thread_data.status = "kill"
break
else:
stdout_cmd('info', f'Gummy engine stopped, trying to restart #{restart_count}')
except KeyboardInterrupt:
break
stream.close_stream()
engine.stop()
def main_vosk(a: int, c: int, m: str):
global thread_data
stream = AudioStream(a, c)
engine = VoskRecognizer(m)
stream.open_stream()
engine.start()
while thread_data.status == "running":
try:
chunk = stream.read_chunk()
if chunk is None: continue
chunk_mono = resample_chunk_mono(chunk, stream.CHANNELS, stream.RATE, 16000)
engine.send_audio_frame(chunk_mono)
except KeyboardInterrupt:
break
stream.close_stream()
engine.stop()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Convert system audio stream to text')
# both
parser.add_argument('-e', '--caption_engine', default='gummy', help='Caption engine: gummy or vosk')
parser.add_argument('-a', '--audio_type', default=0, help='Audio stream source: 0 for output, 1 for input')
parser.add_argument('-c', '--chunk_rate', default=20, help='Number of audio stream chunks collected per second')
parser.add_argument('-p', '--port', default=8080, help='The port to run the server on, 0 for no server')
# gummy
parser.add_argument('-s', '--source_language', default='en', help='Source language code')
parser.add_argument('-t', '--target_language', default='zh', help='Target language code')
parser.add_argument('-k', '--api_key', default='', help='API KEY for Gummy model')
# vosk
parser.add_argument('-m', '--model_path', default='', help='The path to the vosk model.')
args = parser.parse_args()
if int(args.port) == 0:
thread_data.status = "running"
else:
start_server(int(args.port))
if args.caption_engine == 'gummy':
main_gummy(
args.source_language,
args.target_language,
int(args.audio_type),
int(args.chunk_rate),
args.api_key
)
elif args.caption_engine == 'vosk':
main_vosk(
int(args.audio_type),
int(args.chunk_rate),
args.model_path
)
else:
raise ValueError('Invalid caption engine specified.')
if thread_data.status == "kill":
stdout_cmd('kill')

View File

@@ -1,4 +1,5 @@
from .audioprcs import merge_chunk_channels, resample_chunk_mono, resample_mono_chunk
from .sysout import stdout, stdout_cmd, stdout_obj, stderr
from .thdata import thread_data
from .server import start_server
from .audioprcs import merge_chunk_channels, resample_chunk_mono
from .sysout import stdout, stdout_err, stdout_cmd, stdout_obj, stderr
from .shared import shared_data
from .server import start_server
from .translation import ollama_translate, google_translate

View File

@@ -1,4 +1,4 @@
import samplerate
import resampy
import numpy as np
import numpy.core.multiarray # do not remove
@@ -24,16 +24,15 @@ def merge_chunk_channels(chunk: bytes, channels: int) -> bytes:
return chunk_mono.tobytes()
def resample_chunk_mono(chunk: bytes, channels: int, orig_sr: int, target_sr: int, mode="sinc_best") -> bytes:
def resample_chunk_mono(chunk: bytes, channels: int, orig_sr: int, target_sr: int) -> bytes:
"""
将当前多通道音频数据块转换成单通道音频数据块,然后进行重采样
将当前多通道音频数据块转换成单通道音频数据块,进行重采样
Args:
chunk: 多通道音频数据块
channels: 通道数
orig_sr: 原始采样率
target_sr: 目标采样率
mode: 重采样模式,可选:'sinc_best' | 'sinc_medium' | 'sinc_fastest' | 'zero_order_hold' | 'linear'
Return:
单通道音频数据块
@@ -49,28 +48,17 @@ def resample_chunk_mono(chunk: bytes, channels: int, orig_sr: int, target_sr: in
# (length,)
chunk_mono = np.mean(chunk_np.astype(np.float32), axis=1)
ratio = target_sr / orig_sr
chunk_mono_r = samplerate.resample(chunk_mono, ratio, converter_type=mode)
if orig_sr == target_sr:
return chunk_mono.astype(np.int16).tobytes()
chunk_mono_r = resampy.resample(chunk_mono, orig_sr, target_sr)
chunk_mono_r = np.round(chunk_mono_r).astype(np.int16)
real_len = round(chunk_mono.shape[0] * target_sr / orig_sr)
if(chunk_mono_r.shape[0] != real_len):
print(chunk_mono_r.shape[0], real_len)
if(chunk_mono_r.shape[0] > real_len):
chunk_mono_r = chunk_mono_r[:real_len]
else:
while chunk_mono_r.shape[0] < real_len:
chunk_mono_r = np.append(chunk_mono_r, chunk_mono_r[-1])
return chunk_mono_r.tobytes()
def resample_mono_chunk(chunk: bytes, orig_sr: int, target_sr: int, mode="sinc_best") -> bytes:
"""
将当前单通道音频块进行重采样
Args:
chunk: 单通道音频数据块
orig_sr: 原始采样率
target_sr: 目标采样率
mode: 重采样模式,可选:'sinc_best' | 'sinc_medium' | 'sinc_fastest' | 'zero_order_hold' | 'linear'
Return:
单通道音频数据块
"""
chunk_np = np.frombuffer(chunk, dtype=np.int16)
chunk_np = chunk_np.astype(np.float32)
ratio = target_sr / orig_sr
chunk_r = samplerate.resample(chunk_np, ratio, converter_type=mode)
chunk_r = np.round(chunk_r).astype(np.int16)
return chunk_r.tobytes()

View File

@@ -1,12 +1,12 @@
import socket
import threading
import json
from utils import thread_data, stdout_cmd, stderr
from utils import shared_data, stdout_cmd, stderr
def handle_client(client_socket):
global thread_data
while thread_data.status == 'running':
global shared_data
while shared_data.status == 'running':
try:
data = client_socket.recv(4096).decode('utf-8')
if not data:
@@ -14,13 +14,13 @@ def handle_client(client_socket):
data = json.loads(data)
if data['command'] == 'stop':
thread_data.status = 'stop'
shared_data.status = 'stop'
break
except Exception as e:
stderr(f'Communication error: {e}')
break
thread_data.status = 'stop'
shared_data.status = 'stop'
client_socket.close()

8
engine/utils/shared.py Normal file
View File

@@ -0,0 +1,8 @@
import queue
class SharedData:
def __init__(self):
self.status = "running"
self.chunk_queue = queue.Queue()
shared_data = SharedData()

View File

@@ -4,6 +4,9 @@ import json
def stdout(text: str):
stdout_cmd("print", text)
def stdout_err(text: str):
stdout_cmd("error", text)
def stdout_cmd(command: str, content = ""):
msg = { "command": command, "content": content }
sys.stdout.write(json.dumps(msg) + "\n")

View File

@@ -1,5 +0,0 @@
class ThreadData:
def __init__(self):
self.status = "running"
thread_data = ThreadData()

View File

@@ -0,0 +1,49 @@
from ollama import chat
from ollama import ChatResponse
import asyncio
from googletrans import Translator
from .sysout import stdout_cmd, stdout_obj
lang_map = {
'en': 'English',
'es': 'Spanish',
'fr': 'French',
'de': 'German',
'it': 'Italian',
'ru': 'Russian',
'ja': 'Japanese',
'ko': 'Korean',
'zh': 'Chinese',
'zh-cn': 'Chinese'
}
def ollama_translate(model: str, target: str, text: str, time_s: str):
response: ChatResponse = chat(
model=model,
messages=[
{"role": "system", "content": f"/no_think Translate the following content into {lang_map[target]}, and do not output any additional information."},
{"role": "user", "content": text}
]
)
content = response.message.content or ""
if content.startswith('<think>'):
index = content.find('</think>')
if index != -1:
content = content[index+8:]
stdout_obj({
"command": "translation",
"time_s": time_s,
"translation": content.strip()
})
def google_translate(model: str, target: str, text: str, time_s: str):
translator = Translator()
try:
res = asyncio.run(translator.translate(text, dest=target))
stdout_obj({
"command": "translation",
"time_s": time_s,
"translation": res.text
})
except Exception as e:
stdout_cmd("warn", f"Google translation request failed, please check your network connection...")

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "auto-caption",
"version": "0.6.0",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "auto-caption",
"version": "0.6.0",
"version": "0.7.0",
"hasInstallScript": true,
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",

View File

@@ -1,7 +1,7 @@
{
"name": "auto-caption",
"productName": "Auto Caption",
"version": "0.6.0",
"version": "0.7.0",
"description": "A cross-platform subtitle display software.",
"main": "./out/main/index.js",
"author": "himeditator",

View File

@@ -3,6 +3,7 @@ import path from 'path'
import { is } from '@electron-toolkit/utils'
import icon from '../../build/icon.png?asset'
import { controlWindow } from './ControlWindow'
import { allConfig } from './utils/AllConfig'
class CaptionWindow {
window: BrowserWindow | undefined;
@@ -10,7 +11,7 @@ class CaptionWindow {
public createWindow(): void {
this.window = new BrowserWindow({
icon: icon,
width: 900,
width: allConfig.captionWindowWidth,
height: 100,
minWidth: 480,
show: false,
@@ -30,6 +31,12 @@ class CaptionWindow {
this.window?.show()
})
this.window.on('close', () => {
if(this.window) {
allConfig.captionWindowWidth = this.window?.getBounds().width;
}
})
this.window.on('closed', () => {
this.window = undefined
})

View File

@@ -7,8 +7,10 @@ import icon from '../../build/icon.png?asset'
import { captionWindow } from './CaptionWindow'
import { allConfig } from './utils/AllConfig'
import { captionEngine } from './utils/CaptionEngine'
import { Log } from './utils/Log'
class ControlWindow {
mounted: boolean = false;
window: BrowserWindow | undefined;
public createWindow(): void {
@@ -34,6 +36,7 @@ class ControlWindow {
})
this.window.on('closed', () => {
this.mounted = false
this.window = undefined
allConfig.writeConfig()
})
@@ -63,7 +66,8 @@ class ControlWindow {
})
ipcMain.handle('both.window.mounted', () => {
return allConfig.getFullConfig()
this.mounted = true
return allConfig.getFullConfig(Log.getAndClearLogQueue())
})
ipcMain.handle('control.nativeTheme.get', () => {
@@ -109,6 +113,10 @@ class ControlWindow {
allConfig.uiTheme = args
})
ipcMain.on('control.uiColor.change', (_, args) => {
allConfig.uiColor = args
})
ipcMain.on('control.leftBarWidth.change', (_, args) => {
allConfig.leftBarWidth = args
})
@@ -151,6 +159,10 @@ class ControlWindow {
captionEngine.stop()
})
ipcMain.on('control.engine.forceKill', () => {
captionEngine.kill()
})
ipcMain.on('control.captionLog.clear', () => {
allConfig.captionLog.splice(0)
})

View File

@@ -4,5 +4,6 @@ export default {
"engine.start.error": "Caption engine failed to start: ",
"engine.output.parse.error": "Unable to parse caption engine output as a JSON object: ",
"engine.error": "Caption engine error: ",
"engine.shutdown.error": "Failed to shut down the caption engine process: "
"engine.shutdown.error": "Failed to shut down the caption engine process: ",
"engine.start.timeout": "Caption engine startup timeout, automatically force stopped"
}

View File

@@ -4,5 +4,6 @@ export default {
"engine.start.error": "字幕エンジンの起動に失敗しました: ",
"engine.output.parse.error": "字幕エンジンの出力を JSON オブジェクトとして解析できませんでした: ",
"engine.error": "字幕エンジンエラー: ",
"engine.shutdown.error": "字幕エンジンプロセスの終了に失敗しました: "
"engine.shutdown.error": "字幕エンジンプロセスの終了に失敗しました: ",
"engine.start.timeout": "字幕エンジンの起動がタイムアウトしました。自動的に強制停止しました"
}

View File

@@ -4,5 +4,6 @@ export default {
"engine.start.error": "字幕引擎启动失败:",
"engine.output.parse.error": "字幕引擎输出内容无法解析为 JSON 对象:",
"engine.error": "字幕引擎错误:",
"engine.shutdown.error": "字幕引擎进程关闭失败:"
"engine.shutdown.error": "字幕引擎进程关闭失败:",
"engine.start.timeout": "字幕引擎启动超时,已自动强制停止"
}

View File

@@ -25,7 +25,7 @@ app.whenReady().then(() => {
})
app.on('will-quit', async () => {
captionEngine.stop()
captionEngine.kill()
allConfig.writeConfig()
});

View File

@@ -6,6 +6,8 @@ export interface Controls {
engineEnabled: boolean,
sourceLang: string,
targetLang: string,
transModel: string,
ollamaName: string,
engine: string,
audio: 0 | 1,
translation: boolean,
@@ -13,7 +15,8 @@ export interface Controls {
modelPath: string,
customized: boolean,
customizedApp: string,
customizedCommand: string
customizedCommand: string,
startTimeoutSeconds: number
}
export interface Styles {
@@ -45,14 +48,23 @@ export interface CaptionItem {
translation: string
}
export interface SoftwareLogItem {
type: "INFO" | "WARN" | "ERROR",
index: number,
time: string,
text: string
}
export interface FullConfig {
platform: string,
uiLanguage: UILanguage,
uiTheme: UITheme,
uiColor: string,
leftBarWidth: number,
styles: Styles,
controls: Controls,
captionLog: CaptionItem[]
captionLog: CaptionItem[],
softwareLog: SoftwareLogItem[]
}
export interface EngineInfo {

View File

@@ -1,12 +1,17 @@
import {
UILanguage, UITheme, Styles, Controls,
CaptionItem, FullConfig
CaptionItem, FullConfig, SoftwareLogItem
} from '../types'
import { Log } from './Log'
import { app, BrowserWindow } from 'electron'
import * as path from 'path'
import * as fs from 'fs'
interface CaptionTranslation {
time_s: string,
translation: string
}
const defaultStyles: Styles = {
lineBreak: 1,
fontFamily: 'sans-serif',
@@ -31,6 +36,8 @@ const defaultStyles: Styles = {
const defaultControls: Controls = {
sourceLang: 'en',
targetLang: 'zh',
transModel: 'ollama',
ollamaName: '',
engine: 'gummy',
audio: 0,
engineEnabled: false,
@@ -39,16 +46,21 @@ const defaultControls: Controls = {
translation: true,
customized: false,
customizedApp: '',
customizedCommand: ''
customizedCommand: '',
startTimeoutSeconds: 30
};
class AllConfig {
captionWindowWidth: number = 900;
uiLanguage: UILanguage = 'zh';
leftBarWidth: number = 8;
uiTheme: UITheme = 'system';
uiColor: string = '#1677ff';
styles: Styles = {...defaultStyles};
controls: Controls = {...defaultControls};
lastLogIndex: number = -1;
captionLog: CaptionItem[] = [];
@@ -57,20 +69,24 @@ class AllConfig {
public readConfig() {
const configPath = path.join(app.getPath('userData'), 'config.json')
if(fs.existsSync(configPath)){
Log.info('Read Config from:', configPath)
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if(config.captionWindowWidth) this.captionWindowWidth = config.captionWindowWidth
if(config.uiLanguage) this.uiLanguage = config.uiLanguage
if(config.uiTheme) this.uiTheme = config.uiTheme
if(config.uiColor) this.uiColor = config.uiColor
if(config.leftBarWidth) this.leftBarWidth = config.leftBarWidth
if(config.styles) this.setStyles(config.styles)
if(config.controls) this.setControls(config.controls)
Log.info('Read Config from:', configPath)
}
}
public writeConfig() {
const config = {
captionWindowWidth: this.captionWindowWidth,
uiLanguage: this.uiLanguage,
uiTheme: this.uiTheme,
uiColor: this.uiColor,
leftBarWidth: this.leftBarWidth,
controls: this.controls,
styles: this.styles
@@ -80,15 +96,17 @@ class AllConfig {
Log.info('Write Config to:', configPath)
}
public getFullConfig(): FullConfig {
public getFullConfig(softwareLog: SoftwareLogItem[]): FullConfig {
return {
platform: process.platform,
uiLanguage: this.uiLanguage,
uiTheme: this.uiTheme,
uiColor: this.uiColor,
leftBarWidth: this.leftBarWidth,
styles: this.styles,
controls: this.controls,
captionLog: this.captionLog
captionLog: this.captionLog,
softwareLog: softwareLog
}
}
@@ -118,7 +136,9 @@ class AllConfig {
}
}
this.controls.engineEnabled = engineEnabled
Log.info('Set Controls:', this.controls)
let _controls = {...this.controls}
_controls.API_KEY = _controls.API_KEY.replace(/./g, '*')
Log.info('Set Controls:', _controls)
}
public sendControls(window: BrowserWindow, info = true) {
@@ -145,12 +165,28 @@ class AllConfig {
}
}
public sendCaptionLog(window: BrowserWindow, command: 'add' | 'upd' | 'set') {
public updateCaptionTranslation(trans: CaptionTranslation){
for(let i = this.captionLog.length - 1; i >= 0; i--){
if(this.captionLog[i].time_s === trans.time_s){
this.captionLog[i].translation = trans.translation
for(const window of BrowserWindow.getAllWindows()){
this.sendCaptionLog(window, 'upd', i)
}
break
}
}
}
public sendCaptionLog(
window: BrowserWindow,
command: 'add' | 'upd' | 'set',
index: number | undefined = undefined
) {
if(command === 'add'){
window.webContents.send(`both.captionLog.add`, this.captionLog[this.captionLog.length - 1])
window.webContents.send(`both.captionLog.add`, this.captionLog.at(-1))
}
else if(command === 'upd'){
window.webContents.send(`both.captionLog.upd`, this.captionLog[this.captionLog.length - 1])
if(index !== undefined) window.webContents.send(`both.captionLog.upd`, this.captionLog[index])
else window.webContents.send(`both.captionLog.upd`, this.captionLog.at(-1))
}
else if(command === 'set'){
window.webContents.send(`both.captionLog.set`, this.captionLog)

View File

@@ -14,7 +14,9 @@ export class CaptionEngine {
process: any | undefined
client: net.Socket | undefined
port: number = 8080
status: 'running' | 'starting' | 'stopping' | 'stopped' = 'stopped'
status: 'running' | 'starting' | 'stopping' | 'stopped' | 'starting-timeout' = 'stopped'
timerID: NodeJS.Timeout | undefined
startTimeoutID: NodeJS.Timeout | undefined
private getApp(): boolean {
if (allConfig.controls.customized) {
@@ -36,7 +38,7 @@ export class CaptionEngine {
if(process.platform === "win32") {
this.appPath = path.join(
app.getAppPath(), 'engine',
'subenv', 'Scripts', 'python.exe'
'.venv', 'Scripts', 'python.exe'
)
this.command.push(path.join(
app.getAppPath(), 'engine', 'main.py'
@@ -46,7 +48,7 @@ export class CaptionEngine {
else {
this.appPath = path.join(
app.getAppPath(), 'engine',
'subenv', 'bin', 'python3'
'.venv', 'bin', 'python3'
)
this.command.push(path.join(
app.getAppPath(), 'engine', 'main.py'
@@ -65,32 +67,41 @@ export class CaptionEngine {
this.command.push('-a', allConfig.controls.audio ? '1' : '0')
this.port = Math.floor(Math.random() * (65535 - 1024 + 1)) + 1024
this.command.push('-p', this.port.toString())
this.command.push(
'-t', allConfig.controls.translation ?
allConfig.controls.targetLang : 'none'
)
if(allConfig.controls.engine === 'gummy') {
this.command.push('-e', 'gummy')
this.command.push('-s', allConfig.controls.sourceLang)
this.command.push(
'-t', allConfig.controls.translation ?
allConfig.controls.targetLang : 'none'
)
if(allConfig.controls.API_KEY) {
this.command.push('-k', allConfig.controls.API_KEY)
}
}
else if(allConfig.controls.engine === 'vosk'){
this.command.push('-e', 'vosk')
this.command.push('-m', `"${allConfig.controls.modelPath}"`)
this.command.push('-vosk', `"${allConfig.controls.modelPath}"`)
this.command.push('-tm', allConfig.controls.transModel)
this.command.push('-omn', allConfig.controls.ollamaName)
}
}
Log.info('Engine Path:', this.appPath)
Log.info('Engine Command:', this.command)
if(this.command.length > 2 && this.command.at(-2) === '-k') {
const _command = [...this.command]
_command[_command.length -1] = _command[_command.length -1].replace(/./g, '*')
Log.info('Engine Command:', _command)
}
else Log.info('Engine Command:', this.command)
return true
}
public connect() {
Log.info('Connecting to caption engine server...')
if(this.client) { Log.warn('Client already exists, ignoring...') }
if (this.startTimeoutID) {
clearTimeout(this.startTimeoutID)
this.startTimeoutID = undefined
}
this.client = net.createConnection({ port: this.port }, () => {
Log.info('Connected to caption engine server');
});
@@ -125,6 +136,16 @@ export class CaptionEngine {
this.process = spawn(this.appPath, this.command)
this.status = 'starting'
Log.info('Caption Engine Starting, PID:', this.process.pid)
const timeoutMs = allConfig.controls.startTimeoutSeconds * 1000
this.startTimeoutID = setTimeout(() => {
if (this.status === 'starting') {
Log.warn(`Engine start timeout after ${allConfig.controls.startTimeoutSeconds} seconds, forcing kill...`)
this.status = 'starting-timeout'
controlWindow.sendErrorMessage(i18n('engine.start.timeout'))
this.kill()
}
}, timeoutMs)
this.process.stdout.on('data', (data: any) => {
const lines = data.toString().split('\n')
@@ -145,8 +166,7 @@ export class CaptionEngine {
const lines = data.toString().split('\n')
lines.forEach((line: string) => {
if(line.trim()){
controlWindow.sendErrorMessage(/*i18n('engine.error') +*/ line)
console.error(line)
Log.error(line)
}
})
});
@@ -160,6 +180,11 @@ export class CaptionEngine {
controlWindow.window.webContents.send('control.engine.stopped')
}
this.status = 'stopped'
clearInterval(this.timerID)
if (this.startTimeoutID) {
clearTimeout(this.startTimeoutID)
this.startTimeoutID = undefined
}
Log.info(`Engine exited with code ${code}`)
});
}
@@ -167,7 +192,6 @@ export class CaptionEngine {
public stop() {
if(this.status !== 'running'){
Log.warn('Trying to stop engine which is not running, current status:', this.status)
return
}
this.sendCommand('stop')
if(this.client){
@@ -175,26 +199,41 @@ export class CaptionEngine {
this.client = undefined
}
this.status = 'stopping'
Log.info('Caption engine process stopping...')
this.timerID = setTimeout(() => {
if(this.status !== 'stopping') return
Log.warn('Engine process still not stopped, trying to kill...')
this.kill()
}, 4000);
}
public kill(){
if(!this.process || !this.process.pid) return
if(this.status !== 'running'){
Log.warn('Trying to kill engine which is not running, current status:', this.status)
}
Log.warn('Trying to kill engine process, PID:', this.process.pid)
Log.warn('Killing engine process, PID:', this.process.pid)
if (this.startTimeoutID) {
clearTimeout(this.startTimeoutID)
this.startTimeoutID = undefined
}
if(this.client){
this.client.destroy()
this.client = undefined
}
if (this.process.pid) {
let cmd = `kill ${this.process.pid}`;
let cmd = `kill -9 ${this.process.pid}`;
if (process.platform === "win32") {
cmd = `taskkill /pid ${this.process.pid} /t /f`
}
exec(cmd)
exec(cmd, (error) => {
if (error) {
Log.error('Failed to kill process:', error)
} else {
Log.info('Process killed successfully')
}
})
}
this.status = 'stopping'
}
}
@@ -211,14 +250,24 @@ function handleEngineData(data: any) {
else if(data.command === 'caption') {
allConfig.updateCaptionLog(data);
}
else if(data.command === 'translation') {
allConfig.updateCaptionTranslation(data);
}
else if(data.command === 'print') {
Log.info('Engine Print:', data.content)
console.log(data.content)
}
else if(data.command === 'info') {
Log.info('Engine Info:', data.content)
}
else if(data.command === 'warn') {
Log.warn('Engine Warn:', data.content)
}
else if(data.command === 'error') {
Log.error('Engine Error:', data.content)
controlWindow.sendErrorMessage(/*i18n('engine.error') +*/ data.content)
}
else if(data.command === 'usage') {
Log.info('Engine Usage: ', data.content)
Log.info('Engine Token Usage: ', data.content)
}
else {
Log.warn('Unknown command:', data)

View File

@@ -1,3 +1,9 @@
import { controlWindow } from "../ControlWindow"
import { type SoftwareLogItem } from "../types"
let logIndex = 0
const logQueue: SoftwareLogItem[] = []
function getTimeString() {
const now = new Date()
const HH = String(now.getHours()).padStart(2, '0')
@@ -8,15 +14,45 @@ function getTimeString() {
}
export class Log {
static getAndClearLogQueue() {
const copiedQueue = structuredClone(logQueue)
logQueue.length = 0
return copiedQueue
}
static handleLog(logType: "INFO" | "WARN" | "ERROR", ...msg: any[]) {
const timeStr = getTimeString()
const logPre = `[${logType} ${timeStr}]`
let logStr = ""
for(let i = 0; i < msg.length; i++) {
logStr += i ? " " : ""
if(typeof msg[i] === "string") logStr += msg[i]
else logStr += JSON.stringify(msg[i], undefined, 2)
}
console.log(logPre, logStr)
const logItem: SoftwareLogItem = {
type: logType,
index: ++logIndex,
time: timeStr,
text: logStr
}
if(controlWindow.mounted && controlWindow.window) {
controlWindow.window.webContents.send('control.softwareLog.add', logItem)
}
else {
logQueue.push(logItem)
}
}
static info(...msg: any[]){
console.log(`[INFO ${getTimeString()}]`, ...msg)
this.handleLog("INFO", ...msg)
}
static warn(...msg: any[]){
console.warn(`[WARN ${getTimeString()}]`, ...msg)
this.handleLog("WARN", ...msg)
}
static error(...msg: any[]){
console.error(`[ERROR ${getTimeString()}]`, ...msg)
this.handleLog("ERROR", ...msg)
}
}

View File

@@ -2,7 +2,7 @@
<html>
<head>
<meta charset="UTF-8" />
<title>Auto Caption</title>
<title>Auto Caption v0.7.0</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"

View File

@@ -6,6 +6,7 @@
import { onMounted } from 'vue'
import { FullConfig } from './types'
import { useCaptionLogStore } from './stores/captionLog'
import { useSoftwareLogStore } from './stores/softwareLog'
import { useCaptionStyleStore } from './stores/captionStyle'
import { useEngineControlStore } from './stores/engineControl'
import { useGeneralSettingStore } from './stores/generalSetting'
@@ -14,11 +15,13 @@ onMounted(() => {
window.electron.ipcRenderer.invoke('both.window.mounted').then((data: FullConfig) => {
useGeneralSettingStore().uiLanguage = data.uiLanguage
useGeneralSettingStore().uiTheme = data.uiTheme
useGeneralSettingStore().uiColor = data.uiColor
useGeneralSettingStore().leftBarWidth = data.leftBarWidth
useCaptionStyleStore().setStyles(data.styles)
useEngineControlStore().platform = data.platform
useEngineControlStore().setControls(data.controls)
useCaptionLogStore().captionData = data.captionLog
useSoftwareLogStore().softwareLogs = data.softwareLog
})
})
</script>

View File

@@ -17,6 +17,7 @@
}
.input-area {
display: inline-block;
width: calc(100% - 100px);
min-width: 100px;
}

View File

@@ -1,138 +1,151 @@
<template>
<div class="caption-list">
<div>
<a-app class="caption-title">
<span style="margin-right: 30px;">{{ $t('log.title') }}</span>
</a-app>
<a-popover :title="$t('log.baseTime')">
<template #content>
<div class="base-time">
<div class="base-time-container">
<a-input
type="number" min="0"
v-model:value="baseHH"
></a-input>
<span class="base-time-label">{{ $t('log.hour') }}</span>
</div>
</div><span style="margin: 0 4px;">:</span>
<div class="base-time">
<div class="base-time-container">
<a-input
type="number" min="0" max="59"
v-model:value="baseMM"
></a-input>
<span class="base-time-label">{{ $t('log.min') }}</span>
</div>
</div><span style="margin: 0 4px;">:</span>
<div class="base-time">
<div class="base-time-container">
<a-input
type="number" min="0" max="59"
v-model:value="baseSS"
></a-input>
<span class="base-time-label">{{ $t('log.sec') }}</span>
</div>
</div><span style="margin: 0 4px;">.</span>
<div class="base-time">
<div class="base-time-container">
<a-input
type="number" min="0" max="999"
v-model:value="baseMS"
></a-input>
<span class="base-time-label">{{ $t('log.ms') }}</span>
</div>
</div>
</template>
<a-button
type="primary"
style="margin-right: 20px;"
@click="changeBaseTime"
:disabled="captionData.length === 0"
>{{ $t('log.changeTime') }}</a-button>
</a-popover>
<a-popover :title="$t('log.exportOptions')">
<template #content>
<div class="input-item">
<span class="input-label">{{ $t('log.exportFormat') }}</span>
<a-radio-group v-model:value="exportFormat">
<a-radio-button value="srt">.srt</a-radio-button>
<a-radio-button value="json">.json</a-radio-button>
</a-radio-group>
</div>
<div class="input-item">
<span class="input-label">{{ $t('log.exportContent') }}</span>
<a-radio-group v-model:value="contentOption">
<a-radio-button value="both">{{ $t('log.both') }}</a-radio-button>
<a-radio-button value="source">{{ $t('log.source') }}</a-radio-button>
<a-radio-button value="target">{{ $t('log.translation') }}</a-radio-button>
</a-radio-group>
</div>
</template>
<a-button
style="margin-right: 20px;"
@click="exportCaptions"
:disabled="captionData.length === 0"
>{{ $t('log.export') }}</a-button>
</a-popover>
<a-popover :title="$t('log.copyOptions')">
<template #content>
<div class="input-item">
<span class="input-label">{{ $t('log.addIndex') }}</span>
<a-switch v-model:checked="showIndex" />
<span class="input-label">{{ $t('log.copyTime') }}</span>
<a-switch v-model:checked="copyTime" />
</div>
<div class="input-item">
<span class="input-label">{{ $t('log.copyContent') }}</span>
<a-radio-group v-model:value="contentOption">
<a-radio-button value="both">{{ $t('log.both') }}</a-radio-button>
<a-radio-button value="source">{{ $t('log.source') }}</a-radio-button>
<a-radio-button value="target">{{ $t('log.translation') }}</a-radio-button>
</a-radio-group>
</div>
</template>
<a-button
style="margin-right: 20px;"
@click="copyCaptions"
>{{ $t('log.copy') }}</a-button>
</a-popover>
<a-button
danger
@click="clearCaptions"
>{{ $t('log.clear') }}</a-button>
<div>
<div class="caption-title">
<span style="margin-right: 30px;">{{ $t('log.title') }}</span>
</div>
<a-table
:columns="columns"
:data-source="captionData"
v-model:pagination="pagination"
style="margin-top: 10px;"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'index'">
{{ record.index }}
</template>
<template v-if="column.key === 'time'">
<div class="time-cell">
<div class="time-start">{{ record.time_s }}</div>
<div class="time-end">{{ record.time_t }}</div>
<a-popover :title="$t('log.baseTime')">
<template #content>
<div class="base-time">
<div class="base-time-container">
<a-input
type="number" min="0"
v-model:value="baseHH"
></a-input>
<span class="base-time-label">{{ $t('log.hour') }}</span>
</div>
</template>
<template v-if="column.key === 'content'">
<div class="caption-content">
<div class="caption-text">{{ record.text }}</div>
<div class="caption-translation">{{ record.translation }}</div>
</div><span style="margin: 0 4px;">:</span>
<div class="base-time">
<div class="base-time-container">
<a-input
type="number" min="0" max="59"
v-model:value="baseMM"
></a-input>
<span class="base-time-label">{{ $t('log.min') }}</span>
</div>
</template>
</div><span style="margin: 0 4px;">:</span>
<div class="base-time">
<div class="base-time-container">
<a-input
type="number" min="0" max="59"
v-model:value="baseSS"
></a-input>
<span class="base-time-label">{{ $t('log.sec') }}</span>
</div>
</div><span style="margin: 0 4px;">.</span>
<div class="base-time">
<div class="base-time-container">
<a-input
type="number" min="0" max="999"
v-model:value="baseMS"
></a-input>
<span class="base-time-label">{{ $t('log.ms') }}</span>
</div>
</div>
</template>
</a-table>
<a-button
type="primary"
style="margin-right: 20px;"
@click="changeBaseTime"
:disabled="captionData.length === 0"
>{{ $t('log.changeTime') }}</a-button>
</a-popover>
<a-popover :title="$t('log.exportOptions')">
<template #content>
<div class="input-item">
<span class="input-label">{{ $t('log.exportFormat') }}</span>
<a-radio-group v-model:value="exportFormat">
<a-radio-button value="srt"><code>.srt</code></a-radio-button>
<a-radio-button value="json"><code>.json</code></a-radio-button>
</a-radio-group>
</div>
<div class="input-item">
<span class="input-label">{{ $t('log.exportContent') }}</span>
<a-radio-group v-model:value="contentOption">
<a-radio-button value="both">{{ $t('log.both') }}</a-radio-button>
<a-radio-button value="source">{{ $t('log.source') }}</a-radio-button>
<a-radio-button value="target">{{ $t('log.translation') }}</a-radio-button>
</a-radio-group>
</div>
</template>
<a-button
style="margin-right: 20px;"
@click="exportCaptions"
:disabled="captionData.length === 0"
>{{ $t('log.export') }}</a-button>
</a-popover>
<a-popover :title="$t('log.copyOptions')">
<template #content>
<div class="input-item">
<span class="input-label">{{ $t('log.addIndex') }}</span>
<a-switch v-model:checked="showIndex" />
<span class="input-label">{{ $t('log.copyTime') }}</span>
<a-switch v-model:checked="copyTime" />
</div>
<div class="input-item">
<span class="input-label">{{ $t('log.copyContent') }}</span>
<a-radio-group v-model:value="contentOption">
<a-radio-button value="both">{{ $t('log.both') }}</a-radio-button>
<a-radio-button value="source">{{ $t('log.source') }}</a-radio-button>
<a-radio-button value="target">{{ $t('log.translation') }}</a-radio-button>
</a-radio-group>
</div>
<div class="input-item">
<span class="input-label">{{ $t('log.copyNum') }}</span>
<a-radio-group v-model:value="copyNum">
<a-radio-button :value="0"><code>[:]</code></a-radio-button>
<a-radio-button :value="1"><code>[-1:]</code></a-radio-button>
<a-radio-button :value="2"><code>[-2:]</code></a-radio-button>
<a-radio-button :value="3"><code>[-3:]</code></a-radio-button>
</a-radio-group>
</div>
</template>
<a-button
style="margin-right: 20px;"
@click="copyCaptions"
>{{ $t('log.copy') }}</a-button>
</a-popover>
<a-button
danger
@click="clearCaptions"
>{{ $t('log.clear') }}</a-button>
</div>
<a-table
:columns="columns"
:data-source="captionData"
v-model:pagination="pagination"
style="margin-top: 10px;"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'index'">
{{ record.index }}
</template>
<template v-if="column.key === 'time'">
<div class="time-cell">
<code class="time-start"
:style="`color: ${uiColor}`"
>{{ record.time_s }}</code>
<code class="time-end">{{ record.time_t }}</code>
</div>
</template>
<template v-if="column.key === 'content'">
<div class="caption-content">
<div class="caption-text">{{ record.text }}</div>
<div
class="caption-translation"
:style="`border-left: 3px solid ${uiColor};`"
>{{ record.translation }}</div>
</div>
</template>
</template>
</a-table>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useCaptionLogStore } from '@renderer/stores/captionLog'
import { useGeneralSettingStore } from '@renderer/stores/generalSetting'
import { message } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import * as tc from '../utils/timeCalc'
@@ -143,10 +156,14 @@ const { t } = useI18n()
const captionLog = useCaptionLogStore()
const { captionData } = storeToRefs(captionLog)
const generalSetting = useGeneralSettingStore()
const { uiColor } = storeToRefs(generalSetting)
const exportFormat = ref('srt')
const showIndex = ref(true)
const copyTime = ref(true)
const contentOption = ref('both')
const copyNum = ref(0)
const baseHH = ref<number>(0)
const baseMM = ref<number>(0)
@@ -185,7 +202,7 @@ const columns = [
title: 'time',
dataIndex: 'time',
key: 'time',
width: 160,
width: 150,
sorter: (a: CaptionItem, b: CaptionItem) => {
if(a.time_s <= b.time_s) return -1
return 1
@@ -255,7 +272,12 @@ function getExportData() {
function copyCaptions() {
let content = ''
for(let i = 0; i < captionData.value.length; i++){
let start = 0
if(copyNum.value > 0) {
start = captionData.value.length - copyNum.value
if(start < 0) start = 0
}
for(let i = start; i < captionData.value.length; i++){
const item = captionData.value[i]
if(showIndex.value) content += `${i+1}\n`
if(copyTime.value) content += `${item.time_s} --> ${item.time_t}\n`.replace(/\./g, ',')
@@ -285,7 +307,7 @@ function clearCaptions() {
display: inline-block;
font-size: 24px;
font-weight: bold;
margin-bottom: 10px;
margin: 10px 0;
}
.base-time {
@@ -313,10 +335,13 @@ function clearCaptions() {
}
.time-start {
color: #1677ff;
display: block;
font-weight: bold;
}
.time-end {
display: block;
font-weight: bold;
color: #ff4d4f;
}
@@ -332,6 +357,5 @@ function clearCaptions() {
.caption-translation {
font-size: 14px;
padding-left: 16px;
border-left: 3px solid #1890ff;
}
</style>

View File

@@ -34,20 +34,18 @@
</div>
<div class="input-item">
<span class="input-label">{{ $t('style.fontSize') }}</span>
<a-input
<a-slider
class="input-area"
type="range"
min="0" max="72"
:min="0" :max="72"
v-model:value="currentFontSize"
/>
<div class="input-item-value">{{ currentFontSize }}px</div>
</div>
<div class="input-item">
<span class="input-label">{{ $t('style.fontWeight') }}</span>
<a-input
<a-slider
class="input-area"
type="range"
min="1" max="9"
:min="1" :max="9"
v-model:value="currentFontWeight"
/>
<div class="input-item-value">{{ currentFontWeight*100 }}</div>
@@ -63,11 +61,10 @@
</div>
<div class="input-item">
<span class="input-label">{{ $t('style.opacity') }}</span>
<a-input
<a-slider
class="input-area"
type="range"
min="0"
max="100"
:min="0"
:max="100"
v-model:value="currentOpacity"
/>
<div class="input-item-value">{{ currentOpacity }}%</div>
@@ -111,20 +108,18 @@
</div>
<div class="input-item">
<span class="input-label">{{ $t('style.fontSize') }}</span>
<a-input
<a-slider
class="input-area"
type="range"
min="0" max="72"
:min="0" :max="72"
v-model:value="currentTransFontSize"
/>
<div class="input-item-value">{{ currentTransFontSize }}px</div>
</div>
<div class="input-item">
<span class="input-label">{{ $t('style.fontWeight') }}</span>
<a-input
<a-slider
class="input-area"
type="range"
min="1" max="9"
:min="1" :max="9"
v-model:value="currentTransFontWeight"
/>
<div class="input-item-value">{{ currentTransFontWeight*100 }}</div>
@@ -136,30 +131,27 @@
<a-card size="small" :title="$t('style.shadow.title')">
<div class="input-item">
<span class="input-label">{{ $t('style.shadow.offsetX') }}</span>
<a-input
<a-slider
class="input-area"
type="range"
min="-10" max="10"
:min="-10" :max="10"
v-model:value="currentOffsetX"
/>
<div class="input-item-value">{{ currentOffsetX }}px</div>
</div>
<div class="input-item">
<span class="input-label">{{ $t('style.shadow.offsetY') }}</span>
<a-input
<a-slider
class="input-area"
type="range"
min="-10" max="10"
:min="-10" :max="10"
v-model:value="currentOffsetY"
/>
<div class="input-item-value">{{ currentOffsetY }}px</div>
</div>
<div class="input-item">
<span class="input-label">{{ $t('style.shadow.blur') }}</span>
<a-input
<a-slider
class="input-area"
type="range"
min="0" max="12"
:min="0" :max="12"
v-model:value="currentBlur"
/>
<div class="input-item-value">{{ currentBlur }}px</div>
@@ -315,7 +307,7 @@ function resetStyle() {
}
watch(changeSignal, (val) => {
if(val == true) {
if(val === true) {
backStyle();
captionStyle.changeSignal = false;
}

View File

@@ -5,9 +5,18 @@
<a @click="applyChange">{{ $t('engine.applyChange') }}</a> |
<a @click="cancelChange">{{ $t('engine.cancelChange') }}</a>
</template>
<div class="input-item">
<span class="input-label">{{ $t('engine.captionEngine') }}</span>
<a-select
class="input-area"
v-model:value="currentEngine"
:options="captionEngine"
></a-select>
</div>
<div class="input-item">
<span class="input-label">{{ $t('engine.sourceLang') }}</span>
<a-select
:disabled="currentEngine === 'vosk'"
class="input-area"
v-model:value="currentSourceLang"
:options="langList"
@@ -16,20 +25,33 @@
<div class="input-item">
<span class="input-label">{{ $t('engine.transLang') }}</span>
<a-select
:disabled="currentEngine === 'vosk'"
class="input-area"
v-model:value="currentTargetLang"
:options="langList.filter((item) => item.value !== 'auto')"
></a-select>
</div>
<div class="input-item">
<span class="input-label">{{ $t('engine.captionEngine') }}</span>
<div class="input-item" v-if="transModel">
<span class="input-label">{{ $t('engine.transModel') }}</span>
<a-select
class="input-area"
v-model:value="currentEngine"
:options="captionEngine"
v-model:value="currentTransModel"
:options="transModel"
></a-select>
</div>
<div class="input-item" v-if="transModel && currentTransModel === 'ollama'">
<a-popover placement="right">
<template #content>
<p class="label-hover-info">{{ $t('engine.ollamaNote') }}</p>
</template>
<span class="input-label info-label"
:style="{color: uiColor}"
>{{ $t('engine.ollama') }}</span>
</a-popover>
<a-input
class="input-area"
v-model:value="currentOllamaName"
></a-input>
</div>
<div class="input-item">
<span class="input-label">{{ $t('engine.audioType') }}</span>
<a-select
@@ -80,11 +102,13 @@
<a-card size="small" :title="$t('engine.showMore')" v-show="showMore" style="margin-top:10px;">
<div class="input-item">
<a-popover>
<a-popover placement="right">
<template #content>
<p class="label-hover-info">{{ $t('engine.apikeyInfo') }}</p>
</template>
<span class="input-label info-label">{{ $t('engine.apikey') }}</span>
<span class="input-label info-label"
:style="{color: uiColor}"
>{{ $t('engine.apikey') }}</span>
</a-popover>
<a-input
class="input-area"
@@ -93,14 +117,17 @@
/>
</div>
<div class="input-item">
<a-popover>
<a-popover placement="right">
<template #content>
<p class="label-hover-info">{{ $t('engine.modelPathInfo') }}</p>
</template>
<span class="input-label info-label">{{ $t('engine.modelPath') }}</span>
<span class="input-label info-label"
:style="{color: uiColor}"
>{{ $t('engine.modelPath') }}</span>
</a-popover>
<span
class="input-folder"
:style="{color: uiColor}"
@click="selectFolderPath"
><span><FolderOpenOutlined /></span></span>
<a-input
@@ -109,18 +136,37 @@
v-model:value="currentModelPath"
/>
</div>
<div class="input-item">
<a-popover placement="right">
<template #content>
<p class="label-hover-info">{{ $t('engine.startTimeoutInfo') }}</p>
</template>
<span
class="input-label info-label"
:style="{color: uiColor, verticalAlign: 'middle'}"
>{{ $t('engine.startTimeout') }}</span>
</a-popover>
<a-input-number
class="input-area"
v-model:value="currentStartTimeoutSeconds"
:min="10"
:max="120"
:step="5"
:addon-after="$t('engine.seconds')"
/>
</div>
</a-card>
</a-card>
<div style="height: 20px;"></div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, h } from 'vue'
import { storeToRefs } from 'pinia'
import { useGeneralSettingStore } from '@renderer/stores/generalSetting'
import { useEngineControlStore } from '@renderer/stores/engineControl'
import { notification } from 'ant-design-vue'
import { FolderOpenOutlined ,InfoCircleOutlined } from '@ant-design/icons-vue';
import { ExclamationCircleOutlined, FolderOpenOutlined ,InfoCircleOutlined } from '@ant-design/icons-vue';
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -129,16 +175,22 @@ const showMore = ref(false)
const engineControl = useEngineControlStore()
const { captionEngine, audioType, changeSignal } = storeToRefs(engineControl)
const generalSetting = useGeneralSettingStore()
const { uiColor } = storeToRefs(generalSetting)
const currentSourceLang = ref('auto')
const currentTargetLang = ref('zh')
const currentEngine = ref<string>('gummy')
const currentAudio = ref<0 | 1>(0)
const currentTranslation = ref<boolean>(false)
const currentTranslation = ref<boolean>(true)
const currentTransModel = ref('ollama')
const currentOllamaName = ref('')
const currentAPI_KEY = ref<string>('')
const currentModelPath = ref<string>('')
const currentCustomized = ref<boolean>(false)
const currentCustomizedApp = ref('')
const currentCustomizedCommand = ref('')
const currentStartTimeoutSeconds = ref<number>(30)
const langList = computed(() => {
for(let item of captionEngine.value){
@@ -149,9 +201,33 @@ const langList = computed(() => {
return []
})
const transModel = computed(() => {
for(let item of captionEngine.value){
if(item.value === currentEngine.value) {
return item.transModel
}
}
return []
})
function applyChange(){
if(
currentTranslation.value && transModel.value &&
currentTransModel.value === 'ollama' && !currentOllamaName.value.trim()
) {
notification.open({
message: t('noti.ollamaNameNull'),
description: t('noti.ollamaNameNullNote'),
duration: null,
icon: () => h(ExclamationCircleOutlined, { style: 'color: #ff4d4f' })
})
return
}
engineControl.sourceLang = currentSourceLang.value
engineControl.targetLang = currentTargetLang.value
engineControl.transModel = currentTransModel.value
engineControl.ollamaName = currentOllamaName.value
engineControl.engine = currentEngine.value
engineControl.audio = currentAudio.value
engineControl.translation = currentTranslation.value
@@ -160,6 +236,7 @@ function applyChange(){
engineControl.customized = currentCustomized.value
engineControl.customizedApp = currentCustomizedApp.value
engineControl.customizedCommand = currentCustomizedCommand.value
engineControl.startTimeoutSeconds = currentStartTimeoutSeconds.value
engineControl.sendControlsChange()
@@ -173,6 +250,8 @@ function applyChange(){
function cancelChange(){
currentSourceLang.value = engineControl.sourceLang
currentTargetLang.value = engineControl.targetLang
currentTransModel.value = engineControl.transModel
currentOllamaName.value = engineControl.ollamaName
currentEngine.value = engineControl.engine
currentAudio.value = engineControl.audio
currentTranslation.value = engineControl.translation
@@ -181,6 +260,7 @@ function cancelChange(){
currentCustomized.value = engineControl.customized
currentCustomizedApp.value = engineControl.customizedApp
currentCustomizedCommand.value = engineControl.customizedCommand
currentStartTimeoutSeconds.value = engineControl.startTimeoutSeconds
}
function selectFolderPath() {
@@ -200,7 +280,10 @@ watch(changeSignal, (val) => {
watch(currentEngine, (val) => {
if(val == 'vosk'){
currentSourceLang.value = 'auto'
currentTargetLang.value = ''
currentTargetLang.value = useGeneralSettingStore().uiLanguage
if(currentTargetLang.value === 'zh') {
currentTargetLang.value = 'zh-cn'
}
}
else if(val == 'gummy'){
currentSourceLang.value = 'auto'
@@ -218,8 +301,8 @@ watch(currentEngine, (val) => {
}
.info-label {
color: #1677ff;
cursor: pointer;
font-style: italic;
}
.input-folder {
@@ -230,20 +313,12 @@ watch(currentEngine, (val) => {
transition: all 0.25s;
}
.input-folder>span {
padding: 0 2px;
border: 2px solid #1677ff;
color: #1677ff;
border-radius: 30%;
}
.input-folder:hover {
transform: scale(1.1);
}
.customize-note {
padding: 10px 10px 0;
color: red;
max-width: min(40vw, 480px);
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="caption-stat">
<a-row>
<a-col :span="6">
<a-col :span="5">
<a-statistic
:title="$t('status.engine')"
:value="customized?$t('status.customized'):engine"
@@ -36,7 +36,7 @@
</a-col>
</a-row>
</template>
<a-col :span="6" @mouseenter="getEngineInfo" style="cursor: pointer;">
<a-col :span="5" @mouseenter="getEngineInfo" style="cursor: pointer;">
<a-statistic
:title="$t('status.status')"
:value="engineEnabled?$t('status.started'):$t('status.stopped')"
@@ -47,10 +47,13 @@
</a-statistic>
</a-col>
</a-popover>
<a-col :span="6">
<a-col :span="5">
<a-statistic :title="$t('status.logNumber')" :value="captionData.length" />
</a-col>
<a-col :span="6">
<a-col :span="5">
<a-statistic :title="$t('status.logNumber2')" :value="softwareLogs.length" />
</a-col>
<a-col :span="4">
<div class="about-tag">{{ $t('status.aboutProj') }}</div>
<GithubOutlined class="proj-info" @click="showAbout = true"/>
</a-col>
@@ -64,11 +67,26 @@
@click="openCaptionWindow"
>{{ $t('status.openCaption') }}</a-button>
<a-button
v-if="!isStarting"
class="control-button"
:loading="pending && !engineEnabled"
:disabled="pending || engineEnabled"
@click="startEngine"
>{{ $t('status.startEngine') }}</a-button>
<a-popconfirm
v-if="isStarting"
:title="$t('status.forceKillConfirm')"
:ok-text="$t('status.confirm')"
:cancel-text="$t('status.cancel')"
@confirm="forceKillEngine"
>
<a-button
danger
class="control-button"
type="primary"
:icon="h(LoadingOutlined)"
>{{ $t('status.forceKillStarting') }}</a-button>
</a-popconfirm>
<a-button
danger class="control-button"
:loading="pending && engineEnabled"
@@ -83,7 +101,7 @@
<p class="about-desc">{{ $t('status.about.desc') }}</p>
<a-divider />
<div class="about-info">
<p><b>{{ $t('status.about.version') }}</b><a-tag color="green">v0.6.0</a-tag></p>
<p><b>{{ $t('status.about.version') }}</b><a-tag color="green">v0.7.0</a-tag></p>
<p>
<b>{{ $t('status.about.author') }}</b>
<a
@@ -125,17 +143,21 @@
<script setup lang="ts">
import { EngineInfo } from '@renderer/types'
import { ref, watch } from 'vue'
import { ref, watch, h } from 'vue'
import { storeToRefs } from 'pinia'
import { useCaptionLogStore } from '@renderer/stores/captionLog'
import { useSoftwareLogStore } from '@renderer/stores/softwareLog'
import { useEngineControlStore } from '@renderer/stores/engineControl'
import { GithubOutlined, InfoCircleOutlined } from '@ant-design/icons-vue';
import { GithubOutlined, InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons-vue'
const showAbout = ref(false)
const pending = ref(false)
const isStarting = ref(false)
const captionLog = useCaptionLogStore()
const { captionData } = storeToRefs(captionLog)
const softwareLog = useSoftwareLogStore()
const { softwareLogs } = storeToRefs(softwareLog)
const engineControl = useEngineControlStore()
const { engineEnabled, engine, customized, errorSignal } = storeToRefs(engineControl)
@@ -152,8 +174,11 @@ function openCaptionWindow() {
function startEngine() {
pending.value = true
isStarting.value = true
if(engineControl.engine === 'vosk' && engineControl.modelPath.trim() === '') {
engineControl.emptyModelPathErr()
pending.value = false
isStarting.value = false
return
}
window.electron.ipcRenderer.send('control.engine.start')
@@ -164,6 +189,12 @@ function stopEngine() {
window.electron.ipcRenderer.send('control.engine.stop')
}
function forceKillEngine() {
pending.value = true
isStarting.value = false
window.electron.ipcRenderer.send('control.engine.forceKill')
}
function getEngineInfo() {
window.electron.ipcRenderer.invoke('control.engine.info').then((data: EngineInfo) => {
pid.value = data.pid
@@ -175,12 +206,16 @@ function getEngineInfo() {
})
}
watch(engineEnabled, () => {
watch(engineEnabled, (enabled) => {
pending.value = false
if (enabled) {
isStarting.value = false
}
})
watch(errorSignal, () => {
pending.value = false
isStarting.value = false
errorSignal.value = false
})
</script>

View File

@@ -28,11 +28,24 @@
</a-radio-group>
</div>
<div class="input-item">
<span class="input-label">{{ $t('general.color') }}</span>
<a-radio-group v-model:value="uiColor">
<template v-for="color in colorList" :key="color">
<a-radio-button :value="color"
:style="{backgroundColor: color}"
>
<CheckOutlined style="color: white;" v-if="color === uiColor" />
<span v-else>&nbsp;</span>
</a-radio-button>
</template>
</a-radio-group>
</div>
<div class="input-item">
<span class="input-label">{{ $t('general.barWidth') }}</span>
<a-input
type="range" class="span-input"
min="6" max="12" v-model:value="leftBarWidth"
<a-slider class="span-input"
:min="6" :max="12" v-model:value="leftBarWidth"
/>
<div class="input-item-value">{{ (leftBarWidth * 100 / 24).toFixed(0) }}%</div>
</div>
@@ -41,19 +54,55 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useGeneralSettingStore } from '@renderer/stores/generalSetting'
import { InfoCircleOutlined } from '@ant-design/icons-vue';
import { InfoCircleOutlined, CheckOutlined } from '@ant-design/icons-vue'
const generalSettingStore = useGeneralSettingStore()
const { uiLanguage, uiTheme, leftBarWidth } = storeToRefs(generalSettingStore)
const { uiLanguage, realTheme, uiTheme, uiColor, leftBarWidth } = storeToRefs(generalSettingStore)
const colorListLight = [
'#1677ff',
'#00b96b',
'#fa8c16',
'#9254de',
'#eb2f96',
'#000000'
]
const colorListDark = [
'#1677ff',
'#00b96b',
'#fa8c16',
'#9254de',
'#eb2f96',
'#b9d7ea'
]
const colorList = ref(colorListLight)
watch(realTheme, (val) => {
if(val === 'dark') {
colorList.value = colorListDark
} else {
colorList.value = colorListLight
}
console.log(val)
})
watch(uiTheme, (val) => {
console.log(val)
})
</script>
<style scoped>
@import url(../assets/input.css);
.span-input {
display: inline-block;
width: 100px;
margin: 0;
}
.general-note {

View File

@@ -0,0 +1,114 @@
<template>
<div>
<div class="log-title">
<span style="margin-right: 30px;">{{ $t('log.title2') }}</span>
</div>
<a-button
danger
@click="softwareLog.clear()"
>{{ $t('log.clear') }}</a-button>
</div>
<a-table
:columns="columns"
:data-source="softwareLogs"
v-model:pagination="pagination"
style="margin-top: 10px;"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'index'">
{{ record.index }}
</template>
<template v-if="column.key === 'type'">
<code :class="record.type">{{ record.type }}</code>
</template>
<template v-if="column.key === 'time'">
<code>{{ record.time }}</code>
</template>
<template v-if="column.key === 'content'">
<code>{{ record.text }}</code>
</template>
</template>
</a-table>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useSoftwareLogStore } from '@renderer/stores/softwareLog'
import { type SoftwareLogItem } from '../types'
const softwareLog = useSoftwareLogStore()
const { softwareLogs } = storeToRefs(softwareLog)
const pagination = ref({
current: 1,
pageSize: 20,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
onChange: (page: number, pageSize: number) => {
pagination.value.current = page
pagination.value.pageSize = pageSize
},
onShowSizeChange: (current: number, size: number) => {
pagination.value.current = current
pagination.value.pageSize = size
}
})
const columns = [
{
title: 'index',
dataIndex: 'index',
key: 'index',
width: 80,
sorter: (a: SoftwareLogItem, b: SoftwareLogItem) => {
if(a.index <= b.index) return -1
return 1
},
sortDirections: ['descend'],
defaultSortOrder: 'descend',
},
{
title: 'type',
dataIndex: 'type',
key: 'type',
width: 80,
sorter: (a: SoftwareLogItem, b: SoftwareLogItem) => {
if(a.type <= b.type) return -1
return 1
},
},
{
title: 'time',
dataIndex: 'time',
key: 'time',
width: 135,
sortDirections: ['descend'],
},
{
title: 'content',
dataIndex: 'content',
key: 'content',
},
]
</script>
<style scoped>
.log-title {
display: inline-block;
font-size: 24px;
font-weight: bold;
margin: 10px 0;
}
.WARN {
color: #ff7c05;
font-weight: bold;
}
.ERROR {
color: #ff0000;
font-weight: bold;
}
</style>

View File

@@ -21,6 +21,19 @@ export const engines = {
label: '本地 - Vosk',
languages: [
{ value: 'auto', label: '需要自行配置模型' },
{ value: 'en', label: '英语' },
{ value: 'zh-cn', label: '中文' },
{ value: 'ja', label: '日语' },
{ value: 'ko', label: '韩语' },
{ value: 'de', label: '德语' },
{ value: 'fr', label: '法语' },
{ value: 'ru', label: '俄语' },
{ value: 'es', label: '西班牙语' },
{ value: 'it', label: '意大利语' },
],
transModel: [
{ value: 'ollama', label: 'Ollama 本地模型' },
{ value: 'google', label: 'Google API 调用' },
]
}
],
@@ -46,6 +59,19 @@ export const engines = {
label: 'Local - Vosk',
languages: [
{ value: 'auto', label: 'Model needs to be configured manually' },
{ value: 'en', label: 'English' },
{ value: 'zh-cn', label: 'Chinese' },
{ value: 'ja', label: 'Japanese' },
{ value: 'ko', label: 'Korean' },
{ value: 'de', label: 'German' },
{ value: 'fr', label: 'French' },
{ value: 'ru', label: 'Russian' },
{ value: 'es', label: 'Spanish' },
{ value: 'it', label: 'Italian' },
],
transModel: [
{ value: 'ollama', label: 'Ollama Local Model' },
{ value: 'google', label: 'Google API Call' },
]
}
],
@@ -71,8 +97,20 @@ export const engines = {
label: 'ローカル - Vosk',
languages: [
{ value: 'auto', label: 'モデルを手動で設定する必要があります' },
{ value: 'en', label: '英語' },
{ value: 'zh-cn', label: '中国語' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '韓国語' },
{ value: 'de', label: 'ドイツ語' },
{ value: 'fr', label: 'フランス語' },
{ value: 'ru', label: 'ロシア語' },
{ value: 'es', label: 'スペイン語' },
{ value: 'it', label: 'イタリア語' },
],
transModel: [
{ value: 'ollama', label: 'Ollama ローカルモデル' },
{ value: 'google', label: 'Google API 呼び出し' },
]
}
]
}

View File

@@ -0,0 +1,41 @@
import { h } from 'vue';
import { OrderedListOutlined, FileTextOutlined } from '@ant-design/icons-vue'
export const logMenu = {
zh: [
{
key: 'captionLog',
icon: () => h(OrderedListOutlined),
label: '字幕记录',
},
{
key: 'projLog',
icon: () => h(FileTextOutlined),
label: '日志记录',
},
],
en: [
{
key: 'captionLog',
icon: () => h(OrderedListOutlined),
label: 'Caption Log',
},
{
key: 'projLog',
icon: () => h(FileTextOutlined),
label: 'Software Log',
},
],
ja: [
{
key: 'captionLog',
icon: () => h(OrderedListOutlined),
label: '字幕記録',
},
{
key: 'projLog',
icon: () => h(FileTextOutlined),
label: 'ログ記録',
},
]
}

View File

@@ -1,10 +1,29 @@
import { theme } from 'ant-design-vue';
export const antDesignTheme = {
light: {
token: {}
},
dark: {
algorithm: theme.darkAlgorithm,
}
let isLight = true
let themeColor = '#1677ff'
export function setThemeColor(color: string) {
themeColor = color
}
export function getTheme(curIsLight?: boolean) {
const lightTheme = {
token: {
colorPrimary: themeColor,
colorInfo: themeColor
}
}
const darkTheme = {
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: themeColor,
colorInfo: themeColor
}
}
if(curIsLight !== undefined){
isLight = curIsLight
}
return isLight ? lightTheme : darkTheme
}

View File

@@ -18,3 +18,4 @@ export * from './config/engine'
export * from './config/audio'
export * from './config/theme'
export * from './config/linebreak'
export * from './config/logMenu'

View File

@@ -27,7 +27,10 @@ export default {
"engineChange": "Cpation Engine Configuration Changed",
"changeInfo": "If the caption engine is already running, you need to restart it for the changes to take effect.",
"styleChange": "Caption Style Changed",
"styleInfo": "Caption style changes have been saved and applied."
"styleInfo": "Caption style changes have been saved and applied.",
"engineStartTimeout": "Caption engine startup timeout, automatically force stopped",
"ollamaNameNull": "'Ollama' Field is Empty",
"ollamaNameNullNote": "When selecting Ollama model as the translation model, the 'Ollama' field cannot be empty and must be filled with the name of a locally configured Ollama model."
},
general: {
"title": "General Settings",
@@ -37,7 +40,8 @@ export default {
"theme": "Theme",
"light": "light",
"dark": "dark",
"system": "system"
"system": "system",
"color": "Color"
},
engine: {
"title": "Caption Engine Settings",
@@ -45,6 +49,9 @@ export default {
"cancelChange": "Cancel Changes",
"sourceLang": "Source",
"transLang": "Translation",
"transModel": "Model",
"ollama": "Ollama",
"ollamaNote": "To use for translation, the name of the local Ollama model that will call the service on the default port. It is recommended to use a non-inference model with less than 1B parameters.",
"captionEngine": "Engine",
"audioType": "Audio Type",
"systemOutput": "System Audio Output (Speaker)",
@@ -53,8 +60,11 @@ export default {
"showMore": "More Settings",
"apikey": "API KEY",
"modelPath": "Model Path",
"startTimeout": "Timeout",
"seconds": "seconds",
"apikeyInfo": "API KEY required for the Gummy subtitle engine, which needs to be obtained from the Alibaba Cloud Bailing platform. For more details, see the project user manual.",
"modelPathInfo": "The folder path of the model required by the Vosk subtitle engine. You need to download the required model to your local machine in advance. For more details, see the project user manual.",
"startTimeoutInfo": "Caption engine startup timeout duration. Engine will be forcefully stopped if startup exceeds this time. Recommended range: 10-120 seconds.",
"customEngine": "Custom Engine",
custom: {
"title": "Custom Caption Engine",
@@ -105,11 +115,17 @@ export default {
"started": "Started",
"stopped": "Not Started",
"logNumber": "Caption Count",
"logNumber2": "Log Count",
"aboutProj": "About Project",
"openCaption": "Open Caption Window",
"startEngine": "Start Caption Engine",
"restartEngine": "Restart Caption Engine",
"stopEngine": "Stop Caption Engine",
"forceKill": "Force Stop",
"forceKillStarting": "Starting Engine... (Force Stop)",
"forceKillConfirm": "Are you sure you want to force stop the caption engine? This will terminate the process immediately.",
"confirm": "Confirm",
"cancel": "Cancel",
about: {
"title": "About This Project",
"proj": "Auto Caption Project",
@@ -119,7 +135,7 @@ export default {
"projLink": "Project Link",
"manual": "User Manual",
"engineDoc": "Caption Engine Manual",
"date": "July 30, 2025"
"date": "August 20, 2025"
}
},
log: {
@@ -142,7 +158,10 @@ export default {
"both": "Both",
"source": "Original",
"translation": "Translation",
"copyNum": "Copy Count",
"all": "All",
"copySuccess": "Subtitle copied to clipboard",
"clear": "Clear Log"
"clear": "Clear Log",
"title2": "Software Log"
}
}

View File

@@ -27,7 +27,10 @@ export default {
"engineChange": "字幕エンジンの設定が変更されました",
"changeInfo": "字幕エンジンがすでに起動している場合、変更を有効にするには再起動が必要です。",
"styleChange": "字幕のスタイルが変更されました",
"styleInfo": "字幕のスタイル変更が保存され、適用されました"
"styleInfo": "字幕のスタイル変更が保存され、適用されました",
"engineStartTimeout": "字幕エンジンの起動がタイムアウトしました。自動的に強制停止しました",
"ollamaNameNull": "Ollama フィールドが空です",
"ollamaNameNullNote": "Ollama モデルを翻訳モデルとして選択する場合、Ollama フィールドは空にできません。ローカルで設定された Ollama モデルの名前を入力してください。"
},
general: {
"title": "一般設定",
@@ -37,7 +40,8 @@ export default {
"theme": "テーマ",
"light": "明るい",
"dark": "暗い",
"system": "システム"
"system": "システム",
"color": "カラー"
},
engine: {
"title": "字幕エンジン設定",
@@ -45,6 +49,9 @@ export default {
"cancelChange": "変更をキャンセル",
"sourceLang": "ソース言語",
"transLang": "翻訳言語",
"transModel": "翻訳モデル",
"ollama": "Ollama",
"ollamaNote": "翻訳に使用する、デフォルトポートでサービスを呼び出すローカルOllamaモデルの名前。1B 未満のパラメータを持つ非推論モデルの使用を推奨します。",
"captionEngine": "エンジン",
"audioType": "オーディオ",
"systemOutput": "システムオーディオ出力(スピーカー)",
@@ -53,8 +60,11 @@ export default {
"showMore": "詳細設定",
"apikey": "API KEY",
"modelPath": "モデルパス",
"startTimeout": "時間制限",
"seconds": "秒",
"apikeyInfo": "Gummy 字幕エンジンに必要な API KEY は、アリババクラウド百煉プラットフォームから取得する必要があります。詳細情報はプロジェクトのユーザーマニュアルをご覧ください。",
"modelPathInfo": "Vosk 字幕エンジンに必要なモデルのフォルダパスです。必要なモデルを事前にローカルマシンにダウンロードする必要があります。詳細情報はプロジェクトのユーザーマニュアルをご覧ください。",
"startTimeoutInfo": "字幕エンジンの起動タイムアウト時間です。この時間を超えると自動的に強制停止されます。10-120秒の範囲で設定することを推奨します。",
"customEngine": "カスタムエンジン",
custom: {
"title": "カスタムキャプションエンジン",
@@ -105,11 +115,17 @@ export default {
"started": "開始済み",
"stopped": "未開始",
"logNumber": "字幕数",
"logNumber2": "ログ数",
"aboutProj": "プロジェクト情報",
"openCaption": "字幕ウィンドウを開く",
"startEngine": "字幕エンジンを開始",
"restartEngine": "字幕エンジンを再起動",
"stopEngine": "字幕エンジンを停止",
"forceKill": "強制停止",
"forceKillStarting": "エンジン起動中... (強制停止)",
"forceKillConfirm": "字幕エンジンを強制停止しますか?プロセスが直ちに終了されます。",
"confirm": "確認",
"cancel": "キャンセル",
about: {
"title": "このプロジェクトについて",
"proj": "Auto Caption プロジェクト",
@@ -119,11 +135,11 @@ export default {
"projLink": "プロジェクトリンク",
"manual": "ユーザーマニュアル",
"engineDoc": "字幕エンジンマニュアル",
"date": "2025 年 730 日"
"date": "2025 年 820 日"
}
},
log: {
"title": "字幕ログ",
"title": "字幕記録",
"changeTime": "時間を変更",
"baseTime": "最初の字幕開始時間",
"hour": "時",
@@ -131,7 +147,7 @@ export default {
"sec": "秒",
"ms": "ミリ秒",
"export": "エクスポート",
"copy": "ログをコピー",
"copy": "記録をコピー",
"exportOptions": "エクスポートオプション",
"exportFormat": "形式",
"exportContent": "内容",
@@ -142,7 +158,10 @@ export default {
"both": "すべて",
"source": "原文",
"translation": "翻訳",
"copyNum": "コピー数",
"all": "すべて",
"copySuccess": "字幕がクリップボードにコピーされました",
"clear": "ログをクリア"
"clear": "記録をクリア",
"title2": "ログ記録"
}
}

View File

@@ -27,7 +27,10 @@ export default {
"engineChange": "字幕引擎配置已更改",
"changeInfo": "如果字幕引擎已经启动,需要重启字幕引擎修改才会生效",
"styleChange": "字幕样式已修改",
"styleInfo": "字幕样式修改已经保存并生效"
"styleInfo": "字幕样式修改已经保存并生效",
"engineStartTimeout": "字幕引擎启动超时,已自动强制停止",
"ollamaNameNull": "Ollama 字段为空",
"ollamaNameNullNote": "选择 Ollama 模型作为翻译模型时Ollama 字段不能为空,需要填写本地已经配置好的 Ollama 模型的名称。"
},
general: {
"title": "通用设置",
@@ -37,7 +40,8 @@ export default {
"theme": "主题",
"light": "浅色",
"dark": "深色",
"system": "系统"
"system": "系统",
"color": "颜色"
},
engine: {
"title": "字幕引擎设置",
@@ -45,6 +49,9 @@ export default {
"cancelChange": "取消更改",
"sourceLang": "源语言",
"transLang": "翻译语言",
"transModel": "翻译模型",
"ollama": "Ollama",
"ollamaNote": "要使用的进行翻译的本地 Ollama 模型的名称,将调用默认端口的服务,建议使用参数量小于 1B 的非推理模型。",
"captionEngine": "字幕引擎",
"audioType": "音频类型",
"systemOutput": "系统音频输出(扬声器)",
@@ -53,8 +60,11 @@ export default {
"showMore": "更多设置",
"apikey": "API KEY",
"modelPath": "模型路径",
"startTimeout": "启动超时",
"seconds": "秒",
"apikeyInfo": "Gummy 字幕引擎需要的 API KEY需要在阿里云百炼平台获取。详细信息见项目用户手册。",
"modelPathInfo": "Vosk 字幕引擎需要的模型的文件夹路径,需要提前下载需要的模型到本地。信息详情见项目用户手册。",
"startTimeoutInfo": "字幕引擎启动超时时间,超过此时间将自动强制停止。建议设置为 10-120 秒之间。",
"customEngine": "自定义引擎",
custom: {
"title": "自定义字幕引擎",
@@ -105,11 +115,17 @@ export default {
"started": "已启动",
"stopped": "未启动",
"logNumber": "字幕数量",
"logNumber2": "日志数量",
"aboutProj": "项目关于",
"openCaption": "打开字幕窗口",
"startEngine": "启动字幕引擎",
"restartEngine": "重启字幕引擎",
"stopEngine": "关闭字幕引擎",
"forceKill": "强行停止",
"forceKillStarting": "正在启动引擎... (强行停止)",
"forceKillConfirm": "确定要强行停止字幕引擎吗?这将立即终止进程。",
"confirm": "确定",
"cancel": "取消",
about: {
"title": "关于本项目",
"proj": "Auto Caption 项目",
@@ -119,7 +135,7 @@ export default {
"projLink": "项目链接",
"manual": "用户手册",
"engineDoc": "字幕引擎手册",
"date": "2025 年 730 日"
"date": "2025 年 820 日"
}
},
log: {
@@ -142,7 +158,10 @@ export default {
"both": "全部",
"source": "原文",
"translation": "翻译",
"copyNum": "复制数量",
"all": "全部",
"copySuccess": "字幕已复制到剪贴板",
"clear": "清空记录"
"clear": "清空记录",
"title2": "日志记录"
}
}

View File

@@ -15,7 +15,12 @@ export const useCaptionLogStore = defineStore('captionLog', () => {
})
window.electron.ipcRenderer.on('both.captionLog.upd', (_, log) => {
captionData.value.splice(captionData.value.length - 1, 1, log)
for(let i = captionData.value.length - 1; i >= 0; i--) {
if(captionData.value[i].time_s === log.time_s){
captionData.value.splice(i, 1, log)
break
}
}
})
window.electron.ipcRenderer.on('both.captionLog.set', (_, logs) => {

View File

@@ -19,6 +19,8 @@ export const useEngineControlStore = defineStore('engineControl', () => {
const engineEnabled = ref(false)
const sourceLang = ref<string>('en')
const targetLang = ref<string>('zh')
const transModel = ref<string>('ollama')
const ollamaName = ref<string>('')
const engine = ref<string>('gummy')
const audio = ref<0 | 1>(0)
const translation = ref<boolean>(true)
@@ -27,6 +29,7 @@ export const useEngineControlStore = defineStore('engineControl', () => {
const customized = ref<boolean>(false)
const customizedApp = ref<string>('')
const customizedCommand = ref<string>('')
const startTimeoutSeconds = ref<number>(30)
const changeSignal = ref<boolean>(false)
const errorSignal = ref<boolean>(false)
@@ -36,6 +39,8 @@ export const useEngineControlStore = defineStore('engineControl', () => {
engineEnabled: engineEnabled.value,
sourceLang: sourceLang.value,
targetLang: targetLang.value,
transModel: transModel.value,
ollamaName: ollamaName.value,
engine: engine.value,
audio: audio.value,
translation: translation.value,
@@ -43,7 +48,8 @@ export const useEngineControlStore = defineStore('engineControl', () => {
modelPath: modelPath.value,
customized: customized.value,
customizedApp: customizedApp.value,
customizedCommand: customizedCommand.value
customizedCommand: customizedCommand.value,
startTimeoutSeconds: startTimeoutSeconds.value
}
window.electron.ipcRenderer.send('control.controls.change', controls)
}
@@ -66,6 +72,8 @@ export const useEngineControlStore = defineStore('engineControl', () => {
}
sourceLang.value = controls.sourceLang
targetLang.value = controls.targetLang
transModel.value = controls.transModel
ollamaName.value = controls.ollamaName
engine.value = controls.engine
audio.value = controls.audio
engineEnabled.value = controls.engineEnabled
@@ -75,6 +83,7 @@ export const useEngineControlStore = defineStore('engineControl', () => {
customized.value = controls.customized
customizedApp.value = controls.customizedApp
customizedCommand.value = controls.customizedCommand
startTimeoutSeconds.value = controls.startTimeoutSeconds
changeSignal.value = true
}
@@ -129,6 +138,8 @@ export const useEngineControlStore = defineStore('engineControl', () => {
engineEnabled, // 字幕引擎是否启用
sourceLang, // 源语言
targetLang, // 目标语言
transModel, // 翻译模型
ollamaName, // Ollama 模型
engine, // 字幕引擎
audio, // 选择音频
translation, // 是否启用翻译
@@ -137,6 +148,7 @@ export const useEngineControlStore = defineStore('engineControl', () => {
customized, // 是否使用自定义字幕引擎
customizedApp, // 自定义字幕引擎的应用程序
customizedCommand, // 自定义字幕引擎的命令
startTimeoutSeconds, // 启动超时时间(秒)
setControls, // 设置引擎配置
sendControlsChange, // 发送最新控制消息到后端
emptyModelPathErr, // 模型路径为空时显示警告

View File

@@ -3,20 +3,35 @@ import { defineStore } from 'pinia'
import { i18n } from '../i18n'
import type { UILanguage, UITheme } from '../types'
import { engines, audioTypes, antDesignTheme, breakOptions } from '../i18n'
import { engines, audioTypes, breakOptions, setThemeColor, getTheme } from '../i18n'
import { useEngineControlStore } from './engineControl'
import { useCaptionStyleStore } from './captionStyle'
type RealTheme = 'light' | 'dark'
export const useGeneralSettingStore = defineStore('generalSetting', () => {
const uiLanguage = ref<UILanguage>('zh')
const realTheme = ref<RealTheme>('light')
const uiTheme = ref<UITheme>('system')
const uiColor = ref<string>('#1677ff')
const leftBarWidth = ref<number>(8)
const antdTheme = ref<Object>(antDesignTheme['light'])
const antdTheme = ref<Object>(getTheme())
function handleThemeChange(newTheme: RealTheme) {
realTheme.value = newTheme
if(newTheme === 'dark' && uiColor.value === '#000000') {
uiColor.value = '#b9d7ea'
}
if(newTheme === 'light' && uiColor.value === '#b9d7ea') {
uiColor.value = '#000000'
}
}
window.electron.ipcRenderer.invoke('control.nativeTheme.get').then((theme) => {
if(theme === 'light') setLightTheme()
else if(theme === 'dark') setDarkTheme()
handleThemeChange(theme)
})
watch(uiLanguage, (newValue) => {
@@ -30,30 +45,48 @@ export const useGeneralSettingStore = defineStore('generalSetting', () => {
watch(uiTheme, (newValue) => {
window.electron.ipcRenderer.send('control.uiTheme.change', newValue)
if(newValue === 'system'){
window.electron.ipcRenderer.invoke('control.nativeTheme.get').then((theme) => {
window.electron.ipcRenderer.invoke('control.nativeTheme.get').then((theme: RealTheme) => {
if(theme === 'light') setLightTheme()
else if(theme === 'dark') setDarkTheme()
handleThemeChange(theme)
})
}
else if(newValue === 'light') setLightTheme()
else if(newValue === 'dark') setDarkTheme()
else if(newValue === 'light'){
setLightTheme()
handleThemeChange('light')
}
else if(newValue === 'dark') {
setDarkTheme()
handleThemeChange('dark')
}
})
watch(uiColor, (newValue) => {
setThemeColor(newValue)
antdTheme.value = getTheme()
window.electron.ipcRenderer.send('control.uiColor.change', newValue)
})
watch(leftBarWidth, (newValue) => {
window.electron.ipcRenderer.send('control.leftBarWidth.change', newValue)
})
watch(realTheme, (newValue) => {
console.log('realTheme', newValue)
})
window.electron.ipcRenderer.on('control.uiLanguage.set', (_, args: UILanguage) => {
uiLanguage.value = args
})
window.electron.ipcRenderer.on('control.nativeTheme.change', (_, args) => {
window.electron.ipcRenderer.on('control.nativeTheme.change', (_, args: RealTheme) => {
if(args === 'light') setLightTheme()
else if(args === 'dark') setDarkTheme()
handleThemeChange(args)
})
function setLightTheme(){
antdTheme.value = antDesignTheme.light
antdTheme.value = getTheme(true)
const root = document.documentElement
root.style.setProperty('--control-background', '#fff')
root.style.setProperty('--tag-color', 'rgba(0, 0, 0, 0.45)')
@@ -61,7 +94,7 @@ export const useGeneralSettingStore = defineStore('generalSetting', () => {
}
function setDarkTheme(){
antdTheme.value = antDesignTheme.dark
antdTheme.value = getTheme(false)
const root = document.documentElement
root.style.setProperty('--control-background', '#000')
root.style.setProperty('--tag-color', 'rgba(255, 255, 255, 0.45)')
@@ -70,7 +103,9 @@ export const useGeneralSettingStore = defineStore('generalSetting', () => {
return {
uiLanguage,
realTheme,
uiTheme,
uiColor,
leftBarWidth,
antdTheme
}

View File

@@ -0,0 +1,21 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { type SoftwareLogItem } from '../types'
export const useSoftwareLogStore = defineStore('softwareLog', () => {
const softwareLogs = ref<SoftwareLogItem[]>([])
function clear() {
softwareLogs.value = []
}
window.electron.ipcRenderer.on('control.softwareLog.add', (_, log) => {
softwareLogs.value.push(log)
console.log(log)
})
return {
softwareLogs,
clear
}
})

View File

@@ -6,6 +6,8 @@ export interface Controls {
engineEnabled: boolean,
sourceLang: string,
targetLang: string,
transModel: string,
ollamaName: string,
engine: string,
audio: 0 | 1,
translation: boolean,
@@ -13,7 +15,8 @@ export interface Controls {
modelPath: string,
customized: boolean,
customizedApp: string,
customizedCommand: string
customizedCommand: string,
startTimeoutSeconds: number
}
export interface Styles {
@@ -45,14 +48,23 @@ export interface CaptionItem {
translation: string
}
export interface SoftwareLogItem {
type: "INFO" | "WARN" | "ERROR",
index: number,
time: string,
text: string
}
export interface FullConfig {
platform: string,
uiLanguage: UILanguage,
uiTheme: UITheme,
uiColor: string,
leftBarWidth: number,
styles: Styles,
controls: Controls,
captionLog: CaptionItem[]
captionLog: CaptionItem[],
softwareLog: SoftwareLogItem[]
}
export interface EngineInfo {

View File

@@ -11,7 +11,13 @@
<a-col :span="24 - leftBarWidth">
<div class="caption-data">
<EngineStatus />
<CaptionLog />
<div class="log-container">
<a-menu v-model:selectedKeys="current" mode="horizontal" :items="items" />
<div style="padding: 16px;">
<CaptionLog v-if="current[0] === 'captionLog'" />
<SoftwareLog v-else />
</div>
</div>
</div>
</a-col>
</a-row>
@@ -24,11 +30,22 @@ import CaptionStyle from '../components/CaptionStyle.vue'
import EngineControl from '../components/EngineControl.vue'
import EngineStatus from '@renderer/components/EngineStatus.vue'
import CaptionLog from '../components/CaptionLog.vue'
import SoftwareLog from '@renderer/components/SoftwareLog.vue'
import { storeToRefs } from 'pinia'
import { useGeneralSettingStore } from '@renderer/stores/generalSetting'
import { ref, watch } from 'vue'
import { MenuProps } from 'ant-design-vue'
import { logMenu } from '@renderer/i18n'
const generalSettingStore = useGeneralSettingStore()
const { leftBarWidth, antdTheme } = storeToRefs(generalSettingStore)
const { leftBarWidth, antdTheme, uiLanguage } = storeToRefs(generalSettingStore)
const current = ref<string[]>(['captionLog'])
const items = ref<MenuProps['items']>(logMenu[uiLanguage.value])
watch(uiLanguage, (val) => {
items.value = logMenu[val]
})
</script>
<style scoped>
@@ -53,4 +70,10 @@ const { leftBarWidth, antdTheme } = storeToRefs(generalSettingStore)
.caption-data::-webkit-scrollbar {
display: none;
}
.log-container {
padding: 20px 10px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
</style>