diff --git a/.github/scripts/release_version.py b/.github/scripts/release_version.py new file mode 100644 index 0000000..ba17853 --- /dev/null +++ b/.github/scripts/release_version.py @@ -0,0 +1,48 @@ +"""Compare package versions across the complete push, including squash merges.""" +import json +import os +import re +import subprocess +from pathlib import Path + + +def release_outputs(previous, current): + match = re.fullmatch( + r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' + r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?' + r'(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?', current) + if not match or (match[4] and any( + part.isdigit() and len(part) > 1 and part.startswith('0') + for part in match[4].split('.'))): + raise ValueError(f'Invalid package.json SemVer: {current!r}') + return { + 'changed': str(previous is not None and previous != current).lower(), + 'version': current, + 'tag': f'v{current}', + 'prerelease': str(match[4] is not None).lower(), + } + + +def main(): + event = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text(encoding='utf-8')) + before = event['before'] + current = json.loads(Path('package.json').read_text(encoding='utf-8'))['version'] + previous = None + if before != '0' * 40: + # checkout fetch-depth: 0 normally supplies this object; a force push may not. + available = subprocess.run(['git', 'cat-file', '-e', f'{before}^{{commit}}'], + capture_output=True) + if available.returncode: + subprocess.run(['git', 'fetch', 'origin', before], check=True) + content = subprocess.check_output(['git', 'show', f'{before}:package.json'], + encoding='utf-8') + previous = json.loads(content)['version'] + outputs = release_outputs(previous, current) + with Path(os.environ['GITHUB_OUTPUT']).open('a', encoding='utf-8') as output: + for key, value in outputs.items(): + output.write(f'{key}={value}\n') + print(f'Version: {previous!r} -> {current!r}; release: {outputs["changed"]}') + + +if __name__ == '__main__': + main() diff --git a/.github/scripts/test_publish_release.mjs b/.github/scripts/test_publish_release.mjs new file mode 100644 index 0000000..e22238d --- /dev/null +++ b/.github/scripts/test_publish_release.mjs @@ -0,0 +1,145 @@ +// Native Node.js tests cannot use TypeScript return annotations. +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' +import path from 'node:path' +const workflow = fs.readFileSync('.github/workflows/ci.yml', 'utf8') +const source = workflow.split(/ {10}script: \|\r?\n/)[1] +assert.ok(source, 'Publish script found') +const AsyncFunction = Object.getPrototypeOf(async () => null).constructor +const publish = new AsyncFunction('require', 'github', 'context', 'core', 'process', source) + +async function simulate(options = {}) { + const calls = [] + const missing = () => { + throw Object.assign(new Error('Not found'), { status: 404 }) + } + const record = (name, data) => { + calls.push([name, data]) + return { data } + } + const github = { + paginate: async () => options.assets || [], + rest: { + git: { + getRef: async () => { + if (options.permissionError) { + throw Object.assign(new Error('Forbidden'), { status: 403 }) + } + return options.ref ? { data: options.ref } : missing() + }, + getTag: async () => ({ data: { object: { type: 'commit', sha: 'trigger-sha' } } }) + }, + repos: { + getReleaseByTag: async () => (options.release ? { data: options.release } : missing()), + generateReleaseNotes: async () => ({ data: { body: 'generated notes' } }), + createRelease: async (data) => { + record('create', data) + return { data: { id: 42 } } + }, + deleteReleaseAsset: async (data) => record('delete', data), + uploadReleaseAsset: async (data) => { + record('upload', data) + if (options.failUpload) throw new Error('upload failed') + }, + updateRelease: async (data) => record('publish', data) + } + } + } + const fakeRequire = (name) => + name === 'node:fs' + ? { + readdirSync: () => + options.files || ['auto-caption-v1.3.0.exe', 'auto-caption-v1.3.0.tar.gz'], + readFileSync: () => Buffer.from('installer') + } + : path + let error + try { + await publish( + fakeRequire, + github, + { repo: { owner: 'owner', repo: 'repo' }, sha: 'trigger-sha' }, + { info: () => {} }, + { env: { RELEASE_TAG: 'v1.3.0', RELEASE_PRERELEASE: String(!!options.prerelease) } } + ) + } catch (caught) { + error = caught + } + return { calls, error } +} + +test('Creates draft at triggering SHA and publishes Windows/Linux without a macOS installer', async () => { + const { calls, error } = await simulate() + assert.equal(error, undefined) + assert.deepEqual( + calls.map((call) => call[0]), + ['create', 'upload', 'upload', 'publish'] + ) + assert.deepEqual( + calls.filter((call) => call[0] === 'upload').map((call) => call[1].name), + ['auto-caption-v1.3.0.exe', 'auto-caption-v1.3.0.tar.gz'] + ) + assert.equal(calls[0][1].target_commitish, 'trigger-sha') + assert.equal(calls[0][1].draft, true) + assert.equal(calls.at(-1)[1].draft, false) +}) +test('Upload failure leaves draft unpublished', async () => { + const { calls, error } = await simulate({ failUpload: true }) + assert.match(error.message, /upload failed/) + assert.ok(!calls.some((call) => call[0] === 'publish')) +}) +test('Draft retry replaces partial asset and publishes', async () => { + const { calls, error } = await simulate({ + release: { id: 42, draft: true, target_commitish: 'trigger-sha' }, + assets: [{ id: 9, name: 'auto-caption-v1.3.0.exe' }] + }) + assert.equal(error, undefined) + assert.ok(calls.some((call) => call[0] === 'delete' && call[1].asset_id === 9)) + assert.ok(!calls.some((call) => call[0] === 'create')) + assert.equal(calls.at(-1)[0], 'publish') +}) +test('Published release at same annotated tag is skipped', async () => { + const { calls, error } = await simulate({ + release: { id: 42, draft: false }, + ref: { object: { type: 'tag', sha: 'annotated-tag-sha' } } + }) + assert.equal(error, undefined) + assert.equal(calls.length, 0) +}) +test('Tag at another commit fails without mutation', async () => { + const { calls, error } = await simulate({ ref: { object: { type: 'commit', sha: 'other-sha' } } }) + assert.match(error.message, /another commit/) + assert.equal(calls.length, 0) +}) + +test('Untagged draft at another commit fails without mutation', async () => { + const { calls, error } = await simulate({ + release: { id: 42, draft: true, target_commitish: 'other-sha' } + }) + assert.match(error.message, /another target/) + assert.equal(calls.length, 0) +}) + +test('API permission failure is not treated as a missing release', async () => { + const { calls, error } = await simulate({ permissionError: true }) + assert.equal(error.status, 403) + assert.equal(calls.length, 0) +}) +for (const [missing, files] of [ + ['.exe', ['auto-caption-v1.3.0.tar.gz']], + ['.tar.gz', ['auto-caption-v1.3.0.exe']] +]) { + test(`Missing ${missing} installer fails before creating release`, async () => { + const { calls, error } = await simulate({ files }) + assert.equal(error.message, `Missing installer: ${missing}`) + assert.equal(calls.length, 0) + }) +} +test('Pre-release is published without marking latest', async () => { + const { calls, error } = await simulate({ prerelease: true }) + assert.equal(error, undefined) + assert.equal(calls.at(-1)[1].prerelease, true) + assert.equal(calls.at(-1)[1].make_latest, 'false') +}) diff --git a/.github/scripts/test_release_version.py b/.github/scripts/test_release_version.py new file mode 100644 index 0000000..e05beab --- /dev/null +++ b/.github/scripts/test_release_version.py @@ -0,0 +1,72 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from release_version import release_outputs + + +class ReleaseVersionTests(unittest.TestCase): + def test_unchanged_and_new_branch_do_not_release(self): + for previous in ('1.2.0', None): + self.assertEqual(release_outputs(previous, '1.2.0')['changed'], 'false') + + def test_version_change(self): + self.assertEqual(release_outputs('1.2.0', '1.3.0'), { + 'changed': 'true', 'version': '1.3.0', 'tag': 'v1.3.0', + 'prerelease': 'false', + }) + + def test_prerelease_and_build_metadata(self): + result = release_outputs('1.2.0', '1.3.0-rc.1+build.42') + self.assertEqual(result['prerelease'], 'true') + self.assertEqual(result['tag'], 'v1.3.0-rc.1+build.42') + self.assertEqual(release_outputs('1.2.0', '1.3.0+build.42')['prerelease'], 'false') + + def test_reject_unsafe_or_invalid_versions(self): + for version in ('v1.3.0', '01.3.0', '1.3', '1.3.0-01', '1.3.0\ntag=oops', ''): + with self.subTest(version=version), self.assertRaises(ValueError): + release_outputs('1.2.0', version) + + def test_detection_compares_entire_push_not_last_commit(self): + script = Path(__file__).with_name('release_version.py').resolve() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + + def git(*args): + return subprocess.check_output(['git', *args], cwd=root, encoding='utf-8', + stderr=subprocess.STDOUT).strip() + + def commit(version): + (root / 'package.json').write_text(json.dumps({'version': version}), + encoding='utf-8') + git('add', 'package.json') + git('-c', 'user.name=CI Test', '-c', 'user.email=ci@example.com', + 'commit', '--allow-empty', '-m', 'fixture') + + git('init') + commit('1.2.0') + before = git('rev-parse', 'HEAD') + commit('1.3.0') + commit('1.3.0') # A later commit does not modify the version. + event = root / 'event.json' + output = root / 'output.txt' + event.write_text(json.dumps({'before': before}), encoding='utf-8') + environment = {**os.environ, 'GITHUB_EVENT_PATH': str(event), + 'GITHUB_OUTPUT': str(output)} + subprocess.run([sys.executable, str(script)], cwd=root, env=environment, + check=True, capture_output=True) + self.assertIn('changed=true', output.read_text(encoding='utf-8')) + self.assertIn('tag=v1.3.0', output.read_text(encoding='utf-8')) + event.write_text(json.dumps({'before': '0' * 40}), encoding='utf-8') + output.write_text('', encoding='utf-8') + subprocess.run([sys.executable, str(script)], cwd=root, env=environment, + check=True, capture_output=True) + self.assertIn('changed=false', output.read_text(encoding='utf-8')) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c3f7fd8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,208 @@ +name: CI / Release + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +# Each main push gets its own run so a later push cannot discard a version bump. +concurrency: + group: ci-${{ github.event_name }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + check: + name: Check (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2022] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - uses: astral-sh/setup-uv@v7 + with: + version: '0.12.23' + python-version: '3.12' + enable-cache: true + cache-dependency-glob: engine/pyproject.toml + - name: Use default download sources + shell: bash + run: | + sed -E '/^[[:space:]]*electron(_builder_binaries)?_mirror[[:space:]]*=/d' .npmrc > .npmrc.ci + mv .npmrc.ci .npmrc + - run: npm ci + - run: npm run lint + - run: npm run build + - run: npm run engine:test + - name: Test release detection + run: uv run --project engine python -m unittest discover -s .github/scripts -v + - name: Test release publishing + run: node --test .github/scripts/test_publish_release.mjs + + version: + name: Detect version change + if: github.event_name == 'push' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + changed: ${{ steps.version.outputs.changed }} + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + prerelease: ${{ steps.version.outputs.prerelease }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: version + run: python3 .github/scripts/release_version.py + + package: + name: Package (${{ matrix.platform }}) + needs: [check, version] + if: needs.version.outputs.changed == 'true' + strategy: + fail-fast: false + matrix: + include: + - os: windows-2022 + platform: win + target: nsis + files: dist/auto-caption-v*.exe + - os: ubuntu-24.04 + platform: linux + target: tar.gz + files: dist/auto-caption-v*.tar.gz + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + env: + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - uses: astral-sh/setup-uv@v7 + with: + version: '0.12.23' + python-version: '3.12' + enable-cache: true + cache-dependency-glob: engine/pyproject.toml + - name: Use default download sources + shell: bash + run: | + sed -E '/^[[:space:]]*electron(_builder_binaries)?_mirror[[:space:]]*=/d' .npmrc > .npmrc.ci + mv .npmrc.ci .npmrc + - run: npm ci + - run: npm run build:engine + - name: Smoke test packaged engine + shell: bash + run: | + if [ "$RUNNER_OS" = "Windows" ]; then + ./engine/dist/main.exe --help + else + ./engine/dist/main --help + fi + - run: npm run build + - name: Build installer + run: npx electron-builder --${{ matrix.platform }} ${{ matrix.target }} --x64 --publish never --config.electronDownload.mirror=https://github.com/electron/electron/releases/download/ + - uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.platform }}-x64 + path: ${{ matrix.files }} + if-no-files-found: error + retention-days: 14 + + release: + name: Publish GitHub Release + needs: [version, package] + if: needs.version.outputs.changed == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + concurrency: + group: release-${{ needs.version.outputs.tag }} + cancel-in-progress: false + steps: + - uses: actions/download-artifact@v4 + with: + pattern: release-*-x64 + path: release-assets + merge-multiple: true + - name: Publish all installers together + uses: actions/github-script@v7 + env: + RELEASE_TAG: ${{ needs.version.outputs.tag }} + RELEASE_PRERELEASE: ${{ needs.version.outputs.prerelease }} + with: + script: | + const fs = require('node:fs'); + const path = require('node:path'); + const { owner, repo } = context.repo; + const tag = process.env.RELEASE_TAG; + const prerelease = process.env.RELEASE_PRERELEASE === 'true'; + const sha = context.sha; + const optional = async (request) => { + try { return (await request()).data; } + catch (error) { if (error.status === 404) return null; throw error; } + }; + // Reject reused versions even if their tags are annotated. + const ref = await optional(() => github.rest.git.getRef({ owner, repo, ref: `tags/${tag}` })); + if (ref) { + let object = ref.object; + while (object.type === 'tag') { + object = (await github.rest.git.getTag({ owner, repo, tag_sha: object.sha })).data.object; + } + if (object.type !== 'commit' || object.sha !== sha) { + throw new Error(`${tag} already points to another commit; choose a new version.`); + } + } + let release = await optional(() => github.rest.repos.getReleaseByTag({ owner, repo, tag })); + // Draft releases can exist before their tags are created. + if (release && !ref && release.target_commitish !== sha) { + throw new Error(`${tag} has a release for another target; choose a new version.`); + } + if (release && !release.draft) { + core.info(`${tag} is already published for this commit.`); + return; + } + const directory = 'release-assets'; + const files = fs.readdirSync(directory).sort(); + for (const extension of ['.exe', '.tar.gz']) { + if (!files.some(file => file.endsWith(extension))) { + throw new Error(`Missing installer: ${extension}`); + } + } + if (!release) { + const notes = (await github.rest.repos.generateReleaseNotes({ owner, repo, tag_name: tag, target_commitish: sha })).data; + release = (await github.rest.repos.createRelease({ owner, repo, tag_name: tag, + target_commitish: sha, name: tag, body: notes.body, draft: true, prerelease })).data; + } + // A failed upload leaves a draft; rerunning replaces its partial assets. + const assets = await github.paginate(github.rest.repos.listReleaseAssets, { + owner, repo, release_id: release.id + }); + for (const file of files) { + const existing = assets.find(asset => asset.name === file); + if (existing) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: existing.id }); + await github.rest.repos.uploadReleaseAsset({ owner, repo, release_id: release.id, + name: file, data: fs.readFileSync(path.join(directory, file)), + headers: { 'content-type': 'application/octet-stream' } }); + } + await github.rest.repos.updateRelease({ owner, repo, release_id: release.id, + draft: false, prerelease, make_latest: prerelease ? 'false' : 'legacy' }); diff --git a/.gitignore b/.gitignore index 6560132..164e6ed 100644 --- a/.gitignore +++ b/.gitignore @@ -6,14 +6,13 @@ out *.log* __pycache__ .venv +.uv-cache test.py engine/build +engine/uv.lock engine/portaudio engine/pyinstaller_cache engine/models engine/notebook # engine/main.spec - -.repomap -.virtualme diff --git a/.prettierignore b/.prettierignore index 9c6b791..92e4326 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ out dist +.uv-cache pnpm-lock.yaml LICENSE.md tsconfig.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cba53a9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,106 @@ +# AGENTS.md + +## 项目概述 + +Auto Caption 是跨平台的实时字幕显示软件,可识别系统音频输出或麦克风输入,支持字幕翻译、样式设置、字幕记录和导出。 + +- 桌面端:Electron + TypeScript,使用 electron-vite 构建;界面使用 Vue 3、Ant Design Vue、Pinia、Vue Router 和 vue-i18n。 +- 字幕引擎:Python,包含 Paraformer、Qwen LiveTranslate、GLM-ASR、Vosk 和 SOSV 识别实现,以及 Ollama、OpenAI 兼容服务和 Google 翻译支持。 +- 通信:渲染进程通过 Electron IPC 与主进程交互;主进程启动 Python 子进程,引擎通过标准输出发送逐行 JSON 消息,主进程通过 TCP Socket 向引擎发送命令。 + +## 项目结构 + +```text +src/ + main/ Electron 主进程 + index.ts 应用入口和生命周期 + ControlWindow.ts 控制窗口及其 IPC 处理 + CaptionWindow.ts 字幕窗口及其 IPC 处理 + utils/ 引擎进程管理、配置持久化、日志等 + types/ 主进程类型定义 + i18n/ 主进程中英日文案 + preload/ 向渲染进程暴露 Electron API 和类型 + renderer/ + src/ + views/ 控制页面和字幕页面 + components/ 设置、引擎控制、字幕样式和日志组件 + stores/ Pinia 状态管理 + router/ 页面路由 + i18n/ 界面中英日文案及选项配置 + types/ 渲染进程类型定义 + utils/ 界面工具函数 + assets/ 界面样式等资源 +engine/ + README.md 独立引擎使用、测试和构建说明(中文) + main.py 引擎入口、命令行参数和运行流程 + audio2text/ 各语音识别引擎实现 + sysaudio/ Windows、macOS、Linux 音频采集实现 + utils/ 音频处理、翻译、共享状态和通信工具 + pyproject.toml uv 项目配置和 Python 依赖 + uv.lock 本地自动生成的依赖锁文件(Git 忽略) + .python-version 默认 Python 版本 + main.spec PyInstaller 打包配置 + models/ 本地模型(Git 忽略) + notebook/ 实验笔记和示例(Git 忽略) +docs/ 用户手册、引擎说明、通信协议和更新日志 +assets/ README 和文档使用的图片等资源 +build/ Electron 打包资源,如图标和 macOS 权限配置 +electron.vite.config.ts 桌面端构建配置 +electron-builder.yml 安装包配置和引擎资源复制规则 +``` + +`out/`、`dist/`、`engine/build/` 和 `engine/dist/` 为构建产物目录,`node_modules/` 和 `engine/.venv/` 为依赖环境;应修改源文件而不是这些目录中的生成内容。 + +## 项目文档 + +以下路径均相对于仓库根目录: + +| 文档 | 中文 | English | 日本語 | +| ---------------------------------- | ------------------------------------ | ----------------------------------------- | -------------------------------------------- | +| 项目介绍、安装和构建 | [README.md](README.md) | [README_en.md](README_en.md) | [README_ja.md](README_ja.md) | +| 独立字幕引擎使用、测试和构建 | [引擎 README](engine/README.md) | — | — | +| 用户手册、设置和引擎命令行参数 | [用户手册](docs/user-manual/zh.md) | [User manual](docs/user-manual/en.md) | [ユーザーマニュアル](docs/user-manual/ja.md) | +| 引擎架构、运行流程和自定义引擎开发 | [引擎说明](docs/engine-manual/zh.md) | [Engine manual](docs/engine-manual/en.md) | [字幕エンジン説明](docs/engine-manual/ja.md) | + +- [Electron IPC 协议](docs/api-docs/electron-ipc.md):主进程与渲染进程的通信约定。 +- [字幕引擎通信协议](docs/api-docs/caption-engine.md):Electron 主进程与字幕引擎的通信约定。 +- [更新日志](docs/CHANGELOG.md):版本变更记录。 +- [待办事项](docs/TODO.md):已完成事项和后续计划。 +- [GitHub 仓库](https://github.com/HiMeditator/auto-caption)。 + +说明文档统一对应最新发布版本;开发时应结合当前源码核对,遇到与本次变更相关的不一致时同步修正。 + +## 开发规则 + +1. **功能修改后同步更新文档。** 如果现有文档有对应内容,应在同一次变更中更新相关说明、参数、示例和限制,确保文档与实际行为一致: + + - 用户操作、设置、字幕显示或导出行为变更:更新用户手册;README 中有对应介绍时也要更新。 + - 引擎运行流程、模型接入、翻译配置或命令行参数变更:更新引擎说明和用户手册中的对应章节。 + - IPC 通道、消息字段或引擎通信格式变更:更新对应的 `docs/api-docs/` 协议文档。 + - 对于有中英日版本的对应内容,同步维护各语言版本。 + - 需要记录的功能变更和修复写入 `docs/CHANGELOG.md` 的“未发布”部分。 + +2. **保持模块职责和接口一致。** 窗口与系统能力由主进程处理,界面与交互由渲染进程处理,音频采集和识别由 Python 引擎处理。修改配置或通信接口时,检查发送端、接收端、默认配置及两端类型定义;引擎供主进程解析的标准输出应保持逐行 JSON,使用现有输出工具发送消息。 +3. **同步维护界面语言。** 新增或修改用户可见文案时,检查 `src/main/i18n/lang/` 和 `src/renderer/src/i18n/lang/` 中对应的中文、英文、日文内容。 + +## 常用开发命令 + +在仓库根目录执行: + +```bash +npm install # 安装桌面端依赖 +npm run engine:sync # 使用 uv 同步引擎依赖 +npm run engine:test # 运行引擎离线测试 +npm run build:engine # 使用 uv 和 PyInstaller 构建引擎 +npm run dev # 启动开发环境 +npm run typecheck # 主进程和渲染进程类型检查 +npm run lint # ESLint 检查 +npm run build # 类型检查并构建桌面端 +npm run build:win # 构建 Windows 安装包 +npm run build:mac # 构建 macOS 安装包 +npm run build:linux # 构建 Linux 安装包 +``` + +引擎使用 uv 管理,默认 Python 版本由 `engine/.python-version` 指定为 3.12。运行 `npm run engine:sync` 在本地生成或更新 `engine/uv.lock`,并同步到 `engine/.venv/`。锁文件由 Git 忽略,不提交;新增或更新依赖时维护并提交 `pyproject.toml`。开发模式直接调用此环境中的 Python 运行 `engine/main.py`,Windows 为 `.venv/Scripts/python.exe`,macOS/Linux 为 `.venv/bin/python`。 + +uv 命令优先复用 `uv.lock` 中的版本,依赖配置变化时自动更新锁文件,不使用 `--locked` 强制检查。安装包命令先运行 `npm run build:engine`,通过 uv 环境中的 PyInstaller 构建单文件引擎;Vosk 的依赖路径自动发现。Windows 产物 `engine/dist/main.exe` 复制到资源目录的 `engine/main.exe`,macOS/Linux 产物 `engine/dist/main` 复制到资源目录的 `engine/main`。必须在目标操作系统上构建;详细步骤和系统音频依赖见 README。 diff --git a/README.md b/README.md index f8500a0..b744fc6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

auto-caption

Auto Caption 是一个跨平台的实时字幕显示软件。

- + @@ -14,7 +14,7 @@ | English | 日本語 |

-

v1.1.1 版本已经发布,新增 GLM-ASR 云端字幕模型和 OpenAI 兼容模型翻译...

+

当前软件版本:v1.2.0。更新内容详见更新日志。

![](./assets/media/main_zh.png) @@ -29,6 +29,8 @@ SOSV 模型下载:[ Shepra-ONNX SenseVoice Model](https://github.com/HiMeditat ## 📚 相关文档 +[字幕引擎独立使用与构建](./engine/README.md) + [Auto Caption 用户手册](./docs/user-manual/zh.md) [字幕引擎说明文档](./docs/engine-manual/zh.md) @@ -45,14 +47,12 @@ https://github.com/user-attachments/assets/9c188d78-9520-4397-bacf-4c8fdcc54874 - 支持调用本地 Ollama 模型、云端 OpenAI 兼容模型、或云端 Google 翻译 API 进行翻译 - 跨平台(Windows、macOS、Linux)、多界面语言(中文、英语、日语)支持 - 丰富的字幕样式设置(字体、字体大小、字体粗细、字体颜色、背景颜色等) -- 灵活的字幕引擎选择(阿里云 Gummy 云端模型、GLM-ASR 云端模型、本地 Vosk 模型、本地 SOSV 模型、还可以自己开发模型) +- 灵活的字幕引擎选择(阿里云 Paraformer 云端模型、GLM-ASR 云端模型、本地 Vosk 模型、本地 SOSV 模型等、还可以自己开发模型) - 多语言识别与翻译(见下文“⚙️ 自带字幕引擎说明”) - 字幕记录展示与导出(支持导出 `.srt` 和 `.json` 格式) ## 📖 基本使用 -> ⚠️ 注意:目前只维护了 Windows 平台的软件的最新版本,其他平台的最后版本停留在 v1.0.0。 - 软件已经适配了 Windows、macOS 和 Linux 平台。测试过的主流平台信息如下: | 操作系统版本 | 处理器架构 | 获取系统音频输入 | 获取系统音频输出 | @@ -67,13 +67,14 @@ macOS 平台和 Linux 平台获取系统音频输出需要进行额外设置, | | 准确率 | 实时性 | 部署类型 | 支持语言 | 翻译 | 备注 | | ------------------------------------------------------------ | -------- | ------------- | ---------- | ---------- | ---------------------------------------------------------- | ---------------------------------------------------------- | -| [Gummy](https://help.aliyun.com/zh/model-studio/gummy-speech-recognition-translation) | 很好😊 | 很好😊 | 云端 / 阿里云 | 10 种 | 自带翻译 | 收费,识别0.54CNY / 小时,识别+翻译1.08CNY/小时 | +| [Qwen LiveTranslate 3.8](https://help.aliyun.com/zh/model-studio/qwen3-8-livetranslate-flash-realtime) | 很好😊 | 很好😊 | 云端 / 阿里云 | 自动检测 | 自带翻译 | 收费,计费规则见模型链接 | +| [Paraformer Realtime v2](https://help.aliyun.com/zh/model-studio/paraformer-real-time-speech-recognition-python-sdk) | 很好😊 | 很好😊 | 云端 / 阿里云 | 8 种语言(含粤语) | 需额外配置 | 收费,约 0.864 CNY / 小时 | | [glm-asr-2512](https://docs.bigmodel.cn/cn/guide/models/sound-and-video/glm-asr-2512) | 很好😊 | 较差😞 | 云端 / 智谱 AI | 4 种 | 需额外配置 | 收费,约 0.72CNY / 小时 | | [Vosk](https://alphacephei.com/vosk) | 较差😞 | 很好😊 | 本地 / CPU | 超过 30 种 | 需额外配置 | 支持的语言非常多 | | [SOSV](https://k2-fsa.github.io/sherpa/onnx/sense-voice/index.html) | 一般😐 | 一般😐 | 本地 / CPU | 5 种 | 需额外配置 | 仅有一个模型 | | 自己开发 | 🤔 | 🤔 | 自定义 | 自定义 | 自定义 | 根据[文档](./docs/engine-manual/zh.md)使用 Python 自己开发 | -如果你选择的不是 Gummy 模型,你还需要配置自己的翻译模型。 +Qwen LiveTranslate 自带翻译;其他字幕引擎开启翻译时需要配置翻译服务。 ### 配置翻译模型 @@ -85,11 +86,11 @@ macOS 平台和 Linux 平台获取系统音频输出需要进行额外设置, > 注意:使用参数量过大的模型会导致资源消耗和翻译延迟较大。建议使用参数量小于 1B 的模型,比如: `qwen2.5:0.5b`, `qwen3:0.6b`。 -使用该模型之前你需要确定本机安装了 [Ollama](https://ollama.com/) 软件,并已经下载了需要的大语言模型。只需要将需要调用的大模型名称添加到设置中的 `模型名称` 字段中,并保证 `Base URL` 字段为空。 +使用该模型之前你需要确定本机安装了 [Ollama](https://ollama.com/) 软件,并已经下载了需要的大语言模型。在“翻译服务”中选择 `Ollama 本地模型`,再填写模型名称即可;Ollama 会使用本机默认服务地址。 #### OpenAI 兼容模型 -如果觉得本地 Ollama 模型的翻译效果不佳,或者不想在本地安装 Ollama 模型,那么可以使用云端的 OpenAI 兼容模型。 +如果觉得本地 Ollama 模型的翻译效果不佳,或者不想在本地安装 Ollama 模型,那么可以在“翻译服务”中选择 `OpenAI 兼容模型`,并分别填写模型名称、Base URL 和 API Key。 以下是一些模型提供商的 `Base URL`: - OpenAI: https://api.openai.com/v1 @@ -104,30 +105,37 @@ API Key 需要在对应的模型提供商处获取。 无需任何配置,联网即可使用。 -### 使用 Gummy 模型 +## ⚙️ 自带的语音识别模型 + +目前软件自带 5 个语音识别模型。它们的使用方法和介绍如下。 -> 国际版的阿里云服务似乎并没有提供 Gummy 模型,因此目前非中国用户可能无法使用 Gummy 字幕引擎。 +#### 使用 Qwen LiveTranslate -如果要使用默认的 Gummy 字幕引擎(使用云端模型进行语音识别和翻译),首先需要获取阿里云百炼平台的 API KEY,然后将 API KEY 添加到软件设置中(在字幕引擎设置的更多设置中)或者配置到环境变量中(仅 Windows 平台支持读取环境变量中的 API KEY),这样才能正常使用该模型。相关教程: +Qwen LiveTranslate 使用 `qwen3.8-livetranslate-flash-realtime`,在同一个 WebSocket 会话中流式返回原文和译文,无需配置外部翻译服务。默认仍为 Paraformer。选择此引擎后填写业务空间 ID、地域(北京或新加坡),并在更多设置中填写该业务空间和地域的阿里云 API Key;也可使用 `DASHSCOPE_WORKSPACE_ID` 和 `DASHSCOPE_API_KEY` 环境变量。源语言自动检测,界面提供中、英、日、韩、德、法、俄、西、意作为翻译目标语言。音频上传为 16kHz、16bit、单声道 PCM,仅输出文字,不播放翻译语音。关闭翻译仅隐藏译文,服务端仍执行翻译;只需要识别时建议选择 Paraformer。 -- [获取 API KEY](https://help.aliyun.com/zh/model-studio/get-api-key) -- [将 API Key 配置到环境变量](https://help.aliyun.com/zh/model-studio/configure-api-key-through-environment-variables) +[API / WebSocket](https://help.aliyun.com/zh/model-studio/qwen3-8-livetranslate-flash-realtime) -### 使用 GLM-ASR 模型 +#### 使用 Paraformer Realtime v2 + +默认引擎为阿里云 `paraformer-realtime-v2`,通过 DashScope Recognition SDK 双向流式识别。上传音频为 16kHz、16bit、单声道 PCM;支持中文、英文、日语、韩语、德语、法语、俄语及粤语,`auto` 不指定语言提示。模型不提供内置翻译;开启翻译后需配置 Ollama、OpenAI 兼容服务或 Google 翻译,完整句子返回后异步翻译。设置中的阿里云 API Key 或 `DASHSCOPE_API_KEY` 环境变量用于鉴权(所有平台);CLI 参数为 `--dashscope_api_key`。当前使用北京地域默认端点,API Key 必须匹配该地域。旧 Gummy 引擎选项和 API Key 自动迁移,旧西班牙语/意大利语源语言改为自动检测;旧自定义命令需手动更新。详见[阿里云官方 SDK 文档](https://help.aliyun.com/zh/model-studio/paraformer-real-time-speech-recognition-python-sdk)。 + +#### 使用 GLM-ASR 模型 使用前需要获取智谱 AI 平台的 API KEY,并添加到软件设置中。 API KEY 获取相关链接:[快速开始](https://docs.bigmodel.cn/cn/guide/start/quick-start)。 -### 使用 Vosk 模型 +#### 使用 Vosk 模型 -> Vosk 模型的识别效果较差,请谨慎使用。 +基于 [vosk-api](https://github.com/alphacep/vosk-api) 开发。该字幕引擎的优点是可选的语言模型非常多(超过 30 种),缺点是识别效果比较差,且生成内容没有标点符号。 如果要使用 Vosk 本地字幕引擎,首先需要在 [Vosk Models](https://alphacephei.com/vosk/models) 页面下载你需要的模型,并将模型解压到本地,并将模型文件夹的路径添加到软件的设置中。 ![](./assets/media/config_zh.png) -### 使用 SOSV 模型 +#### 使用 SOSV 模型 + +SOSV 是一个模型整合包,该整合包主要基于 [Shepra-ONNX SenseVoice](https://k2-fsa.github.io/sherpa/onnx/sense-voice/index.html),并添加了端点检测模型和标点恢复模型。该模型支持识别的语言有:英语、中文、日语、韩语、粤语。 使用 SOSV 模型的方式和 Vosk 一样,下载地址如下:https://github.com/HiMeditator/auto-caption/releases/tag/sosv-model @@ -140,12 +148,16 @@ API KEY 获取相关链接:[快速开始](https://docs.bigmodel.cn/cn/guide/st 1. 使用项目字幕引擎部分的源代码,使用安装了对应库的 Python 环境进行运行 2. 使用打包好的字幕引擎的可执行文件,通过终端运行 +源码方式请先安装 uv,进入 `engine` 目录并执行 `uv sync`;下方示例也在此目录运行。打包后的可执行文件无需 uv。 + 运行参数和详细使用介绍请参考[用户手册](./docs/user-manual/zh.md#单独使用字幕引擎)。 ```bash -python main.py \ --e gummy \ --k sk-******************************** \ +uv run python main.py \ +-e paraformer \ +--dashscope_api_key sk-******************************** \ +--translation_provider ollama \ +--translation_model qwen2.5:0.5b \ -a 0 \ -d 1 \ -s en \ @@ -154,48 +166,6 @@ python main.py \ ![](./docs/img/07.png) -## ⚙️ 自带字幕引擎说明 - -目前软件自带 4 个字幕引擎。它们的详细信息如下。 - -### Gummy 字幕引擎(云端) - -基于通义实验室[Gummy语音翻译大模型](https://help.aliyun.com/zh/model-studio/gummy-speech-recognition-translation/)进行开发,基于[阿里云百炼](https://bailian.console.aliyun.com)的 API 进行调用该云端模型。 - -**模型详细参数:** - -- 音频采样率支持:16kHz及以上 -- 音频采样位数:16bit -- 音频通道数支持:单通道 -- 可识别语言:中文、英文、日语、韩语、德语、法语、俄语、意大利语、西班牙语 -- 支持的翻译: - - 中文 → 英文、日语、韩语 - - 英文 → 中文、日语、韩语 - - 日语、韩语、德语、法语、俄语、意大利语、西班牙语 → 中文或英文 - -**网络流量消耗:** - -字幕引擎使用原生采样率(假设为 48kHz)进行采样,样本位深为 16bit,上传音频为为单通道,因此上传速率约为: - -$$ -48000\ \text{samples/second} \times 2\ \text{bytes/sample} \times 1\ \text{channel} = 93.75\ \text{KB/s} -$$ - -而且引擎只会获取到音频流的时候才会上传数据,因此实际上传速率可能更小。模型结果回传流量消耗较小,没有纳入考虑。 - -### GLM-ASR 字幕引擎(云端) - -https://docs.bigmodel.cn/cn/guide/models/sound-and-video/glm-asr-2512 - -### Vosk 字幕引擎(本地) - -基于 [vosk-api](https://github.com/alphacep/vosk-api) 开发。该字幕引擎的优点是可选的语言模型非常多(超过 30 种),缺点是识别效果比较差,且生成内容没有标点符号。 - - -### SOSV 字幕引擎(本地) - -[SOSV](https://github.com/HiMeditator/auto-caption/releases/tag/sosv-model) 是一个整合包,该整合包主要基于 [Shepra-ONNX SenseVoice](https://k2-fsa.github.io/sherpa/onnx/sense-voice/index.html),并添加了端点检测模型和标点恢复模型。该模型支持识别的语言有:英语、中文、日语、韩语、粤语。 - ## 🚀 项目运行 ![](./assets/media/structure_zh.png) @@ -206,56 +176,42 @@ https://docs.bigmodel.cn/cn/guide/models/sound-and-video/glm-asr-2512 npm install ``` -### 构建字幕引擎 +### 管理与构建字幕引擎 -首先进入 `engine` 文件夹,执行如下指令创建虚拟环境(需要使用大于等于 Python 3.10 的 Python 运行环境,建议使用 Python 3.12): +先安装 [uv](https://docs.astral.sh/uv/getting-started/installation/)。引擎依赖声明在 `engine/pyproject.toml`;`engine/.python-version` 默认选择 Python 3.12。新克隆环境会重新解析依赖,不同开发者或构建可能使用不同版本。以下命令均在仓库根目录执行: ```bash -cd ./engine -# in ./engine folder -python -m venv .venv -# or -python3 -m venv .venv +# 更新项目依赖 +npm run engine:sync +# 运行测试 +npm run engine:test +# 构建可执行文件 +npm run build:engine ``` -然后激活虚拟环境: +开发版 Electron 直接运行该环境中的 Python;发布版运行打包后的引擎,不需要用户安装 uv 或 Python。 -```bash -# Windows -.venv/Scripts/activate -# Linux or macOS -source .venv/bin/activate -``` +macOS 的 PyAudio 需要 PortAudio(例如使用 `brew install portaudio` 安装);Linux 的音频采集需要 `pactl` 和 `parec`(Ubuntu 可安装 `pulseaudio-utils`),并需要可用的 PulseAudio 或 PipeWire PulseAudio 兼容服务。其他平台配置见[用户手册](./docs/user-manual/zh.md)。 -然后安装依赖(这一步在 macOS 和 Linux 可能会报错,一般是因为构建失败,需要根据报错信息进行处理): +`build:engine` 使用 uv 管理的环境运行 PyInstaller,生成单文件引擎:Windows 为 `engine/dist/main.exe`,macOS/Linux 为 `engine/dist/main`。安装包将引擎放在 `resources/engine/main.exe`(Windows)或 `resources/engine/main`(macOS/Linux)。 -```bash -pip install -r requirements.txt -``` - -然后使用 `pyinstaller` 构建项目: +### 运行项目 ```bash -pyinstaller ./main.spec -``` - -注意 `main.spec` 文件中 `vosk` 库的路径可能不正确,需要根据实际状况配置(与 Python 环境的版本相关)。 - -``` -# Windows -vosk_path = str(Path('./.venv/Lib/site-packages/vosk').resolve()) -# Linux or macOS -vosk_path = str(Path('./.venv/lib/python3.x/site-packages/vosk').resolve()) +npm run dev ``` -此时项目构建完成,进入 `engine/dist` 文件夹可见对应的可执行文件。即可进行后续操作。 +### 代码检查 -### 运行项目 +在仓库根目录执行: ```bash -npm run dev +npm run lint +npm run typecheck ``` +ESLint 检查桌面端 JavaScript、TypeScript 和 Vue 源码及构建配置,跳过构建产物、依赖环境、模型和实验目录;Python 引擎不在 ESLint 检查范围内。 + ### 构建项目 ```bash @@ -266,3 +222,7 @@ npm run build:mac # For Linux npm run build:linux ``` + +以上安装包命令会先构建字幕引擎,再构建桌面端。必须在目标操作系统上构建相应的安装包;PyInstaller 不支持跨操作系统编译引擎。 + +GitHub Actions 当前仅检查和发布 Windows/Linux x64,暂时移除 macOS 自动构建。面向 `main` 的 PR 运行代码检查、桌面端构建和测试;推送或合并到 `main` 且 `package.json` 版本号变化时,检查通过后构建 Windows NSIS 安装包(`.exe`)和 Linux 压缩包(`.tar.gz`),两者上传完成后发布 GitHub Release。 diff --git a/README_en.md b/README_en.md index 7afd984..f513114 100644 --- a/README_en.md +++ b/README_en.md @@ -1,9 +1,9 @@

auto-caption

-

Auto Caption is a cross-platform real-time caption display software.

+

Auto Caption is cross-platform software for displaying real-time captions.

- + @@ -14,7 +14,7 @@ | English | 日本語 |

-

v1.1.1 has been released, adding the GLM-ASR cloud caption model and OpenAI compatible model translation...

+

Current software version: v1.2.0. See the changelog for updates.

![](./assets/media/main_en.png) @@ -29,6 +29,8 @@ SOSV Model Download: [Shepra-ONNX SenseVoice Model](https://github.com/HiMeditat ## 📚 Documentation +[Standalone Engine Usage and Build Guide (Chinese)](./engine/README.md) + [Auto Caption User Manual](./docs/user-manual/en.md) [Caption Engine Documentation](./docs/engine-manual/en.md) @@ -45,14 +47,12 @@ https://github.com/user-attachments/assets/9c188d78-9520-4397-bacf-4c8fdcc54874 - Supports calling local Ollama models, cloud-based OpenAI compatible models, or cloud-based Google Translate API for translation - 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 (Aliyun Gummy cloud model,GLM-ASR cloud model, local Vosk model, local SOSV model, or you can develop your own model) -- Multi-language recognition and translation (see below "⚙️ Built-in Subtitle Engines") +- Flexible caption engine selection (Aliyun Paraformer cloud model, GLM-ASR cloud model, local Vosk model, local SOSV model, or you can develop your own model) +- Multi-language recognition and translation (see below "⚙️ Built-in Speech Recognition Models") - Subtitle record display and export (supports exporting `.srt` and `.json` formats) ## 📖 Basic Usage -> ⚠️ Note: Currently, only the latest version of the software on Windows platform is maintained, while the last versions for other platforms remain at v1.0.0. - The software has been adapted for Windows, macOS, and Linux platforms. The tested platform information is as follows: | OS Version | Architecture | System Audio Input | System Audio Output | @@ -63,18 +63,18 @@ The software has been adapted for Windows, macOS, and Linux platforms. The teste Additional configuration is required to capture system audio output on macOS and Linux platforms. See [Auto Caption User Manual](./docs/user-manual/en.md) for details. - After downloading the software, you need to select the corresponding model according to your needs and then configure the model. | | Accuracy | Real-time | Deployment Type | Supported Languages | Translation | Notes | | ------------------------------------------------------------ | -------- | --------- | --------------- | ------------------- | ----------- | ----- | -| [Gummy](https://help.aliyun.com/zh/model-studio/gummy-speech-recognition-translation) | Very good 😊 | Very good 😊 | Cloud / Alibaba Cloud | 10 languages | Built-in translation | Paid, recognition 0.54 CNY/hour, recognition + translation 1.08 CNY/hour | +| [Qwen LiveTranslate 3.8](https://help.aliyun.com/zh/model-studio/qwen3-8-livetranslate-flash-realtime) | Very good 😊 | Very good 😊 | Cloud / Alibaba Cloud | Auto detection | Built-in translation | Paid; see the model link for pricing | +| [Paraformer Realtime v2](https://help.aliyun.com/zh/model-studio/paraformer-real-time-speech-recognition-python-sdk) | Very good 😊 | Very good 😊 | Cloud / Alibaba Cloud | 8 languages including Cantonese | Requires additional configuration | Paid, approximately 0.864 CNY/hour | | [glm-asr-2512](https://docs.bigmodel.cn/cn/guide/models/sound-and-video/glm-asr-2512) | Very good 😊 | Poor 😞 | Cloud / Zhipu AI | 4 languages | Requires additional configuration | Paid, approximately 0.72 CNY/hour | | [Vosk](https://alphacephei.com/vosk) | Poor 😞 | Very good 😊 | Local / CPU | Over 30 languages | Requires additional configuration | Supports many languages | | [SOSV](https://k2-fsa.github.io/sherpa/onnx/sense-voice/index.html) | Average 😐 | Average 😐 | Local / CPU | 5 languages | Requires additional configuration | Only one model | | Self-developed | 🤔 | 🤔 | Custom | Custom | Custom | Develop your own using Python according to the [documentation](./docs/engine-manual/en.md) | -If you choose a model other than Gummy, you also need to configure your own translation model. +Qwen LiveTranslate includes translation; other caption engines require an external translation service when translation is enabled. ### Configuring Translation Models @@ -86,11 +86,11 @@ If you choose a model other than Gummy, you also need to configure your own tran > Note: Using models with too many parameters will lead to high resource consumption and translation delays. It is recommended to use models with less than 1B parameters, such as: `qwen2.5:0.5b`, `qwen3:0.6b`. -Before using this model, you need to confirm that the [Ollama](https://ollama.com/) software is installed on your local machine and that you have downloaded the required large language model. Simply add the name of the large model you want to call to the `Model Name` field in the settings, and ensure that the `Base URL` field is empty. +Before using this model, confirm that [Ollama](https://ollama.com/) is installed locally and that the required model has been downloaded. Select `Ollama Local Model` as the translation service and enter the model name; Ollama uses its default local service address. #### OpenAI Compatible Model -If you feel the translation effect of the local Ollama model is not good enough, or don't want to install the Ollama model locally, then you can use cloud-based OpenAI compatible models. +If the local Ollama model's translation quality is unsatisfactory, or you prefer not to install Ollama locally, select `OpenAI-compatible Model` as the translation service and enter its model name, Base URL, and API key separately. Here are some model provider `Base URL`s: - OpenAI: https://api.openai.com/v1 @@ -101,34 +101,41 @@ The API Key needs to be obtained from the corresponding model provider. #### Google Translate API -> Note: Google Translate API is not available in some regions. +> Note: Google Translate API cannot be used in regions without access to the international internet. No configuration required, just connect to the internet to use. -### Using Gummy Model +## ⚙️ Built-in Speech Recognition Models + +The software currently includes 5 speech recognition models. Their usage and descriptions are provided below. + +#### Using Qwen LiveTranslate -> The international version of Alibaba Cloud services does not seem to provide the Gummy model, so non-Chinese users may not be able to use the Gummy caption engine at present. +Qwen LiveTranslate uses `qwen3.8-livetranslate-flash-realtime` to stream source transcripts and translated text in one WebSocket session, with no external translation service. Paraformer remains the default. Select this engine, enter the workspace ID and region (Beijing or Singapore), and enter an Alibaba Cloud API Key for that workspace and region in More Settings. Alternatively, use `DASHSCOPE_WORKSPACE_ID` and `DASHSCOPE_API_KEY`. The source language is detected automatically. The UI offers Chinese, English, Japanese, Korean, German, French, Russian, Spanish and Italian as target languages. Audio is uploaded as 16kHz, 16-bit mono PCM. Only text is returned; translated speech is not played. Disabling translation only hides the translation, while the service still translates. Use Paraformer for recognition only. -To use the default Gummy caption engine (using cloud models for speech recognition and translation), you first need to obtain an API KEY from Alibaba Cloud Bailian platform, then add the API KEY to the software settings or configure it in the environment variables (only Windows platform supports reading API KEY from environment variables), so that the model can be used normally. Related tutorials: +[API / WebSocket](https://help.aliyun.com/zh/model-studio/qwen3-8-livetranslate-flash-realtime) -- [Get API KEY](https://help.aliyun.com/zh/model-studio/get-api-key) -- [Configure API Key through Environment Variables](https://help.aliyun.com/zh/model-studio/configure-api-key-through-environment-variables) +#### Using Paraformer Realtime v2 -### Using the GLM-ASR Model +The default engine is Alibaba Cloud `paraformer-realtime-v2`, using the DashScope Recognition SDK for duplex streaming ASR. Audio is uploaded as 16kHz, 16-bit mono PCM. Supported source languages are Chinese, English, Japanese, Korean, German, French, Russian and Cantonese; `auto` sends no language hint. There is no built-in translation: configure Ollama, an OpenAI-compatible service or Google Translate to translate finalized sentences asynchronously. Set the Alibaba Cloud API Key in settings or the `DASHSCOPE_API_KEY` environment variable on any platform; the CLI option is `--dashscope_api_key`. The current endpoint is the default Beijing endpoint and requires a matching regional API Key. Old Gummy selections and API Keys migrate automatically; old Spanish/Italian source selections switch to auto detection. Update old custom commands manually. See the [official SDK documentation](https://help.aliyun.com/zh/model-studio/paraformer-real-time-speech-recognition-python-sdk). + +#### Using the GLM-ASR Model Before using it, you need to obtain an API KEY from the Zhipu AI platform and add it to the software settings. -For API KEY acquisition, see: [Quick Start](https://docs.bigmodel.cn/en/guide/start/quick-start). +For API KEY acquisition, see: [Quick Start](https://docs.bigmodel.cn/cn/guide/start/quick-start). -### Using Vosk Model +#### Using the Vosk Model -> The recognition effect of the Vosk model is poor, please use it with caution. +Developed based on [vosk-api](https://github.com/alphacep/vosk-api). This caption engine offers a wide choice of language models (over 30 languages), but recognition accuracy is relatively poor and the generated text has no punctuation. To use the Vosk local caption engine, first download the model you need from the [Vosk Models](https://alphacephei.com/vosk/models) page, unzip the model locally, and add the path of the model folder to the software settings. ![](./assets/media/config_en.png) -### Using SOSV Model +#### Using the SOSV Model + +SOSV is an integrated model package, mainly based on [Shepra-ONNX SenseVoice](https://k2-fsa.github.io/sherpa/onnx/sense-voice/index.html), with additional endpoint detection and punctuation restoration models. It supports recognition in English, Chinese, Japanese, Korean, and Cantonese. The way to use the SOSV model is the same as Vosk. The download address is as follows: https://github.com/HiMeditator/auto-caption/releases/tag/sosv-model @@ -141,12 +148,16 @@ Caption engine is developed using Python and packaged as executable files via Py 1. Use the source code of the project's caption engine part and run it with a Python environment that has the required libraries installed 2. Run the packaged executable file of the caption engine through the terminal +For source execution, install uv, enter `engine`, and run `uv sync` first. Run the example below in that directory. Packaged executables do not require uv. + For runtime parameters and detailed usage instructions, please refer to the [User Manual](./docs/user-manual/en.md#using-caption-engine-standalone). ```bash -python main.py \ --e gummy \ --k sk-******************************** \ +uv run python main.py \ +-e paraformer \ +--dashscope_api_key sk-******************************** \ +--translation_provider ollama \ +--translation_model qwen2.5:0.5b \ -a 0 \ -d 1 \ -s en \ @@ -155,47 +166,6 @@ python main.py \ ![](./docs/img/07.png) -## ⚙️ Built-in Subtitle Engines - -Currently, the software comes with 4 caption engines, with new engines under development. Their detailed information is as follows. - -### Gummy Subtitle Engine (Cloud) - -Developed based on Tongyi Lab's [Gummy Speech Translation Model](https://help.aliyun.com/zh/model-studio/gummy-speech-recognition-translation/), using [Alibaba Cloud Bailian](https://bailian.console.aliyun.com) API to call this cloud model. - -**Model Parameters:** - -- Supported audio sample rate: 16kHz and above -- Audio sample depth: 16bit -- Supported audio channels: Mono -- Recognizable languages: Chinese, English, Japanese, Korean, German, French, Russian, Italian, Spanish -- Supported translations: - - Chinese → English, Japanese, Korean - - English → Chinese, Japanese, Korean - - Japanese, Korean, German, French, Russian, Italian, Spanish → Chinese or English - -**Network Traffic Consumption:** - -The caption engine uses native sample rate (assumed to be 48kHz) for sampling, with 16bit sample depth and mono channel, so the upload rate is approximately: - -$$ -48000\ \text{samples/second} \times 2\ \text{bytes/sample} \times 1\ \text{channel} = 93.75\ \text{KB/s} -$$ - -The engine only uploads data when receiving audio streams, so the actual upload rate may be lower. The return traffic consumption of model results is small and not considered here. - -### GLM-ASR Caption Engine (Cloud) - -https://docs.bigmodel.cn/en/guide/models/sound-and-video/glm-asr-2512 - -### Vosk Subtitle Engine (Local) - -Developed based on [vosk-api](https://github.com/alphacep/vosk-api). The advantage of this caption engine is that there are many optional language models (over 30 languages), but the disadvantage is that the recognition effect is relatively poor, and the generated content has no punctuation. - -### SOSV Subtitle Engine (Local) - -[SOSV](https://github.com/HiMeditator/auto-caption/releases/tag/sosv-model) is an integrated package, mainly based on [Shepra-ONNX SenseVoice](https://k2-fsa.github.io/sherpa/onnx/sense-voice/index.html), with added endpoint detection model and punctuation restoration model. The languages supported by this model for recognition are: English, Chinese, Japanese, Korean, and Cantonese. - ## 🚀 Project Setup ![](./assets/media/structure_en.png) @@ -206,56 +176,42 @@ Developed based on [vosk-api](https://github.com/alphacep/vosk-api). The advanta npm install ``` -### Build Subtitle Engine +### Manage and Build the Subtitle Engine -First enter the `engine` folder and execute the following commands to create a virtual environment (requires Python 3.10 or higher, with Python 3.12 recommended): +Install [uv](https://docs.astral.sh/uv/getting-started/installation/) first. Engine dependencies are declared in `engine/pyproject.toml`. `engine/.python-version` selects Python 3.12 by default. Fresh clones resolve dependencies again, so versions can differ between developers or builds. Run these commands from the repository root: ```bash -cd ./engine -# in ./engine folder -python -m venv .venv -# or -python3 -m venv .venv +# Synchronize project dependencies +npm run engine:sync +# Run tests +npm run engine:test +# Build the executable +npm run build:engine ``` -Then activate the virtual environment: - -```bash -# Windows -.venv/Scripts/activate -# Linux or macOS -source .venv/bin/activate -``` +Electron development mode runs Python directly from this environment; the release version runs the packaged engine and does not require users to install uv or Python. -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): +On macOS, PyAudio requires PortAudio (for example, `brew install portaudio`). Linux audio capture requires `pactl` and `parec` (`pulseaudio-utils` on Ubuntu) and a working PulseAudio server or PipeWire PulseAudio compatibility service. See the [user manual](./docs/user-manual/en.md) for other platform settings. -```bash -pip install -r requirements.txt -``` +`build:engine` runs PyInstaller in the uv-managed environment and produces a single executable: `engine/dist/main.exe` on Windows or `engine/dist/main` on macOS/Linux. Installers copy the executable to `resources/engine/main.exe` on Windows or `resources/engine/main` on macOS/Linux. -Then use `pyinstaller` to build the project: +### Run Project ```bash -pyinstaller ./main.spec -``` - -Note that the path to the `vosk` library in `main-vosk.spec` might be incorrect and needs to be configured according to the actual situation (related to the version of the Python environment). - -``` -# Windows -vosk_path = str(Path('./.venv/Lib/site-packages/vosk').resolve()) -# Linux or macOS -vosk_path = str(Path('./.venv/lib/python3.x/site-packages/vosk').resolve()) +npm run dev ``` -After the build completes, you can find the executable file in the `engine/dist` folder. Then proceed with subsequent operations. +### Code Checks -### Run Project +Run these commands from the repository root: ```bash -npm run dev +npm run lint +npm run typecheck ``` +ESLint checks the desktop JavaScript, TypeScript and Vue source files and build configuration, excluding build outputs, dependency environments, models and experimental directories. The Python engine is outside ESLint's scope. + ### Build Project ```bash @@ -266,3 +222,7 @@ npm run build:mac # For Linux npm run build:linux ``` + +These installer commands build the engine before the desktop app. Run each command on its target operating system; PyInstaller does not cross-compile the engine for another OS. + +GitHub Actions currently checks and publishes Windows/Linux x64 only; automated macOS builds are temporarily disabled. PRs targeting `main` run code checks, desktop builds and tests. When a push or merge to `main` changes the `package.json` version, successful checks are followed by packaging the Windows NSIS installer (`.exe`) and Linux archive (`.tar.gz`). The GitHub Release is published after both uploads complete. diff --git a/README_ja.md b/README_ja.md index ab398b8..7e5a3e9 100644 --- a/README_ja.md +++ b/README_ja.md @@ -3,7 +3,7 @@

auto-caption

Auto Caption はクロスプラットフォームのリアルタイム字幕表示ソフトウェアです。

- + @@ -14,7 +14,7 @@ | English | 日本語 |

-

v1.1.1 バージョンがリリースされました。GLM-ASR クラウド字幕モデルと OpenAI 互換モデル翻訳が追加されました...

+

現在のソフトウェアバージョン:v1.2.0。更新内容は更新履歴をご覧ください。

![](./assets/media/main_ja.png) @@ -29,6 +29,8 @@ SOSV モデルダウンロード: [Shepra-ONNX SenseVoice Model](https://github. ## 📚 関連ドキュメント +[字幕エンジンの単独実行と構築(中国語)](./engine/README.md) + [Auto Caption ユーザーマニュアル](./docs/user-manual/ja.md) [字幕エンジン説明ドキュメント](./docs/engine-manual/ja.md) @@ -45,13 +47,11 @@ https://github.com/user-attachments/assets/9c188d78-9520-4397-bacf-4c8fdcc54874 - ローカルのOllamaモデル、クラウド上のOpenAI互換モデル、またはクラウド上のGoogle翻訳APIを呼び出して翻訳を行うことをサポートしています - クロスプラットフォーム(Windows、macOS、Linux)、多言語インターフェース(中国語、英語、日本語)対応 - 豊富な字幕スタイル設定(フォント、フォントサイズ、フォント太さ、フォント色、背景色など) -- 柔軟な字幕エンジン選択(阿里云Gummyクラウドモデル、GLM-ASRクラウドモデル、ローカルVoskモデル、ローカルSOSVモデル、または独自にモデルを開発可能) -- 多言語認識と翻訳(下記「⚙️ 字幕エンジン説明」参照) +- 柔軟な字幕エンジン選択(阿里云Paraformerクラウドモデル、GLM-ASRクラウドモデル、ローカルVoskモデル、ローカルSOSVモデル、または独自にモデルを開発可能) +- 多言語認識と翻訳(下記「⚙️ 内蔵の音声認識モデル」参照) - 字幕記録表示とエクスポート(`.srt` および `.json` 形式のエクスポートに対応) -## 📖 基本使い方 - -> ⚠️ 注意:現在、Windowsプラットフォームのソフトウェアの最新バージョンのみがメンテナンスされており、他のプラットフォームの最終バージョンはv1.0.0のままです。 +## 📖 基本的な使い方 このソフトウェアは Windows、macOS、Linux プラットフォームに対応しています。テスト済みのプラットフォーム情報は以下の通りです: @@ -60,8 +60,6 @@ https://github.com/user-attachments/assets/9c188d78-9520-4397-bacf-4c8fdcc54874 | Windows 11 24H2 | x64 | ✅ | ✅ | | 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)をご覧ください。 @@ -69,13 +67,14 @@ macOS および Linux プラットフォームでシステムオーディオ出 | | 正確性 | 実時間性 | デプロイタイプ | 対応言語 | 翻訳 | 備考 | | ------------------------------------------------------------ | -------- | --------- | -------------- | -------- | ---- | ---- | -| [Gummy](https://help.aliyun.com/zh/model-studio/gummy-speech-recognition-translation) | とても良い😊 | とても良い😊 | クラウド / アリババクラウド | 10言語 | 内蔵翻訳 | 有料、音声認識 0.54 CNY/時間、音声認識+翻訳 1.08 CNY/時間 | -| [glm-asr-2512](https://docs.bigmodel.cn/cn/guide/models/sound-and-video/glm-asr-2512) | とても良い😊 | 悪い😞 | クラウド / Zhipu AI | 4言語 | 追加設定が必要 | 有料、約0.72元/時間 | +| [Qwen LiveTranslate 3.8](https://help.aliyun.com/zh/model-studio/qwen3-8-livetranslate-flash-realtime) | とても良い😊 | とても良い😊 | クラウド / アリババクラウド | 自動検出 | 内蔵翻訳 | 有料、料金はモデルのリンクを参照 | +| [Paraformer Realtime v2](https://help.aliyun.com/zh/model-studio/paraformer-real-time-speech-recognition-python-sdk) | とても良い😊 | とても良い😊 | クラウド / アリババクラウド | 広東語を含む8言語 | 追加設定が必要 | 有料、約0.864 CNY/時間 | +| [glm-asr-2512](https://docs.bigmodel.cn/cn/guide/models/sound-and-video/glm-asr-2512) | とても良い😊 | 悪い😞 | クラウド / Zhipu AI | 4言語 | 追加設定が必要 | 有料、約0.72 CNY/時間 | | [Vosk](https://alphacephei.com/vosk) | 悪い😞 | とても良い😊 | ローカル / CPU | 30言語以上 | 追加設定が必要 | 多くの言語に対応 | | [SOSV](https://k2-fsa.github.io/sherpa/onnx/sense-voice/index.html) | 普通😐 | 普通😐 | ローカル / CPU | 5言語 | 追加設定が必要 | 1つのモデルのみ | | 自分で開発 | 🤔 | 🤔 | カスタム | カスタム | カスタム | [ドキュメント](./docs/engine-manual/ja.md)に従ってPythonを使用して自分で開発 | -Gummyモデル以外を選択した場合、独自の翻訳モデルを設定する必要があります。 +Qwen LiveTranslate は翻訳機能を内蔵しています。他の字幕エンジンで翻訳を有効にする場合は、翻訳サービスの設定が必要です。 ### 翻訳モデルの設定 @@ -87,11 +86,11 @@ Gummyモデル以外を選択した場合、独自の翻訳モデルを設定す > 注意:パラメータ数が多すぎるモデルを使用すると、リソース消費と翻訳遅延が大きくなります。1B未満のパラメータ数のモデルを使用することを推奨します。例:`qwen2.5:0.5b`、`qwen3:0.6b`。 -このモデルを使用する前に、ローカルマシンに[Ollama](https://ollama.com/)ソフトウェアがインストールされており、必要な大規模言語モデルをダウンロード済みであることを確認してください。設定で呼び出す必要がある大規模モデル名を「モデル名」フィールドに入力し、「Base URL」フィールドが空であることを確認してください。 +このモデルを使用する前に、ローカルマシンに [Ollama](https://ollama.com/) がインストールされ、必要なモデルがダウンロード済みであることを確認してください。翻訳サービスで「Ollama ローカルモデル」を選択し、モデル名を入力します。Ollama はデフォルトのローカルサービスアドレスを使用します。 #### OpenAI互換モデル -ローカルのOllamaモデルの翻訳効果が良くないと感じる場合や、ローカルにOllamaモデルをインストールしたくない場合は、クラウド上のOpenAI互換モデルを使用できます。 +ローカルの Ollama モデルの翻訳品質に満足できない場合や、Ollama をローカルにインストールしたくない場合は、翻訳サービスで「OpenAI 互換モデル」を選択し、モデル名、Base URL、API Key を個別に入力します。 いくつかのモデルプロバイダの「Base URL」: - OpenAI: https://api.openai.com/v1 @@ -102,34 +101,41 @@ API Keyは対応するモデルプロバイダから取得する必要があり #### Google翻訳API -> 注意:Google翻訳APIは一部の地域では使用できません。 +> 注意:Google 翻訳 API は、国際インターネットにアクセスできない地域では使用できません。 設定不要で、ネット接続があれば使用できます。 -### Gummyモデルの使用 +## ⚙️ 内蔵の音声認識モデル + +現在、ソフトウェアには5つの音声認識モデルが搭載されています。それぞれの使い方と説明は以下の通りです。 + +#### Qwen LiveTranslate の使用 + +Qwen LiveTranslate は `qwen3.8-livetranslate-flash-realtime` を使用し、1つの WebSocket セッションで原文と訳文をストリーミング出力します。外部翻訳サービスは不要です。デフォルトは Paraformer のままです。このエンジンを選択し、業務スペース ID とリージョン(北京・シンガポール)を設定し、詳細設定で同じ業務スペース・リージョンの API Key を入力してください。`DASHSCOPE_WORKSPACE_ID` と `DASHSCOPE_API_KEY` 環境変数も利用できます。入力言語は自動検出します。UI の翻訳先は中国語・英語・日本語・韓国語・ドイツ語・フランス語・ロシア語・スペイン語・イタリア語です。16kHz・16bit・モノラル PCM を送信し、文字のみを出力します。翻訳音声は再生しません。翻訳を無効にしても訳文を非表示にするだけで、サービスは翻訳を実行します。認識のみが必要な場合は Paraformer を選択してください。 -> 阿里云の国際版サービスにはGummyモデルが提供されていないため、現在中国以外のユーザーはGummy字幕エンジンを使用できない可能性があります。 +[API / WebSocket](https://help.aliyun.com/zh/model-studio/qwen3-8-livetranslate-flash-realtime) -デフォルトのGummy字幕エンジン(クラウドモデルを使用した音声認識と翻訳)を使用するには、まず阿里云百煉プラットフォームのAPI KEYを取得し、API KEYをソフトウェア設定に追加するか環境変数に設定する必要があります(Windowsプラットフォームのみ環境変数からのAPI KEY読み取りをサポート)。関連チュートリアル: +#### Paraformer Realtime v2 の使用 -- [API KEYの取得](https://help.aliyun.com/zh/model-studio/get-api-key) -- [環境変数へのAPI Keyの設定](https://help.aliyun.com/zh/model-studio/configure-api-key-through-environment-variables) +デフォルトエンジンはアリババクラウドの `paraformer-realtime-v2` です。DashScope Recognition SDK の双方向ストリーミング認識を使用し、16kHz・16bit・モノラル PCM を送信します。中国語、英語、日本語、韓国語、ドイツ語、フランス語、ロシア語、広東語に対応し、`auto` は言語ヒントを指定しません。内蔵翻訳はありません。翻訳を有効にする場合は Ollama、OpenAI 互換サービス、Google 翻訳を設定してください。確定した文を非同期で翻訳します。設定または全プラットフォームで利用できる `DASHSCOPE_API_KEY` 環境変数に API Key を指定します。CLI は `--dashscope_api_key` を使用します。現在は北京リージョンのデフォルト接続先を使用するため、同リージョンの API Key が必要です。旧 Gummy の選択と API Key は自動移行し、スペイン語・イタリア語の入力言語は自動検出に変更します。旧カスタムコマンドは手動で更新してください。[公式 SDK ドキュメント](https://help.aliyun.com/zh/model-studio/paraformer-real-time-speech-recognition-python-sdk)を参照してください。 -### GLM-ASR モデルの使用 +#### GLM-ASR モデルの使用 使用前に、Zhipu AI プラットフォームから API キーを取得し、それをソフトウェアの設定に追加する必要があります。 -API キーの取得についてはこちらをご覧ください:[クイックスタート](https://docs.bigmodel.cn/ja/guide/start/quick-start)。 +API キーの取得についてはこちらをご覧ください:[クイックスタート](https://docs.bigmodel.cn/cn/guide/start/quick-start)。 -### Voskモデルの使用 +#### Vosk モデルの使用 -> Voskモデルの認識効果は不良のため、注意して使用してください。 +[vosk-api](https://github.com/alphacep/vosk-api) をベースに開発されています。この字幕エンジンは選択可能な言語モデルが非常に多い(30言語以上)一方、認識精度が比較的低く、生成されるテキストに句読点が含まれません。 Voskローカル字幕エンジンを使用するには、まず[Vosk Models](https://alphacephei.com/vosk/models)ページから必要なモデルをダウンロードし、ローカルにモデルを解凍し、モデルフォルダのパスをソフトウェア設定に追加してください。 ![](./assets/media/config_ja.png) -### SOSVモデルの使用 +#### SOSV モデルの使用 + +SOSV は統合モデルパッケージで、主に [Shepra-ONNX SenseVoice](https://k2-fsa.github.io/sherpa/onnx/sense-voice/index.html) をベースに、エンドポイント検出モデルと句読点復元モデルを追加しています。英語、中国語、日本語、韓国語、広東語の認識に対応しています。 SOSVモデルの使用方法はVoskと同じで、ダウンロードアドレスは以下の通りです:https://github.com/HiMeditator/auto-caption/releases/tag/sosv-model @@ -142,12 +148,16 @@ SOSVモデルの使用方法はVoskと同じで、ダウンロードアドレス 1. プロジェクトの字幕エンジン部分のソースコードを使用し、必要なライブラリがインストールされたPython環境で実行する 2. パッケージ化された字幕エンジンの実行可能ファイルをターミナルから実行する -実行引数および詳細な使用方法については、[User Manual](./docs/user-manual/en.md#using-caption-engine-standalone)をご参照ください。 +ソースから実行する場合は uv をインストールし、`engine` ディレクトリで `uv sync` を実行してください。以下の例もこのディレクトリで実行します。配布用実行ファイルには uv は不要です。 + +実行引数および詳細な使用方法については、[ユーザーマニュアル](./docs/user-manual/ja.md#ソースコードでエンジンを実行する)をご参照ください。 ```bash -python main.py \ --e gummy \ --k sk-******************************** \ +uv run python main.py \ +-e paraformer \ +--dashscope_api_key sk-******************************** \ +--translation_provider ollama \ +--translation_model qwen2.5:0.5b \ -a 0 \ -d 1 \ -s en \ @@ -156,47 +166,6 @@ python main.py \ ![](./docs/img/07.png) -## ⚙️ 字幕エンジン説明 - -現在、ソフトウェアには4つの字幕エンジンが搭載されており、新しいエンジンが計画されています。それらの詳細情報は以下の通りです。 - -### Gummy 字幕エンジン(クラウド) - -Tongyi Lab の [Gummy 音声翻訳大規模モデル](https://help.aliyun.com/zh/model-studio/gummy-speech-recognition-translation/)をベースに開発され、[Alibaba Cloud Bailian](https://bailian.console.aliyun.com) の APIを使用してこのクラウドモデルを呼び出します。 - -**モデル詳細パラメータ:** - -- サポートするオーディオサンプルレート:16kHz以上 -- オーディオサンプルビット深度:16bit -- サポートするオーディオチャンネル:モノラル -- 認識可能な言語:中国語、英語、日本語、韓国語、ドイツ語、フランス語、ロシア語、イタリア語、スペイン語 -- サポートする翻訳: - - 中国語 → 英語、日本語、韓国語 - - 英語 → 中国語、日本語、韓国語 - - 日本語、韓国語、ドイツ語、フランス語、ロシア語、イタリア語、スペイン語 → 中国語または英語 - -**ネットワークトラフィック消費量:** - -字幕エンジンはネイティブサンプルレート(48kHz と仮定)でサンプリングを行い、サンプルビット深度は 16bit、アップロードオーディオはモノラルチャンネルのため、アップロードレートは約: - -$$ -48000\ \text{samples/second} \times 2\ \text{bytes/sample} \times 1\ \text{channel} = 93.75\ \text{KB/s} -$$ - -また、エンジンはオーディオストームを取得したときのみデータをアップロードするため、実際のアップロードレートはさらに小さくなる可能性があります。モデル結果の返信トラフィック消費量は小さく、ここでは考慮していません。 - -### GLM-ASR 字幕エンジン(クラウド) - -https://docs.bigmodel.cn/ja/guide/models/sound-and-video/glm-asr-2512 - -### Vosk字幕エンジン(ローカル) - -[vosk-api](https://github.com/alphacep/vosk-api)をベースに開発。この字幕エンジンの利点は選択可能な言語モデルが非常に多く(30言語以上)、欠点は認識効果が比較的悪く、生成内容に句読点がないことです。 - -### SOSV 字幕エンジン(ローカル) - -[SOSV](https://github.com/HiMeditator/auto-caption/releases/tag/sosv-model)は統合パッケージで、主に[Shepra-ONNX SenseVoice](https://k2-fsa.github.io/sherpa/onnx/sense-voice/index.html)をベースにし、エンドポイント検出モデルと句読点復元モデルを追加しています。このモデルが認識をサポートする言語は:英語、中国語、日本語、韓国語、広東語です。 - ## 🚀 プロジェクト実行 ![](./assets/media/structure_ja.png) @@ -207,56 +176,42 @@ https://docs.bigmodel.cn/ja/guide/models/sound-and-video/glm-asr-2512 npm install ``` -### 字幕エンジンの構築 +### 字幕エンジンの管理と構築 -まず `engine` フォルダに入り、以下のコマンドを実行して仮想環境を作成します(Python 3.10 以上が必要で、Python 3.12 が推奨されます): +まず [uv](https://docs.astral.sh/uv/getting-started/installation/) をインストールしてください。依存関係は `engine/pyproject.toml` で宣言します。`engine/.python-version` により既定の Python は 3.12 になります。新しくクローンした環境では依存関係を再解決するため、開発者やビルド間でバージョンが異なる場合があります。以下はリポジトリのルートで実行します: ```bash -cd ./engine -# ./engine フォルダ内 -python -m venv .venv -# または -python3 -m venv .venv +# プロジェクトの依存関係を同期 +npm run engine:sync +# テストを実行 +npm run engine:test +# 実行可能ファイルを構築 +npm run build:engine ``` -次に仮想環境をアクティブにします: - -```bash -# Windows -.venv/Scripts/activate -# Linux または macOS -source .venv/bin/activate -``` +Electron の開発版はこの環境の Python を直接実行します。配布版はパッケージ化されたエンジンを実行するため、ユーザーが uv や Python をインストールする必要はありません。 -次に依存関係をインストールします(このステップでは macOS と Linux でエラーが発生する可能性があります。通常はビルド失敗によるもので、エラーメッセージに基づいて対処する必要があります): +macOS の PyAudio には PortAudio が必要です(例:`brew install portaudio`)。Linux の音声取得には `pactl` と `parec`(Ubuntu では `pulseaudio-utils`)、および動作する PulseAudio または PipeWire の PulseAudio 互換サービスが必要です。その他の設定は[ユーザーマニュアル](./docs/user-manual/ja.md)を参照してください。 -```bash -pip install -r requirements.txt -``` +`build:engine` は uv が管理する環境で PyInstaller を実行し、単一実行ファイルを生成します。Windows は `engine/dist/main.exe`、macOS/Linux は `engine/dist/main` です。配布アプリ内の配置先は Windows が `resources/engine/main.exe`、macOS/Linux が `resources/engine/main` です。 -その後、`pyinstaller` を使用してプロジェクトをビルドします: +### プロジェクト実行 ```bash -pyinstaller ./main.spec -``` - -`main-vosk.spec` ファイル内の `vosk` ライブラリのパスが正しくない可能性があるため、実際の状況(Python 環境のバージョンに関連)に応じて設定する必要があります。 - -``` -# Windows -vosk_path = str(Path('./.venv/Lib/site-packages/vosk').resolve()) -# Linux または macOS -vosk_path = str(Path('./.venv/lib/python3.x/site-packages/vosk').resolve()) +npm run dev ``` -これでプロジェクトのビルドが完了し、`engine/dist` フォルダ内に対応する実行可能ファイルが確認できます。その後、次の操作に進むことができます。 +### コードチェック -### プロジェクト実行 +リポジトリのルートで実行します: ```bash -npm run dev +npm run lint +npm run typecheck ``` +ESLint はデスクトップ側の JavaScript、TypeScript、Vue のソースとビルド設定をチェックし、ビルド成果物、依存環境、モデル、実験用ディレクトリを除外します。Python エンジンは ESLint の対象外です。 + ### プロジェクト構築 ```bash @@ -267,3 +222,7 @@ npm run build:mac # Linux 用 npm run build:linux ``` + +これらの配布用コマンドは字幕エンジンを先に構築してからデスクトップアプリを構築します。対象の OS 上で実行してください。PyInstaller は別の OS 向けのクロスコンパイルに対応していません。 + +現在の GitHub Actions は Windows/Linux x64 のみを検証・配布し、macOS の自動ビルドは一時的に無効にしています。`main` 向けの PR ではコード検証、デスクトップアプリのビルド、テストを実行します。`main` へのプッシュまたはマージで `package.json` のバージョンが変わると、検証成功後に Windows の NSIS インストーラー(`.exe`)と Linux のアーカイブ(`.tar.gz`)を作成し、両方のアップロード完了後に GitHub Release を公開します。 diff --git a/assets/media/engine_en.png b/assets/media/engine_en.png index b52bfe8..e7f2978 100644 Binary files a/assets/media/engine_en.png and b/assets/media/engine_en.png differ diff --git a/assets/media/engine_ja.png b/assets/media/engine_ja.png index eec7220..f2c0e29 100644 Binary files a/assets/media/engine_ja.png and b/assets/media/engine_ja.png differ diff --git a/assets/media/engine_zh.png b/assets/media/engine_zh.png index b186d2a..0392a6f 100644 Binary files a/assets/media/engine_zh.png and b/assets/media/engine_zh.png differ diff --git a/assets/media/main_en.png b/assets/media/main_en.png index b9826bf..e93b9c7 100644 Binary files a/assets/media/main_en.png and b/assets/media/main_en.png differ diff --git a/assets/media/main_ja.png b/assets/media/main_ja.png index dfb216e..da3f33e 100644 Binary files a/assets/media/main_ja.png and b/assets/media/main_ja.png differ diff --git a/assets/media/main_zh.png b/assets/media/main_zh.png index 7dfaa04..80bcaa5 100644 Binary files a/assets/media/main_zh.png and b/assets/media/main_zh.png differ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c7ab0a9..dccc1a6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,3 +1,32 @@ +# 更新日志 + +> 旧版本(v1.2.0 之前的版本)软件为人工开发。 +> 新版本(v1.2.0 以及之后的版本)主要使用 Agent 进行项目的开发与维护。 + +## v1.2.0 - 2026/10/14 + +### 开发维护 + +- 同步中英日说明文档,新增字幕引擎独立使用与构建文档 +- 完善代码检查,修复 ESLint 配置并兼容 TypeScript 6 +- 字幕引擎改用 uv 管理依赖,统一依赖同步、离线测试和构建命令 +- 新增 Windows、macOS、Linux 自动测试和发布流程,版本变更时自动构建安装包并发布 GitHub Release + +### 引擎更新 + +- 新增 Qwen LiveTranslate 3.8 实时引擎,支持内置流式翻译、业务空间和地域设置,并处理原文与译文交错返回 +- 移除已下架的 Gummy 引擎,改为阿里云 Paraformer Realtime v2,支持实时识别及外部翻译 +- 自动迁移旧 Gummy 引擎和 API Key 配置;CLI 密钥参数改为 `--dashscope_api_key` + +### 优化体验 + +- 为更多设置项添加鼠标悬浮说明 +- 优化用户界面(字幕引擎设置、项目关于等界面) +- 将自定义翻译服务拆分为 Ollama 本地模型和 OpenAI API 兼容模型,分别保存两类服务的配置 +- 重命名主进程与字幕引擎之间含义不准确的翻译参数,并为旧版应用配置提供自动迁移 + +# 旧版本更新日志 + ## v0.0.1 2025-06-22 @@ -176,6 +205,8 @@ ## v1.1.0 +2026-01-10 + ### 新增功能 - 添加基于 GLM-ASR 的字幕引擎 @@ -183,8 +214,9 @@ ## v1.1.1 +2026-01-31 + ### 优化体验 - 取消字幕窗口的顶置选项,字幕窗口将始终处于顶置状态 - 将字幕窗口顶置选项改为鼠标穿透选项,当图钉图标为实心时,表示启用鼠标穿透 - diff --git a/docs/TODO.md b/docs/TODO.md index 07fd219..2f3b351 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,30 +1,44 @@ -## 已完成 +# 待办事项 -- [x] 添加英语和日语语言支持 *2025/07/04* -- [x] 添加暗色主题 *2025/07/04* -- [x] 优化长字幕显示效果 *2025/07/05* -- [x] 修复字幕引擎空置报错的问题 *2025/07/05* -- [x] 增强字幕窗口顶置优先级 *2025/07/07* -- [x] 添加对自带字幕引擎的详细规格说明 *2025/07/07* -- [x] 添加复制字幕到剪贴板功能 *2025/07/08* -- [x] 适配 macOS 平台 *2025/07/08* -- [x] 添加字幕文字描边 *2025/07/09* -- [x] 添加基于 Vosk 的字幕引擎 *2025/07/09* -- [x] 适配 Linux 平台 *2025/07/13* -- [x] 字幕窗口右上角图标改为竖向排布 *2025/07/14* -- [x] 可以调整字幕时间轴 *2025/07/14* -- [x] 可以导出 srt 格式的字幕记录 *2025/07/14* -- [x] 可以获取字幕引擎的系统资源消耗情况 *2025/07/15* -- [x] 添加字幕记录按时间降序排列选择 *2025/07/26* -- [x] 重构字幕引擎 *2025/07/28* -- [x] 优化前端界面提示消息 *2025/07/29* -- [x] 复制字幕记录可选择只复制最近的字幕记录 *2025/08/18* -- [x] 添加颜色主题设置 *2025/08/18* -- [x] 前端页面添加日志内容展示 *2025/08/19* -- [x] 添加 Ollama 模型用于本地字幕引擎的翻译 *2025/09/04* -- [x] 验证 / 添加基于 sherpa-onnx 的字幕引擎 *2025/09/06* -- [x] 添加 GLM-ASR 模型 *2026/01/10* +对应版本:v1.2.0 ## TODO -暂无 +- [ ] 优化使用大模型进行翻译的功能(自定义模型调用参数、添加调用策略) +- [ ] 添加只显示翻译内容的选项 +- [ ] 优化多行字幕的体验 + +## 已完成 + +- [X] 添加三平台 CI 和版本变更自动发布流程 *2026/10/14* +- [X] 将字幕引擎迁移到 uv,统一各平台引擎构建与安装包启动路径 *2026/10/14* +- [X] 移除过时的 gummy 模型,添加新的模型 *2026/10/13* +- [X] 优化用户界面,提升界面可读性 *2026/10/14* +- [X] 重构整个项目(优化项目结构、更改不合适的配置属性名称)*2026/09/03* + +## (旧版)已完成 + +- [X] 添加英语和日语语言支持 *2025/07/04* +- [X] 添加暗色主题 *2025/07/04* +- [X] 优化长字幕显示效果 *2025/07/05* +- [X] 修复字幕引擎空置报错的问题 *2025/07/05* +- [X] 增强字幕窗口顶置优先级 *2025/07/07* +- [X] 添加对自带字幕引擎的详细规格说明 *2025/07/07* +- [X] 添加复制字幕到剪贴板功能 *2025/07/08* +- [X] 适配 macOS 平台 *2025/07/08* +- [X] 添加字幕文字描边 *2025/07/09* +- [X] 添加基于 Vosk 的字幕引擎 *2025/07/09* +- [X] 适配 Linux 平台 *2025/07/13* +- [X] 字幕窗口右上角图标改为竖向排布 *2025/07/14* +- [X] 可以调整字幕时间轴 *2025/07/14* +- [X] 可以导出 srt 格式的字幕记录 *2025/07/14* +- [X] 可以获取字幕引擎的系统资源消耗情况 *2025/07/15* +- [X] 添加字幕记录按时间降序排列选择 *2025/07/26* +- [X] 重构字幕引擎 *2025/07/28* +- [X] 优化前端界面提示消息 *2025/07/29* +- [X] 复制字幕记录可选择只复制最近的字幕记录 *2025/08/18* +- [X] 添加颜色主题设置 *2025/08/18* +- [X] 前端页面添加日志内容展示 *2025/08/19* +- [X] 添加 Ollama 模型用于本地字幕引擎的翻译 *2025/09/04* +- [X] 验证 / 添加基于 sherpa-onnx 的字幕引擎 *2025/09/06* +- [X] 添加 GLM-ASR 模型 *2026/01/10* diff --git a/docs/api-docs/caption-engine.md b/docs/api-docs/caption-engine.md index 88cb100..83b11d9 100644 --- a/docs/api-docs/caption-engine.md +++ b/docs/api-docs/caption-engine.md @@ -1,144 +1,145 @@ -# caption engine api-doc +# 字幕引擎通信协议 -本文档主要介绍字幕引擎和 Electron 主进程进程的通信约定。 +本文档记录当前 Python 字幕引擎与 Electron 主进程的通信约定。 -## 原理说明 +实现入口:[主进程引擎管理](../../src/main/utils/CaptionEngine.ts)、[字幕记录处理](../../src/main/utils/AllConfig.ts)、[Python 引擎入口](../../engine/main.py)、[Socket 服务](../../engine/utils/server.py)、[输出工具](../../engine/utils/sysout.py) 和 [翻译工具](../../engine/utils/translation.py)。 -本项目的 Python 进程通过标准输出向 Electron 主进程发送数据。Python 进程标准输出 (`sys.stdout`) 的内容一定为一行一行的字符串。且每行字符串均可以解释为一个 JSON 对象。每个 JSON 对象一定有 `command` 参数。 +## 连接与生命周期 -Electron 主进程通过 TCP Socket 向 Python 进程发送数据。发送的数据均是转化为字符串的对象,对象格式一定为: +1. Electron 主进程通过 `child_process.spawn()` 启动引擎,并通过 `-p ` 传入随机选择的端口(1024–65535)。自定义引擎也会收到追加的 `-p` 参数。 +2. Python 引擎在 `localhost` 上启动 TCP Socket 服务,监听成功后通过标准输出发送 `connect` 消息。`connect.content` 不提供端口,主进程使用启动时传入的端口。 +3. 主进程收到 `connect` 后发起 Socket 连接,将状态设为 `running`,并向控制窗口推送配置和 `control.engine.started`。当前实现不等待 Socket 连接回调再更新状态。该消息只表示通信服务已开始监听,不保证模型初始化和音频采集均已完成。 +4. 关闭时,主进程通过 Socket 发送 `stop`,随后关闭连接并进入 `stopping` 状态;4 秒后仍处于该状态,则尝试强制终止引擎进程。 +5. 子进程触发 `close` 后,主进程清理进程和连接引用,将状态设为 `stopped`,并推送 `engineEnabled: false` 的配置和 `control.engine.stopped`。 -```js -{ - command: string, - content: string -} -``` +启动后若在 `Controls.startTimeoutSeconds` 指定的时间内仍处于 `starting` 状态、没有收到 `connect`,主进程报告启动超时并尝试强制终止引擎;默认等待 30 秒。Python 单独运行时 `--port` 默认为 0,此时不启动 Socket 服务。 + +## 标准输出与错误输出 -## 标准输出约定 +数据方向:字幕引擎进程 → Electron 主进程。 -> 数据传递方向:字幕引擎进程 => Electron 主进程 +协议消息通过 `sys.stdout` 发送,每行是一个完整 JSON 对象,以 `\n` 结束并及时 flush。每个对象都包含字符串字段 `command`。使用现有 `stdout()`、`stdout_cmd()` 或 `stdout_obj()` 工具发送协议消息,普通日志也应包装成相应的 JSON 消息。 -当 JSON 对象的 `command` 参数为下列值时,表示的对应的含义: +`sys.stderr` 可以输出普通文本;主进程将非空行记录为 ERROR 软件日志,当前不会仅因 stderr 输出而向界面发送错误通知或终止引擎。 + +当前主进程按每次 stdout `data` 事件的数据块进行换行分割,没有缓存跨块的半行 JSON。解析失败会记录 ERROR 日志,无法解析的片段不会重试;自定义引擎应避免输出不必要的大消息,但完整行仍可能被流拆分。这是当前接收实现的限制。 + +下面示例均为实际 JSON 消息;类型和字段语义另行说明。未知 `command` 会被主进程记录为 WARN 日志。 ### `connect` -```js -{ - command: "connect", - content: "" -} +```json +{ "command": "connect", "content": "" } ``` -字幕引擎 TCP Socket 服务已经准备好,命令 Electron 主进程连接字幕引擎 Socket 服务 +Socket 服务开始监听,要求主进程连接该服务。主进程忽略 `content`,使用启动参数中的端口。 ### `kill` -```js -{ - command: "connect", - content: "" -} +```json +{ "command": "kill", "content": "" } ``` -命令 Electron 主进程强制结束字幕引擎进程。 +请求主进程强制终止引擎进程;主进程在状态不是 `stopped` 时处理该请求。内置引擎可能在 Socket 监听失败或识别发生不可恢复错误时发送此消息。 ### `caption` -```js +```json { - command: "caption", - index: number, - time_s: string, - time_t: string, - text: string, - translation: string + "command": "caption", + "index": 1, + "time_s": "12:34:56.100", + "time_t": "12:34:57.250", + "text": "Hello world.", + "translation": "你好,世界。" } ``` -Python 端监听到的音频流转换为的字幕数据。 +| 字段 | 类型 | 含义 | +| ------------- | -------- | ----------------------------------------------------------------------------------------------- | +| `index` | `number` | 引擎的句子标识;同一句的连续识别结果沿用同一标识,新句使用新标识。不同引擎的初始值可能为 0 或 1 | +| `time_s` | `string` | 该句的起始标记,内置引擎使用本地时间 `HH:mm:ss.SSS`,同一句保持一致 | +| `time_t` | `string` | 本次字幕结果生成时的本地时间,格式为 `HH:mm:ss.SSS` | +| `text` | `string` | 当前识别文本,流式引擎可能反复发送尚未完成的文本 | +| `translation` | `string` | 当前翻译文本;没有翻译或异步翻译尚未返回时为空字符串 | + +主进程比较新消息的引擎 `index` 与最近一次识别的标识:记录非空且标识相同则替换最后一条,否则追加记录。保存后将记录的 `index` 改写为从 1 开始的记录序号,再通过 `both.captionLog.add` 或 `both.captionLog.upd` 发送给窗口。因此界面中的 `index` 不一定等于引擎传入的标识。 + +这些时间来自引擎本地时钟,不是模型返回的精确音频时间戳。当前协议没有单独的“句子最终完成”字段。 ### `translation` -```js +```json { - command: "translation", - time_s: string, - text: string, - translation: string + "command": "translation", + "time_s": "12:34:56.100", + "text": "Hello world.", + "translation": "你好,世界。" } ``` -语音识别的内容的翻译,可以根据起始时间确定对应的字幕。 +用于返回异步翻译结果。`time_s`、`text`、`translation` 均为字符串;`time_s` 必须与原始字幕完全一致。 + +主进程从后向前查找相同 `time_s` 的字幕,只更新其 `translation` 字段,再通过 `both.captionLog.upd` 发送完整记录。未找到则忽略消息;`text` 不参与匹配,也不会覆盖原始文本。翻译结果可以对应较早的记录,不限于最后一条。 + +Paraformer、Vosk、SOSV 和 GLM 引擎使用外部翻译服务时会发送此消息。Paraformer 在完整句子返回后触发翻译,识别片段通过相同 index 和 time_s 更新字幕。 ### `print` -```js -{ - command: "print", - content: string -} +```json +{ "command": "print", "content": "Audio recording..." } ``` -输出 Python 端打印的内容,不计入日志。 +`content` 为字符串,主进程仅使用 `console.log()` 输出,不加入软件日志列表。 ### `info` -```js -{ - command: "info", - content: string -} +```json +{ "command": "info", "content": "Paraformer recognizer started." } ``` -Python 端打印的提示信息,会计入日志。 +`content` 为字符串,主进程记录为 INFO 软件日志。 ### `warn` -```js -{ - command: "warn", - content: string -} +```json +{ "command": "warn", "content": "Translation request failed." } ``` -Python 端打印的警告信息,会计入日志。 +`content` 为字符串,主进程记录为 WARN 软件日志。 ### `error` -```js -{ - command: "error", - content: string -} +```json +{ "command": "error", "content": "GLM request failed." } ``` -Python 端打印的错误信息,该错误信息会在前端弹窗显示。 +`content` 为字符串,主进程记录为 ERROR 软件日志,并通过 `control.error.occurred` 向已存在的控制窗口发送错误通知。该消息本身不会自动终止引擎;需要强制终止时另发 `kill`。 ### `usage` -```js -{ - command: "usage", - content: string -} +```json +{ "command": "usage", "content": "60" } ``` -Gummy 字幕引擎结束时打印计费消耗信息。 +当前内置引擎不发送此消息;保留供自定义引擎使用。主进程将 content 原样记录到 INFO 日志,不计算费用。 -## TCP Socket +## TCP Socket 命令 -> 数据传递方向:Electron 主进程 => 字幕引擎进程 +数据方向:Electron 主进程 → 字幕引擎进程。 -当 JSON 对象的 `command` 参数为下列值时,表示的对应的含义: +主进程将 `{ command: string, content: string }` 使用 `JSON.stringify()` 序列化后调用 `Socket.write()`,不会追加换行。Python 服务使用 UTF-8 解码后解析 JSON;当前只处理 `stop` 命令。 ### `stop` -```js -{ - command: "stop", - content: "" -} +```json +{ "command": "stop", "content": "" } ``` -命令当前字幕引擎停止监听并结束任务。 \ No newline at end of file +要求引擎停止当前任务。Python 服务忽略 `content`,将 `shared_data.status` 设为 `stop` 并关闭客户端连接,由引擎和音频采集循环根据共享状态退出。客户端断开连接或接收、解码、JSON 解析发生异常时,也会将状态设为 `stop`;通信异常会输出到 stderr。 + +当前服务直接对单次 `recv(4096)` 的结果调用 `json.loads()`,没有消息长度或分隔符,也没有处理跨次接收的拆包和一次接收中的多条消息。现有业务只发送单个小型停止命令,但 TCP 不保证消息与接收数据块一一对应,即使单条命令也可能被拆分;扩展为连续命令时需先完善双方的消息分帧逻辑。 + +## Qwen LiveTranslate 消息适配 + +`caption` 可包含可选布尔字段 `update_by_time`(默认 false)。LiveTranslate 发送 true,主进程按 `time_s` 更新已有记录,即使下一句已经开始;未匹配则追加。该标记不改变 IPC 通道。原文片段累加、最终原文覆盖,通过同一消息项保留 index 和 time_s;译文片段和完整译文通过 `translation` 按 time_s 更新。服务端 `conversation.item.created.previous_item_id` 用于关联原文和译文,译文先到时缓存。关闭翻译后不发送译文。停止时发送 `session.finish`,最多等待 2.5 秒接收 `session.finished`,超时记录警告并关闭连接,最后一段结果可能不完整。 diff --git a/docs/api-docs/electron-ipc.md b/docs/api-docs/electron-ipc.md index 64c5d91..6220196 100644 --- a/docs/api-docs/electron-ipc.md +++ b/docs/api-docs/electron-ipc.md @@ -1,26 +1,48 @@ -# electron ipc api-doc +# Electron IPC 通信协议 -本文档主要记录主进程和渲染进程的通信约定。 +本文档记录当前 Electron 主进程和两个渲染窗口(控制窗口、字幕窗口)的通信约定。 + +实现入口:[控制窗口](../../src/main/ControlWindow.ts)、[字幕窗口](../../src/main/CaptionWindow.ts)、[配置与字幕记录](../../src/main/utils/AllConfig.ts)、[引擎管理](../../src/main/utils/CaptionEngine.ts)、[日志](../../src/main/utils/Log.ts)。 + +## 调用方式和类型 + +- 请求与返回:渲染进程使用 `window.electron.ipcRenderer.invoke(channel, ...args)`,主进程使用 `ipcMain.handle`,返回值通过 Promise 获取。 +- 渲染进程发送事件:使用 `window.electron.ipcRenderer.send(channel, ...args)`,主进程使用 `ipcMain.on`;这些通道没有直接返回值。 +- 主进程推送事件:使用目标窗口的 `webContents.send(channel, ...args)`,渲染进程使用 `window.electron.ipcRenderer.on(channel, (_, payload) => ...)` 接收。回调第一个参数是事件对象,第二个参数才是本文描述的数据。 + +两个窗口都会通过 `App.vue` 初始化共用的 `generalSetting` store,因此主题查询及语言、主题、主题色、侧栏宽度的变更消息均可能由任一窗口发出(包括配置初始化触发的 watcher)。其余通道按当前界面的主要调用位置说明发起方;主进程处理器没有按来源窗口限制这些通道。 + +类型定义见 [主进程类型](../../src/main/types/index.ts) 和 [渲染进程类型](../../src/renderer/src/types/index.ts): + +- `UILanguage`:`'zh' | 'en' | 'ja'`。 +- `UITheme`:`'light' | 'dark' | 'system'`;实际应用的主题为 `'light' | 'dark'`。 +- `FullConfig`:包含 `platform`、`uiLanguage`、`uiTheme`、`uiColor`、`leftBarWidth`、`styles`、`controls`、`captionLog` 和 `softwareLog`;不包含持久化的字幕窗口宽度。 +- `Controls`:字幕引擎和翻译配置,当前包括 `translationProvider`、`ollamaModel`、`openaiModel`、`openaiBaseUrl`、`openaiApiKey`、`dashscopeApiKey` 和 `startTimeoutSeconds` 等字段。`engineEnabled` 由主进程维护,配置变更不会覆盖该状态。 +- `Styles`:字幕及翻译的显示样式;字段以源码类型定义为准。 +- `CaptionItem`:`index`、`time_s`、`time_t`、`text`、`translation`;主进程发送给界面的 `index` 是从 1 开始的记录序号。渲染进程按 `time_s` 匹配更新记录。 +- `SoftwareLogItem`:`type: 'INFO' | 'WARN' | 'ERROR'`、`index: number`、`time: string`、`text: string`。 +- `EngineInfo`:`pid`、`ppid`、`port`、`cpu`、`mem`、`elapsed` 均为 `number`;CPU 为百分比,内存为字节,已运行时间为毫秒。 ## 命名方式 本项目渲染进程包含两个:字幕窗口和控制窗口,主进程需要分别和两者进行通信。通信命令的命名规则如下: 1. 命令一般由三个关键字组成,由点号隔开。 -2. 第一个关键字表示通信发送目标: - - `config` 表示控制窗口类实例(后端)或控制窗口(前端) - - `engine` 表示字幕窗口类实例(后端)或字幕窗口(前端) - - `both` 表示上述对象都有可能成为目标 +2. 第一个关键字通常表示关联的窗口或范围: + - `control` 表示控制窗口相关操作或事件 + - `caption` 表示字幕窗口相关操作或事件 + - `both` 表示两个窗口共用的通道 + - 前缀不保证实际接收窗口,例如 `control.uiLanguage.set` 当前发送给字幕窗口;接收方以各通道说明为准 3. 第二个关键字表示需要修改的对象 / 发生改变的对象,采用小驼峰命名 4. 第三个关键字一般是动词,表示通信发生时对应动作 / 需要进行的操作 根据上面的描述可以看出通信命令一般有两种语义,一种表示要求执行的操作,另一种表示当前发生的事件。 -## 前端 <=> 后端 +## 请求与返回(`invoke` / `handle`) ### `both.window.mounted` -**介绍:** 前端窗口挂载完毕,请求最新的配置数据 +**介绍:** 前端窗口挂载完毕,请求完整配置、字幕记录及积压的软件日志。调用会将控制窗口的 `mounted` 标记设为 `true`,并取出、清空日志队列;`softwareLog` 不是完整的历史日志。 **发起方:** 前端 @@ -33,20 +55,20 @@ ### `control.nativeTheme.get` -**介绍:** 前端获取系统当前的主题 +**介绍:** 获取当前应使用的主题。配置为 `system` 时按系统主题返回,否则返回配置的 `light` 或 `dark`。 -**发起方:** 前端控制窗口 +**发起方:** 前端控制窗口或字幕窗口(共用 `generalSetting` store) **接收方:** 后端控制窗口实例 **数据类型:** - 发送:无数据 -- 接收:`string` +- 接收:`'light' | 'dark'` ### `control.folder.select` -**介绍:** 打开文件夹选择器,并将用户选择的文件夹路径返回给前端 +**介绍:** 打开文件夹选择器,并返回所选文件夹路径;取消选择时返回空字符串 `''`。 **发起方:** 前端控制窗口 @@ -59,7 +81,7 @@ ### `control.engine.info` -**介绍:** 获取字幕引擎的资源消耗情况 +**介绍:** 获取字幕引擎进程及资源信息。引擎状态不是 `running` 时,返回所有字段均为 0 的 `EngineInfo`。 **发起方:** 前端控制窗口 @@ -70,13 +92,13 @@ - 发送:无数据 - 接收:`EngineInfo` -## 前端 ==> 后端 +## 渲染进程发送事件(`send` / `on`) ### `control.uiLanguage.change` -**介绍:** 前端修改字界面语言,将修改同步给后端 +**介绍:** 前端修改界面语言,将修改同步给主进程;字幕窗口存在时,通过 `control.uiLanguage.set` 同步给字幕窗口。 -**发起方:** 前端控制窗口 +**发起方:** 前端控制窗口或字幕窗口(共用 `generalSetting` store) **接收方:** 后端控制窗口实例 @@ -86,7 +108,7 @@ **介绍:** 前端修改界面主题,将修改同步给后端 -**发起方:** 前端控制窗口 +**发起方:** 前端控制窗口或字幕窗口(共用 `generalSetting` store) **接收方:** 后端控制窗口实例 @@ -96,7 +118,7 @@ **介绍:** 前端修改界面主题颜色,将修改同步给后端 -**发起方:** 前端控制窗口 +**发起方:** 前端控制窗口或字幕窗口(共用 `generalSetting` store) **接收方:** 后端控制窗口实例 @@ -106,7 +128,7 @@ **介绍:** 前端修改边栏宽度,将修改同步给后端 -**发起方:** 前端控制窗口 +**发起方:** 前端控制窗口或字幕窗口(共用 `generalSetting` store) **接收方:** 后端控制窗口实例 @@ -114,7 +136,7 @@ ### `control.captionLog.clear` -**介绍:** 清空字幕记录 +**介绍:** 清空主进程中的字幕记录。发起方的 Pinia store 同时清空自己的记录;当前主进程不会向其他窗口广播清空事件。 **发起方:** 前端控制窗口 @@ -124,7 +146,7 @@ ### `control.styles.change` -**介绍:** 前端修改字幕样式,将修改同步给后端 +**介绍:** 前端修改字幕样式,将修改同步给主进程,并通过 `both.styles.set` 同步给已存在的字幕窗口。 **发起方:** 前端控制窗口 @@ -134,7 +156,7 @@ ### `control.styles.reset` -**介绍:** 将字幕样式恢复为默认 +**介绍:** 将字幕样式恢复为默认,并通过 `both.styles.set` 同步给已存在的控制窗口和字幕窗口。 **发起方:** 前端控制窗口 @@ -144,7 +166,7 @@ ### `control.controls.change` -**介绍:** 前端修改了字幕引擎配置,将最新配置发送给后端 +**介绍:** 前端修改字幕引擎配置,将最新配置发送给主进程。主进程只更新已知配置字段,保留自身维护的 `engineEnabled` 状态;此操作不会立即广播 `control.controls.set`。 **发起方:** 前端控制窗口 @@ -154,7 +176,7 @@ ### `control.captionWindow.activate` -**介绍:** 激活字幕窗口 +**介绍:** 显示字幕窗口;窗口不存在时创建。 **发起方:** 前端控制窗口 @@ -174,7 +196,7 @@ ### `control.engine.stop` -**介绍:** 关闭字幕引擎 +**介绍:** 请求关闭字幕引擎,向引擎发送 Socket `stop` 命令并关闭连接;4 秒后仍处于停止过程中则尝试强制终止。 **发起方:** 前端控制窗口 @@ -184,7 +206,7 @@ ### `control.engine.forceKill` -**介绍:** 强制关闭启动超时的字幕引擎 +**介绍:** 强制终止当前引擎进程。界面在启动过程中提供此操作;主进程处理器本身不要求先发生启动超时。 **发起方:** 前端控制窗口 @@ -194,7 +216,7 @@ ### `caption.windowHeight.change` -**介绍:** 字幕窗口宽度发生改变 +**介绍:** 根据前端内容高度设置字幕窗口高度(像素),保留窗口宽度。 **发起方:** 前端字幕窗口 @@ -204,7 +226,7 @@ ### `caption.mouseEvents.ignore` -**介绍:** 是否设置鼠标穿透 +**介绍:** 设置字幕窗口是否忽略鼠标事件,`true` 启用鼠标穿透,`false` 关闭;同时将 `forward` 选项设为同一值。 **发起方:** 前端字幕窗口 @@ -214,7 +236,7 @@ ### `caption.controlWindow.activate` -**介绍:** 激活控制窗口 +**介绍:** 显示控制窗口;窗口不存在时创建。 **发起方:** 前端字幕窗口 @@ -232,7 +254,7 @@ **数据类型:** 无数据 -## 后端 ==> 前端 +## 主进程推送事件(`webContents.send` / `on`) ### `control.uiLanguage.set` @@ -246,17 +268,17 @@ ### `control.nativeTheme.change` -**介绍:** 系统主题发生改变 +**介绍:** 系统主题变化时,若应用主题配置为 `system`,向控制窗口发送最新的实际主题。 **发起方:** 后端 **接收方:** 前端控制窗口 -**数据类型:** `string` +**数据类型:** `'light' | 'dark'` ### `control.engine.started` -**介绍:** 引擎启动成功,参数为引擎的进程 ID +**介绍:** 主进程收到引擎 `connect` 消息并发起 Socket 连接后发送,参数为引擎进程 ID;当前实现发送时不等待 Socket 的连接回调。此前会先推送 `control.controls.set`,其中 `engineEnabled` 为 `true`。 **发起方:** 后端 @@ -266,7 +288,7 @@ ### `control.engine.stopped` -**介绍:** 引擎关闭 +**介绍:** 引擎子进程触发 `close` 后发送。此前会先推送 `control.controls.set`,其中 `engineEnabled` 为 `false`。 **发起方:** 后端 @@ -302,7 +324,9 @@ **接收方:** 前端控制窗口 -**数据类型:** `SoftwareLog` +**数据类型:** `SoftwareLogItem` + +控制窗口已经挂载时即时推送;否则日志进入队列,在下一次 `both.window.mounted` 的返回值中提供。 ### `both.styles.set` @@ -326,7 +350,7 @@ ### `both.captionLog.upd` -**介绍:** 更新最后一条字幕数据 +**介绍:** 更新一条已有字幕数据,可以是当前识别中的最后一条,也可以是异步翻译完成的较早记录。渲染进程从后向前查找相同 `time_s` 的记录并替换;未找到则不修改。 **发起方:** 后端 @@ -336,10 +360,14 @@ ### `both.captionLog.set` -**介绍:** 设置全部的字幕数据 +**介绍:** 替换全部字幕数据。当前主进程保留了发送分支,渲染进程也注册了监听,但现有业务流程没有调用该分支;初始化记录来自 `both.window.mounted` 返回的 `FullConfig.captionLog`。 **发起方:** 后端 **接收方:** 前端 **数据类型:** `CaptionItem[]` + +旧配置的 `engine: "gummy"` 自动迁移为 `"paraformer"`,`gummyApiKey`(或更早的 `API_KEY`)迁移为 `dashscopeApiKey`。新的显式密钥优先。Paraformer 使用外部翻译服务,与其他引擎共享翻译配置。 + +LiveTranslate 配置:`engine: "livetranslate"`,`dashscopeWorkspaceId: string`(默认空),`dashscopeRegion: "cn-beijing" | "ap-southeast-1"`(默认北京)。与 Paraformer 共用 `dashscopeApiKey`。使用内置翻译,不使用外部翻译配置;源语言自动检测。 diff --git a/docs/engine-manual/en.md b/docs/engine-manual/en.md index f03ca46..dcf7afd 100644 --- a/docs/engine-manual/en.md +++ b/docs/engine-manual/en.md @@ -1,6 +1,6 @@ # Caption Engine Documentation -Corresponding version: v1.0.0 +Corresponding Version: v1.2.0 ![](../../assets/media/structure_en.png) @@ -29,7 +29,7 @@ Process of communication between main process and caption engine: - Electron main process: When the user operates to close the caption engine in the frontend, the main process sends an object string with `command` field set to `stop` to the caption engine process through Socket communication - Caption engine process: Receive the caption data object string sent by the main engine process, parse the string into an object. If the object's `command` field is `stop`, set the value of global variable `shared_data.status` to `stop` -- Caption engine process: Main thread continuously monitors system audio output, when `thread_data.status` value is not `running`, end the loop, release resources, and terminate execution +- Caption engine process: Main thread continuously monitors system audio output, when `shared_data.status` value is not `running`, end the loop, release resources, and terminate execution - Electron main process: If the caption engine process termination is detected, perform corresponding processing and provide feedback to the frontend ## Implemented Features @@ -46,8 +46,8 @@ Examples: from utils import stdout, stdout_cmd, stdout_obj, stderr # {"command": "print", "content": "Hello"}\n stdout("Hello") -# {"command": "connect", "content": "8080"}\n -stdout_cmd("connect", "8080") +# {"command": "connect", "content": ""}\n +stdout_cmd("connect") # {"command": "print", "content": "print"}\n stdout_obj({"command": "print", "content": "print"}) # sys.stderr.write("Error Info" + "\n") @@ -61,13 +61,14 @@ This Socket service listens on a specified port, parses content sent by the Elec Example: ```python -from utils import start_server -from utils import shared_data +import threading +import time +from utils import start_server, shared_data + port = 8080 -start_server(port) -while thread_data == 'running': - # do something - pass +threading.Thread(target=start_server, args=(port,), daemon=True).start() +while shared_data.status == 'running': + time.sleep(0.1) ``` ### Audio Acquisition @@ -88,15 +89,18 @@ Example: ```python from sysaudio import AudioStream -audio_type = 0 -chunk_rate = 20 -stream = AudioStream(audio_type, chunk_rate) +from utils import shared_data + +stream = AudioStream(0, 20) stream.open_stream() -while True: - data = stream.read_chunk() - # do something with data - pass -stream.close_stream() +try: + while shared_data.status == 'running': + data = stream.read_chunk() + if data is None: + continue + # Process the audio chunk here. +finally: + stream.close_stream() ``` ### Audio Processing @@ -110,12 +114,19 @@ Example: ```python from sysaudio import AudioStream -from utils import merge_chunk_channels -stream = AudioStream(1) -while True: - raw_chunk = stream.read_chunk() - chunk = resample_chunk_mono(raw_chunk, stream.CHANNELS, stream.RATE, 16000) - # do something with chunk +from utils import resample_chunk_mono, shared_data + +stream = AudioStream(1) +stream.open_stream() +try: + while shared_data.status == 'running': + raw_chunk = stream.read_chunk() + if raw_chunk is None: + continue + chunk = resample_chunk_mono(raw_chunk, stream.CHANNELS, stream.RATE, 16000) + # Send the mono PCM chunk to the recognizer here. +finally: + stream.close_stream() ``` ## Features to be Implemented by the Caption Engine @@ -133,7 +144,9 @@ It is recommended to encapsulate this as a class, implementing four methods: Complete caption engine examples: -- [gummy.py](../../engine/audio2text/gummy.py) +- [paraformer.py](../../engine/audio2text/paraformer.py) +- [livetranslate.py](../../engine/audio2text/livetranslate.py) +- [glm.py](../../engine/audio2text/glm.py) - [vosk.py](../../engine/audio2text/vosk.py) - [sosv.py](../../engine/audio2text/sosv.py) @@ -144,10 +157,11 @@ Some speech-to-text models do not provide translation. If needed, an additional Example: ```python -from utils import google_translate, ollama_translate +from utils import google_translate, ollama_translate, openai_translate text = "This is a translation test." google_translate("", "en", text, "time_s") ollama_translate("qwen3:0.6b", "en", text, "time_s") +openai_translate("gpt-4.1-mini", "en", text, "time_s", "https://api.openai.com/v1", "") ``` ### Caption Data Transmission @@ -174,33 +188,46 @@ export interface CaptionItem { Custom caption engine settings provide command line parameter specification, so the caption engine parameters need to be set properly. Currently used parameters in this project are as follows: ```python +import argparse + if __name__ == "__main__": parser = argparse.ArgumentParser(description='Convert system audio stream to text') # 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=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') - parser.add_argument('-r', '--record', default=0, help='Whether to record the audio, 0 for no recording, 1 for recording') + parser.add_argument('-e', '--caption_engine', default='paraformer', choices=['paraformer', 'livetranslate', 'glm', 'vosk', 'sosv'], help='Caption engine: paraformer, livetranslate, glm, vosk or sosv') + parser.add_argument('-a', '--audio_type', type=int, default=0, help='Audio stream source: 0 for output, 1 for input') + parser.add_argument('-c', '--chunk_rate', type=int, default=10, help='Number of audio stream chunks collected per second') + parser.add_argument('-p', '--port', type=int, default=0, help='The port to run the server on, 0 for no server') + parser.add_argument('-d', '--display_caption', type=int, default=0, help='Display caption on terminal, 0 for no display, 1 for display') + parser.add_argument('-t', '--target_language', default='none', help='Target language code, "none" for no translation') + parser.add_argument('-r', '--record', type=int, default=0, help='Whether to record the audio, 0 for no recording, 1 for recording') parser.add_argument('-rp', '--record_path', default='', help='Path to save the recorded audio') - # gummy and sosv + # paraformer and sosv and glm parser.add_argument('-s', '--source_language', default='auto', help='Source language code') - # gummy only - 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') + # Alibaba Cloud engines + parser.add_argument('--dashscope_api_key', default='', help='API key for Alibaba Cloud caption engines') + parser.add_argument('--dashscope_workspace_id', default='', help='LiveTranslate workspace ID (or DASHSCOPE_WORKSPACE_ID)') + parser.add_argument('--dashscope_region', choices=['cn-beijing', 'ap-southeast-1'], default='cn-beijing', help='LiveTranslate API region') + # External translation: Paraformer, GLM, Vosk and SOSV + parser.add_argument('--translation_provider', choices=['ollama', 'openai', 'google'], default='ollama', help='Translation service provider') + parser.add_argument('--translation_model', default='', help='Translation model name (for Ollama or OpenAI-compatible services)') + parser.add_argument('--translation_base_url', default='', help='OpenAI-compatible API base URL') + parser.add_argument('--translation_api_key', default='', help='OpenAI-compatible API key') # vosk only 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') + # glm only + parser.add_argument('-gurl', '--glm_url', default='https://open.bigmodel.cn/api/paas/v4/audio/transcriptions', help='GLM API URL') + parser.add_argument('-gmodel', '--glm_model', default='glm-asr-2512', help='GLM Model Name') + parser.add_argument('-gkey', '--glm_api_key', default='', help='GLM API Key') + + args = parser.parse_args() ``` -For example, for this project's caption engine, if I want to use the Gummy model, specify the original text as Japanese, translate to Chinese, and capture captions from system audio output, with 0.1s audio data segments each time, the command line parameters would be: +For example, for this project's caption engine, if I want to use the Paraformer model, specify the original text as Japanese, translate to Chinese, and capture captions from system audio output, with 0.1s audio data segments each time, the command line parameters would be: ```bash -python main.py -e gummy -s ja -t zh -a 0 -c 10 -k +uv run python main.py -e paraformer -s ja -t zh -a 0 -c 10 --dashscope_api_key --translation_provider ollama --translation_model qwen2.5:0.5b ``` ## Additional Notes @@ -218,14 +245,34 @@ python main.py -e gummy -s ja -t zh -a 0 -c 10 -k Except for audio-to-text conversion and translation, other components (audio acquisition, audio resampling, and communication with the main process) are recommended to directly reuse the project's code. If this approach is taken, the content that needs to be added includes: - `engine/audio2text/`: Add a new audio-to-text class (file-level). -- `engine/main.py`: Add new parameter settings and workflow functions (refer to `main_gummy` and `main_vosk` functions). +- `engine/main.py`: Add new parameter settings and workflow functions (refer to `main_paraformer` and `main_vosk` functions). ### Packaging -After development and testing, the caption engine must be packaged into an executable. Typically, `pyinstaller` is used. If the packaged executable reports errors, check for missing dependencies. +The engine uses uv: `engine/pyproject.toml` declares runtime dependencies and the PyInstaller development dependency, `engine/uv.lock` is generated locally and ignored by Git, and Python 3.12 is selected by default. Run from the repository root: + +```bash +npm run engine:sync +npm run engine:test +npm run build:engine +``` + +Alternatively, run `uv run pyinstaller --noconfirm --clean main.spec` from `engine`. The Vosk package path is discovered from the active environment. PyInstaller produces a single file, `engine/dist/main.exe` on Windows or `engine/dist/main` on macOS/Linux. Installers copy it to `engine/main.exe` or `engine/main` inside the resources directory, matching the main process launch path. Installer commands build the engine first and must run on the target OS. See the README for platform audio dependencies. Packaged apps do not require uv or Python. ### Execution With a functional caption engine, it can be launched in the caption software window by specifying the engine's path and runtime arguments. -![](../img/02_en.png) \ No newline at end of file +![](../img/02_en.png) + +The default engine is Alibaba Cloud `paraformer-realtime-v2`, using the DashScope Recognition SDK for duplex streaming ASR. Audio is uploaded as 16kHz, 16-bit mono PCM. Supported source languages are Chinese, English, Japanese, Korean, German, French, Russian and Cantonese; `auto` sends no language hint. There is no built-in translation: configure Ollama, an OpenAI-compatible service or Google Translate to translate finalized sentences asynchronously. Set the Alibaba Cloud API Key in settings or the `DASHSCOPE_API_KEY` environment variable on any platform; the CLI option is `--dashscope_api_key`. The current endpoint is the default Beijing endpoint and requires a matching regional API Key. Old Gummy selections and API Keys migrate automatically; old Spanish/Italian source selections switch to auto detection. Update old custom commands manually. See the [official SDK documentation](https://help.aliyun.com/zh/model-studio/paraformer-real-time-speech-recognition-python-sdk). + +## Qwen LiveTranslate + +Qwen LiveTranslate uses `qwen3.8-livetranslate-flash-realtime` to stream source transcripts and translated text in one WebSocket session, with no external translation service. Paraformer remains the default. Select this engine, enter the workspace ID and region (Beijing or Singapore), and enter an Alibaba Cloud API Key for that workspace and region in More Settings. Alternatively, use `DASHSCOPE_WORKSPACE_ID` and `DASHSCOPE_API_KEY`. The source language is detected automatically. The UI offers Chinese, English, Japanese, Korean, German, French, Russian, Spanish and Italian as target languages. Audio is uploaded as 16kHz, 16-bit mono PCM. Only text is returned; translated speech is not played. Disabling translation only hides the translation, while the service still translates. Use Paraformer for recognition only. + +[API / WebSocket](https://help.aliyun.com/zh/model-studio/qwen3-5-livetranslate-flash-realtime) + +```bash +uv run python main.py -e livetranslate -t zh --dashscope_api_key --dashscope_workspace_id --dashscope_region cn-beijing +``` diff --git a/docs/engine-manual/ja.md b/docs/engine-manual/ja.md index e69d624..e5d3160 100644 --- a/docs/engine-manual/ja.md +++ b/docs/engine-manual/ja.md @@ -1,10 +1,6 @@ # 字幕エンジン説明ドキュメント -## 注意:このドキュメントはメンテナンスが行われていないため、記載されている情報は古くなっています。最新の情報については、[中国語版](./zh.md)または[英語版](./en.md)のドキュメントをご参照ください。 - -対応バージョン:v0.6.0 - -この文書は大規模モデルを使用して翻訳されていますので、内容に正確でない部分があるかもしれません。 +対応バージョン:v1.2.0 ![](../../assets/media/structure_ja.png) @@ -32,8 +28,8 @@ ### エンジンの停止 - メインプロセス:ユーザーがフロントエンドで字幕エンジンを停止する操作を実行すると、メインプロセスはSocket通信を介して字幕エンジンプロセスに`command`フィールドが`stop`のオブジェクト文字列を送信 -- 字幕エンジンプロセス:メインエンジンプロセスから送信された字幕データオブジェクト文字列を受信し、文字列をオブジェクトとして解析。オブジェクトの`command`フィールドが`stop`の場合、グローバル変数`thread_data.status`の値を`stop`に設定 -- 字幕エンジンプロセス:メインスレッドでシステムオーディオ出力をループ監視し、`thread_data.status`の値が`running`でない場合、ループを終了し、リソースを解放して実行を終了 +- 字幕エンジンプロセス:メインエンジンプロセスから送信された字幕データオブジェクト文字列を受信し、文字列をオブジェクトとして解析。オブジェクトの`command`フィールドが`stop`の場合、グローバル変数`shared_data.status`の値を`stop`に設定 +- 字幕エンジンプロセス:メインスレッドでシステムオーディオ出力をループ監視し、`shared_data.status`の値が`running`でない場合、ループを終了し、リソースを解放して実行を終了 - メインプロセス:字幕エンジンプロセスの終了を検出した場合、対応する処理を実行し、フロントエンドにフィードバック ## プロジェクトで実装済みの機能 @@ -49,25 +45,26 @@ ```python from utils import stdout, stdout_cmd, stdout_obj, stderr stdout("Hello") # {"command": "print", "content": "Hello"}\n -stdout_cmd("connect", "8080") # {"command": "connect", "content": "8080"}\n +stdout_cmd("connect") # {"command": "connect", "content": ""}\n stdout_obj({"command": "print", "content": "Hello"}) stderr("Error Info") ``` ### Socketサービスの作成 -このSocketサービスは指定されたポートを監視し、Electronメインプログラムから送信された内容を解析し、`thread_data.status`の値を変更する可能性があります。 +このSocketサービスは指定されたポートを監視し、Electronメインプログラムから送信された内容を解析し、`shared_data.status`の値を変更する可能性があります。 サンプル: ```python -from utils import start_server -from utils import thread_data +import threading +import time +from utils import start_server, shared_data + port = 8080 -start_server(port) -while thread_data == 'running': - # 何か処理 - pass +threading.Thread(target=start_server, args=(port,), daemon=True).start() +while shared_data.status == 'running': + time.sleep(0.1) ``` ### オーディオ取得 @@ -77,34 +74,37 @@ while thread_data == 'running': - `audio_type`:取得するオーディオのタイプ。0はシステム出力オーディオ(スピーカー)、1はシステム入力オーディオ(マイク) - `chunk_rate`:オーディオデータの取得頻度。1秒あたりに取得するオーディオブロックの数 -このクラスには3つのメソッドがあります: +このクラスには次のメソッドがあります: - `open_stream()`:オーディオ取得を開始 - `read_chunk() -> bytes`:1つのオーディオブロックを読み取り - `close_stream()`:オーディオ取得を閉じる +- `close_stream_signal()`:スレッドから安全にオーディオ取得を終了 サンプル: ```python from sysaudio import AudioStream -audio_type = 0 -chunk_rate = 20 -stream = AudioStream(audio_type, chunk_rate) +from utils import shared_data + +stream = AudioStream(0, 20) stream.open_stream() -while True: - data = stream.read_chunk() - # データで何か処理 - pass -stream.close_stream() +try: + while shared_data.status == 'running': + data = stream.read_chunk() + if data is None: + continue + # Process the audio chunk here. +finally: + stream.close_stream() ``` ### オーディオ処理 -取得したオーディオストリームは、文字に変換する前に前処理が必要な場合があります。一般的に、マルチチャンネルオーディオをシングルチャンネルオーディオに変換し、リサンプリングが必要な場合もあります。このプロジェクトでは、3つのオーディオ処理関数を提供しています: +取得したオーディオストリームは、文字に変換する前に前処理が必要な場合があります。一般的に、マルチチャンネルオーディオをシングルチャンネルオーディオに変換し、リサンプリングが必要な場合もあります。このプロジェクトでは、次の2つのオーディオ処理関数を提供しています: - `merge_chunk_channels(chunk: bytes, channels: int) -> bytes`:マルチチャンネルオーディオブロックをシングルチャンネルオーディオブロックに変換 -- `resample_chunk_mono(chunk: bytes, channels: int, orig_sr: int, target_sr: int, mode="sinc_best") -> bytes`:現在のマルチチャンネルオーディオデータブロックをシングルチャンネルオーディオデータブロックに変換し、リサンプリングを実行 -- `resample_mono_chunk(chunk: bytes, orig_sr: int, target_sr: int, mode="sinc_best") -> bytes`:現在のシングルチャンネルオーディオブロックをリサンプリング +- `resample_chunk_mono(chunk: bytes, channels: int, orig_sr: int, target_sr: int) -> bytes`:現在のマルチチャンネルオーディオデータブロックをシングルチャンネルオーディオデータブロックに変換し、リサンプリングを実行 ## 字幕エンジンで実装が必要な機能 @@ -112,20 +112,32 @@ stream.close_stream() 適切なオーディオストリームを取得した後、オーディオストリームを文字に変換する必要があります。一般的に、さまざまなモデル(クラウドまたはローカル)を使用してオーディオストリームを文字に変換します。要件に応じて適切なモデルを選択する必要があります。 -この部分はクラスとしてカプセル化することをお勧めします。以下の3つのメソッドを実装する必要があります: +この部分はクラスとしてカプセル化することをお勧めします。以下の4つのメソッドを実装する必要があります: - `start(self)`:モデルを起動 - `send_audio_frame(self, data: bytes)`:現在のオーディオブロックデータを処理し、**生成された字幕データを標準出力を介してElectronメインプロセスに送信** +- `translate(self)`:`shared_data.chunk_queue` から音声チャンクを取得し、`send_audio_frame` に渡す - `stop(self)`:モデルを停止 完全な字幕エンジンの実例: -- [gummy.py](../../engine/audio2text/gummy.py) +- [paraformer.py](../../engine/audio2text/paraformer.py) +- [livetranslate.py](../../engine/audio2text/livetranslate.py) +- [glm.py](../../engine/audio2text/glm.py) - [vosk.py](../../engine/audio2text/vosk.py) +- [sosv.py](../../engine/audio2text/sosv.py) ### 字幕翻訳 -一部の音声文字変換モデルは翻訳を提供していません。必要がある場合、翻訳モジュールを追加する必要があります。 +一部の音声認識モデルは内蔵翻訳を提供していません。既存の翻訳関数を再利用できます。翻訳結果の time_s は元の字幕と一致させてください。 + +```python +from utils import google_translate, ollama_translate, openai_translate +text = "これは翻訳テストです。" +google_translate("", "en", text, "12:34:56.100") +ollama_translate("qwen3:0.6b", "en", text, "12:34:56.100") +openai_translate("gpt-4.1-mini", "en", text, "12:34:56.100", "https://api.openai.com/v1", "") +``` ### 字幕データの送信 @@ -154,25 +166,45 @@ export interface CaptionItem { ```python import argparse + if __name__ == "__main__": - parser = argparse.ArgumentParser(description='システムオーディオストリームをテキストに変換') - # 共通 - 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=10, help='1秒あたりに収集するオーディオストリームブロックの数') - parser.add_argument('-p', '--port', default=8080, help='サーバーを実行するポート、0はサーバーなし') - # gummy専用 - parser.add_argument('-s', '--source_language', default='en', help='ソース言語コード') - parser.add_argument('-t', '--target_language', default='zh', help='ターゲット言語コード') - parser.add_argument('-k', '--api_key', default='', help='GummyモデルのAPI KEY') - # vosk専用 - parser.add_argument('-m', '--model_path', default='', help='voskモデルのパス') + parser = argparse.ArgumentParser(description='Convert system audio stream to text') + # all + parser.add_argument('-e', '--caption_engine', default='paraformer', choices=['paraformer', 'livetranslate', 'glm', 'vosk', 'sosv'], help='Caption engine: paraformer, livetranslate, glm, vosk or sosv') + parser.add_argument('-a', '--audio_type', type=int, default=0, help='Audio stream source: 0 for output, 1 for input') + parser.add_argument('-c', '--chunk_rate', type=int, default=10, help='Number of audio stream chunks collected per second') + parser.add_argument('-p', '--port', type=int, default=0, help='The port to run the server on, 0 for no server') + parser.add_argument('-d', '--display_caption', type=int, default=0, help='Display caption on terminal, 0 for no display, 1 for display') + parser.add_argument('-t', '--target_language', default='none', help='Target language code, "none" for no translation') + parser.add_argument('-r', '--record', type=int, default=0, help='Whether to record the audio, 0 for no recording, 1 for recording') + parser.add_argument('-rp', '--record_path', default='', help='Path to save the recorded audio') + # paraformer and sosv and glm + parser.add_argument('-s', '--source_language', default='auto', help='Source language code') + # Alibaba Cloud engines + parser.add_argument('--dashscope_api_key', default='', help='API key for Alibaba Cloud caption engines') + parser.add_argument('--dashscope_workspace_id', default='', help='LiveTranslate workspace ID (or DASHSCOPE_WORKSPACE_ID)') + parser.add_argument('--dashscope_region', choices=['cn-beijing', 'ap-southeast-1'], default='cn-beijing', help='LiveTranslate API region') + # External translation: Paraformer, GLM, Vosk and SOSV + parser.add_argument('--translation_provider', choices=['ollama', 'openai', 'google'], default='ollama', help='Translation service provider') + parser.add_argument('--translation_model', default='', help='Translation model name (for Ollama or OpenAI-compatible services)') + parser.add_argument('--translation_base_url', default='', help='OpenAI-compatible API base URL') + parser.add_argument('--translation_api_key', default='', help='OpenAI-compatible API key') + # vosk only + 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') + # glm only + parser.add_argument('-gurl', '--glm_url', default='https://open.bigmodel.cn/api/paas/v4/audio/transcriptions', help='GLM API URL') + parser.add_argument('-gmodel', '--glm_model', default='glm-asr-2512', help='GLM Model Name') + parser.add_argument('-gkey', '--glm_api_key', default='', help='GLM API Key') + + args = parser.parse_args() ``` -たとえば、このプロジェクトの字幕エンジンでGummyモデルを使用し、原文を日本語、翻訳を中国語に指定し、システムオーディオ出力の字幕を取得し、毎回0.1秒のオーディオデータをキャプチャする場合、コマンドラインパラメータは以下のようになります: +たとえば、このプロジェクトの字幕エンジンでParaformerモデルを使用し、原文を日本語、翻訳を中国語に指定し、システムオーディオ出力の字幕を取得し、毎回0.1秒のオーディオデータをキャプチャする場合、コマンドラインパラメータは以下のようになります: ```bash -python main.py -e gummy -s ja -t zh -a 0 -c 10 -k +uv run python main.py -e paraformer -s ja -t zh -a 0 -c 10 --dashscope_api_key --translation_provider ollama --translation_model qwen2.5:0.5b ``` ## その他 @@ -190,14 +222,34 @@ python main.py -e gummy -s ja -t zh -a 0 -c 10 -k オーディオから文字への変換以外は、このプロジェクトのコードを直接再利用することをお勧めします。その場合、追加する必要がある内容は: - `engine/audio2text/`:新しいオーディオから文字への変換クラスを追加(ファイルレベル) -- `engine/main.py`:新しいパラメータ設定とプロセス関数を追加(`main_gummy`関数と`main_vosk`関数を参照) +- `engine/main.py`:新しいパラメータ設定とプロセス関数を追加(`main_paraformer`関数と`main_vosk`関数を参照) ### パッケージ化 -字幕エンジンの開発とテストが完了した後、字幕エンジンを実行可能ファイルにパッケージ化する必要があります。一般的に`pyinstaller`を使用してパッケージ化します。パッケージ化された字幕エンジンファイルの実行でエラーが発生した場合、依存ライブラリが不足している可能性があります。不足している依存ライブラリを確認してください。 +エンジンの依存関係は uv で管理します。`engine/pyproject.toml` は実行時依存と開発依存の PyInstaller、`engine/uv.lock` はローカルで自動生成し、Git の対象外とします。既定の Python は 3.12 です。リポジトリのルートで実行してください: + +```bash +npm run engine:sync +npm run engine:test +npm run build:engine +``` + +`engine` ディレクトリでは `uv run pyinstaller --noconfirm --clean main.spec` も使用できます。Vosk のパスは現在の環境から自動取得します。生成される単一実行ファイルは Windows が `engine/dist/main.exe`、macOS/Linux が `engine/dist/main` です。配布アプリのリソース内ではそれぞれ `engine/main.exe`、`engine/main` に配置し、メインプロセスの起動パスと一致させます。配布用コマンドはエンジンを先に構築するため、対象の OS 上で実行してください。音声取得のシステム依存は README を参照してください。配布版には uv や Python のインストールは不要です。 ### 実行 使用可能な字幕エンジンを取得したら、字幕ソフトウェアウィンドウで字幕エンジンのパスと字幕エンジンの実行コマンド(パラメータ)を指定して字幕エンジンを起動できます。 -![](../img/02_ja.png) \ No newline at end of file +![](../img/02_ja.png) + +デフォルトエンジンはアリババクラウドの `paraformer-realtime-v2` です。DashScope Recognition SDK の双方向ストリーミング認識を使用し、16kHz・16bit・モノラル PCM を送信します。中国語、英語、日本語、韓国語、ドイツ語、フランス語、ロシア語、広東語に対応し、`auto` は言語ヒントを指定しません。内蔵翻訳はありません。翻訳を有効にする場合は Ollama、OpenAI 互換サービス、Google 翻訳を設定してください。確定した文を非同期で翻訳します。設定または全プラットフォームで利用できる `DASHSCOPE_API_KEY` 環境変数に API Key を指定します。CLI は `--dashscope_api_key` を使用します。現在は北京リージョンのデフォルト接続先を使用するため、同リージョンの API Key が必要です。旧 Gummy の選択と API Key は自動移行し、スペイン語・イタリア語の入力言語は自動検出に変更します。旧カスタムコマンドは手動で更新してください。[公式 SDK ドキュメント](https://help.aliyun.com/zh/model-studio/paraformer-real-time-speech-recognition-python-sdk)を参照してください。 + +## Qwen LiveTranslate + +Qwen LiveTranslate は `qwen3.8-livetranslate-flash-realtime` を使用し、1つの WebSocket セッションで原文と訳文をストリーミング出力します。外部翻訳サービスは不要です。デフォルトは Paraformer のままです。このエンジンを選択し、業務スペース ID とリージョン(北京・シンガポール)を設定し、詳細設定で同じ業務スペース・リージョンの API Key を入力してください。`DASHSCOPE_WORKSPACE_ID` と `DASHSCOPE_API_KEY` 環境変数も利用できます。入力言語は自動検出します。UI の翻訳先は中国語・英語・日本語・韓国語・ドイツ語・フランス語・ロシア語・スペイン語・イタリア語です。16kHz・16bit・モノラル PCM を送信し、文字のみを出力します。翻訳音声は再生しません。翻訳を無効にしても訳文を非表示にするだけで、サービスは翻訳を実行します。認識のみが必要な場合は Paraformer を選択してください。 + +[API / WebSocket](https://help.aliyun.com/zh/model-studio/qwen3-5-livetranslate-flash-realtime) + +```bash +uv run python main.py -e livetranslate -t zh --dashscope_api_key --dashscope_workspace_id --dashscope_region cn-beijing +``` diff --git a/docs/engine-manual/zh.md b/docs/engine-manual/zh.md index ceb0a5c..90d1baf 100644 --- a/docs/engine-manual/zh.md +++ b/docs/engine-manual/zh.md @@ -1,6 +1,6 @@ # 字幕引擎说明文档 -对应版本:v1.0.0 +对应版本:v1.2.0 ![](../../assets/media/structure_zh.png) @@ -29,7 +29,7 @@ - Electron 主进程:当用户在前端操作关闭字幕引擎时,主进程通过 Socket 通信给字幕引擎进程发送 `command` 字段为 `stop` 的对象字符串 - 字幕引擎进程:接收主引擎进程发送的字幕数据对象字符串,将字符串解析为对象,如果对象的 `command` 字段为 `stop`,则将全局变量 `shared_data.status` 的值设置为 `stop` -- 字幕引擎进程:主线程循环监听系统音频输出,当 `thread_data.status` 的值不为 `running` 时,则结束循环,释放资源,结束运行 +- 字幕引擎进程:主线程循环监听系统音频输出,当 `shared_data.status` 的值不为 `running` 时,则结束循环,释放资源,结束运行 - Electron 主进程:如果检测到字幕引擎进程结束,进行相应处理,并向前端反馈 ## 项目已经实现的功能 @@ -46,8 +46,8 @@ from utils import stdout, stdout_cmd, stdout_obj, stderr # {"command": "print", "content": "Hello"}\n stdout("Hello") -# {"command": "connect", "content": "8080"}\n -stdout_cmd("connect", "8080") +# {"command": "connect", "content": ""}\n +stdout_cmd("connect") # {"command": "print", "content": "print"}\n stdout_obj({"command": "print", "content": "print"}) # sys.stderr.write("Error Info" + "\n") @@ -61,13 +61,14 @@ stderr("Error Info") 样例: ```python -from utils import start_server -from utils import shared_data +import threading +import time +from utils import start_server, shared_data + port = 8080 -start_server(port) -while thread_data == 'running': - # do something - pass +threading.Thread(target=start_server, args=(port,), daemon=True).start() +while shared_data.status == 'running': + time.sleep(0.1) ``` ### 音频获取 @@ -88,15 +89,18 @@ while thread_data == 'running': ```python from sysaudio import AudioStream -audio_type = 0 -chunk_rate = 20 -stream = AudioStream(audio_type, chunk_rate) +from utils import shared_data + +stream = AudioStream(0, 20) stream.open_stream() -while True: - data = stream.read_chunk() - # do something with data - pass -stream.close_stream() +try: + while shared_data.status == 'running': + data = stream.read_chunk() + if data is None: + continue + # Process the audio chunk here. +finally: + stream.close_stream() ``` ### 音频处理 @@ -110,12 +114,19 @@ stream.close_stream() ```python from sysaudio import AudioStream -from utils import merge_chunk_channels -stream = AudioStream(1) -while True: - raw_chunk = stream.read_chunk() - chunk = resample_chunk_mono(raw_chunk, stream.CHANNELS, stream.RATE, 16000) - # do something with chunk +from utils import resample_chunk_mono, shared_data + +stream = AudioStream(1) +stream.open_stream() +try: + while shared_data.status == 'running': + raw_chunk = stream.read_chunk() + if raw_chunk is None: + continue + chunk = resample_chunk_mono(raw_chunk, stream.CHANNELS, stream.RATE, 16000) + # Send the mono PCM chunk to the recognizer here. +finally: + stream.close_stream() ``` ## 字幕引擎需要实现的功能 @@ -133,7 +144,9 @@ while True: 完整的字幕引擎实例如下: -- [gummy.py](../../engine/audio2text/gummy.py) +- [paraformer.py](../../engine/audio2text/paraformer.py) +- [livetranslate.py](../../engine/audio2text/livetranslate.py) +- [glm.py](../../engine/audio2text/glm.py) - [vosk.py](../../engine/audio2text/vosk.py) - [sosv.py](../../engine/audio2text/sosv.py) @@ -144,10 +157,11 @@ while True: 样例: ```python -from utils import google_translate, ollama_translate +from utils import google_translate, ollama_translate, openai_translate text = "这是一个翻译测试。" google_translate("", "en", text, "time_s") ollama_translate("qwen3:0.6b", "en", text, "time_s") +openai_translate("gpt-4.1-mini", "en", text, "time_s", "https://api.openai.com/v1", "") ``` ### 字幕数据发送 @@ -174,33 +188,46 @@ export interface CaptionItem { 自定义字幕引擎的设置提供命令行参数指定,因此需要设置好字幕引擎的参数,本项目目前用到的参数如下: ```python +import argparse + if __name__ == "__main__": parser = argparse.ArgumentParser(description='Convert system audio stream to text') # 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=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') - parser.add_argument('-r', '--record', default=0, help='Whether to record the audio, 0 for no recording, 1 for recording') + parser.add_argument('-e', '--caption_engine', default='paraformer', choices=['paraformer', 'livetranslate', 'glm', 'vosk', 'sosv'], help='Caption engine: paraformer, livetranslate, glm, vosk or sosv') + parser.add_argument('-a', '--audio_type', type=int, default=0, help='Audio stream source: 0 for output, 1 for input') + parser.add_argument('-c', '--chunk_rate', type=int, default=10, help='Number of audio stream chunks collected per second') + parser.add_argument('-p', '--port', type=int, default=0, help='The port to run the server on, 0 for no server') + parser.add_argument('-d', '--display_caption', type=int, default=0, help='Display caption on terminal, 0 for no display, 1 for display') + parser.add_argument('-t', '--target_language', default='none', help='Target language code, "none" for no translation') + parser.add_argument('-r', '--record', type=int, default=0, help='Whether to record the audio, 0 for no recording, 1 for recording') parser.add_argument('-rp', '--record_path', default='', help='Path to save the recorded audio') - # gummy and sosv + # paraformer and sosv and glm parser.add_argument('-s', '--source_language', default='auto', help='Source language code') - # gummy only - 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') + # Alibaba Cloud engines + parser.add_argument('--dashscope_api_key', default='', help='API key for Alibaba Cloud caption engines') + parser.add_argument('--dashscope_workspace_id', default='', help='LiveTranslate workspace ID (or DASHSCOPE_WORKSPACE_ID)') + parser.add_argument('--dashscope_region', choices=['cn-beijing', 'ap-southeast-1'], default='cn-beijing', help='LiveTranslate API region') + # External translation: Paraformer, GLM, Vosk and SOSV + parser.add_argument('--translation_provider', choices=['ollama', 'openai', 'google'], default='ollama', help='Translation service provider') + parser.add_argument('--translation_model', default='', help='Translation model name (for Ollama or OpenAI-compatible services)') + parser.add_argument('--translation_base_url', default='', help='OpenAI-compatible API base URL') + parser.add_argument('--translation_api_key', default='', help='OpenAI-compatible API key') # vosk only 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') + # glm only + parser.add_argument('-gurl', '--glm_url', default='https://open.bigmodel.cn/api/paas/v4/audio/transcriptions', help='GLM API URL') + parser.add_argument('-gmodel', '--glm_model', default='glm-asr-2512', help='GLM Model Name') + parser.add_argument('-gkey', '--glm_api_key', default='', help='GLM API Key') + + args = parser.parse_args() ``` -比如对于本项目的字幕引擎,我想使用 Gummy 模型,指定原文为日语,翻译为中文,获取系统音频输出的字幕,每次截取 0.1s 的音频数据,那么命令行参数如下: +比如对于本项目的字幕引擎,我想使用 Paraformer 模型,指定原文为日语,翻译为中文,获取系统音频输出的字幕,每次截取 0.1s 的音频数据,那么命令行参数如下: ```bash -python main.py -e gummy -s ja -t zh -a 0 -c 10 -k +uv run python main.py -e paraformer -s ja -t zh -a 0 -c 10 --dashscope_api_key --translation_provider ollama --translation_model qwen2.5:0.5b ``` ## 其他 @@ -218,14 +245,34 @@ python main.py -e gummy -s ja -t zh -a 0 -c 10 -k 除音频转文字和翻译外,其他(音频获取、音频重采样、与主进程通信)建议直接复用本项目代码。如果这样,那么需要添加的内容为: - `engine/audio2text/`:添加新的音频转文字类(文件级别) -- `engine/main.py`:添加新参数设置、流程函数(参考 `main_gummy` 函数和 `main_vosk` 函数) +- `engine/main.py`:添加新参数设置、流程函数(参考 `main_paraformer` 函数和 `main_vosk` 函数) ### 打包 -在完成字幕引擎的开发和测试后,需要将字幕引擎打包成可执行文件。一般使用 `pyinstaller` 进行打包。如果打包好的字幕引擎文件执行报错,可能是打包漏掉了某些依赖库,请检查是否缺少了依赖库。 +引擎使用 uv 管理依赖:`engine/pyproject.toml` 声明运行依赖与 PyInstaller 开发依赖,`engine/uv.lock` 在本地自动生成并由 Git 忽略,默认 Python 为 3.12。在仓库根目录执行: + +```bash +npm run engine:sync +npm run engine:test +npm run build:engine +``` + +也可在 `engine` 目录执行 `uv run pyinstaller --noconfirm --clean main.spec`。Vosk 路径由当前环境自动发现。PyInstaller 生成单文件 `engine/dist/main.exe`(Windows)或 `engine/dist/main`(macOS/Linux),安装包分别复制至资源目录的 `engine/main.exe` 或 `engine/main`,与主进程启动路径一致。安装包构建命令会自动先构建引擎;必须在目标操作系统上构建。平台音频依赖见三语 README,发布版无需安装 uv 或 Python。 ### 运行 有了可以使用的字幕引擎,就可以在字幕软件窗口中通过指定字幕引擎的路径和字幕引擎的运行指令(参数)来启动字幕引擎了。 ![](../img/02_zh.png) + +默认引擎为阿里云 `paraformer-realtime-v2`,通过 DashScope Recognition SDK 双向流式识别。上传音频为 16kHz、16bit、单声道 PCM;支持中文、英文、日语、韩语、德语、法语、俄语及粤语,`auto` 不指定语言提示。模型不提供内置翻译;开启翻译后需配置 Ollama、OpenAI 兼容服务或 Google 翻译,完整句子返回后异步翻译。设置中的阿里云 API Key 或 `DASHSCOPE_API_KEY` 环境变量用于鉴权(所有平台);CLI 参数为 `--dashscope_api_key`。当前使用北京地域默认端点,API Key 必须匹配该地域。旧 Gummy 引擎选项和 API Key 自动迁移,旧西班牙语/意大利语源语言改为自动检测;旧自定义命令需手动更新。详见[阿里云官方 SDK 文档](https://help.aliyun.com/zh/model-studio/paraformer-real-time-speech-recognition-python-sdk)。 + +## Qwen LiveTranslate + +Qwen LiveTranslate 使用 `qwen3.8-livetranslate-flash-realtime`,在同一个 WebSocket 会话中流式返回原文和译文,无需配置外部翻译服务。默认仍为 Paraformer。选择此引擎后填写业务空间 ID、地域(北京或新加坡),并在更多设置中填写该业务空间和地域的阿里云 API Key;也可使用 `DASHSCOPE_WORKSPACE_ID` 和 `DASHSCOPE_API_KEY` 环境变量。源语言自动检测,界面提供中、英、日、韩、德、法、俄、西、意作为翻译目标语言。音频上传为 16kHz、16bit、单声道 PCM,仅输出文字,不播放翻译语音。关闭翻译仅隐藏译文,服务端仍执行翻译;只需要识别时建议选择 Paraformer。 + +[API / WebSocket](https://help.aliyun.com/zh/model-studio/qwen3-5-livetranslate-flash-realtime) + +```bash +uv run python main.py -e livetranslate -t zh --dashscope_api_key --dashscope_workspace_id --dashscope_region cn-beijing +``` diff --git a/docs/user-manual/en.md b/docs/user-manual/en.md index a88a97b..930ea12 100644 --- a/docs/user-manual/en.md +++ b/docs/user-manual/en.md @@ -1,12 +1,10 @@ # Auto Caption User Manual -Corresponding Version: v1.1.1 - -**Note: Due to limited personal resources, the English and Japanese documentation files for this project (except for the README document) will no longer be maintained. The content of this document may not be consistent with the latest version of the project. If you are willing to help with translation, please submit relevant Pull Requests.** +Corresponding Version: v1.2.0 ## Software Introduction -Auto Caption is a cross-platform caption display software that can real-time capture system audio input (recording) or output (playback) streaming data and use an audio-to-text model to generate captions for the corresponding audio. The default caption engine provided by the software (using Alibaba Cloud Gummy model) supports recognition and translation in nine languages (Chinese, English, Japanese, Korean, German, French, Russian, Spanish, Italian). +Auto Caption is a cross-platform caption display software that can real-time capture system audio input (recording) or output (playback) streaming data and use an audio-to-text model to generate captions for the corresponding audio. The default Paraformer engine recognizes eight source languages including Cantonese, with translation provided by external services. The default caption engine currently has full functionality on Windows, macOS, and Linux platforms. Additional configuration is required to capture system audio output on macOS. @@ -22,25 +20,31 @@ The following operating system versions have been tested and confirmed to work p ![](../../assets/media/main_en.png) +### About Project + +Click the GitHub icon under “About Project” on the main screen to open the About window. It displays the app icon at the top, the software version, update date and author, with a two-by-two grid of cards linking to the project repository, user manual, caption engine manual and changelog. Each link shows its full address. Manual links follow the interface language, the changelog links to the shared update history, and the window follows the light or dark theme. Click the author name [HiMeditator](https://github.com/HiMeditator) to open the GitHub profile in your default browser. + ### Software Limitations -To use the Gummy caption engine, you need to obtain an API KEY from Alibaba Cloud. +To use the Paraformer caption engine, you need to obtain an API KEY from Alibaba Cloud. Additional configuration is required to capture audio output on macOS platform. The software is built using Electron, so the software size is inevitably large. -## Preparation for Using Gummy Engine +## Paraformer Realtime v2 -To use the default caption engine provided by the software (Alibaba Cloud Gummy), you need to obtain an API KEY from the Alibaba Cloud Bailian platform. Then add the API KEY to the software settings or configure it in environment variables (only Windows platform supports reading API KEY from environment variables). +The default engine is Alibaba Cloud `paraformer-realtime-v2`, using the DashScope Recognition SDK for duplex streaming ASR. Audio is uploaded as 16kHz, 16-bit mono PCM. Supported source languages are Chinese, English, Japanese, Korean, German, French, Russian and Cantonese; `auto` sends no language hint. There is no built-in translation: configure Ollama, an OpenAI-compatible service or Google Translate to translate finalized sentences asynchronously. Set the Alibaba Cloud API Key in settings or the `DASHSCOPE_API_KEY` environment variable on any platform; the CLI option is `--dashscope_api_key`. The current endpoint is the default Beijing endpoint and requires a matching regional API Key. Old Gummy selections and API Keys migrate automatically; old Spanish/Italian source selections switch to auto detection. Update old custom commands manually. See the [official SDK documentation](https://help.aliyun.com/zh/model-studio/paraformer-real-time-speech-recognition-python-sdk). -**The international version of Alibaba Cloud services does not provide the Gummy model, so non-Chinese users currently cannot use the default caption engine.** +## Qwen LiveTranslate -Alibaba Cloud provides detailed tutorials for this part, which can be referenced: +Qwen LiveTranslate uses `qwen3.8-livetranslate-flash-realtime` to stream source transcripts and translated text in one WebSocket session, with no external translation service. Paraformer remains the default. Select this engine, enter the workspace ID and region (Beijing or Singapore), and enter an Alibaba Cloud API Key for that workspace and region in More Settings. Alternatively, use `DASHSCOPE_WORKSPACE_ID` and `DASHSCOPE_API_KEY`. The source language is detected automatically. The UI offers Chinese, English, Japanese, Korean, German, French, Russian, Spanish and Italian as target languages. Audio is uploaded as 16kHz, 16-bit mono PCM. Only text is returned; translated speech is not played. Disabling translation only hides the translation, while the service still translates. Use Paraformer for recognition only. -- [Obtaining API KEY (Chinese)](https://help.aliyun.com/zh/model-studio/get-api-key) -- [Configuring API Key through Environment Variables (Chinese)](https://help.aliyun.com/zh/model-studio/configure-api-key-through-environment-variables) +[API / WebSocket](https://help.aliyun.com/zh/model-studio/qwen3-5-livetranslate-flash-realtime) +```bash +uv run python main.py -e livetranslate -t zh --dashscope_api_key --dashscope_workspace_id --dashscope_region cn-beijing +``` ## Preparation for GLM Engine @@ -110,6 +114,8 @@ sudo yum install pulseaudio pavucontrol Caption settings can be divided into three categories: general settings, caption engine settings, and caption style settings. Note that changes to general settings take effect immediately. For the other two categories, after making changes, you need to click the "Apply" option in the upper right corner of the corresponding settings module for the changes to take effect. If you click "Cancel Changes," the current modifications will not be saved and will revert to the previous state. +The caption style preview has a close icon in its top-right corner, matching the caption window's button style and using the source caption text color. Clicking it immediately hides the preview and turns off the Preview switch in Caption Style Settings. A notification confirms that the preview is closed and explains how to enable it again in Caption Style Settings. Turn the switch back on to show the preview again. Apply saves this setting; Cancel restores the last saved state. + ### Starting and Stopping Captions After completing all configurations, click the "Start Caption Engine" button on the interface to start the captions. If you need a separate caption display window, click the "Open Caption Window" button to activate the independent caption display window. To pause caption recognition, click the "Stop Caption Engine" button. @@ -126,9 +132,19 @@ In the caption control window, you can see the records of all collected captions ## Caption Engine +Caption engine options appear in the order Qwen LiveTranslate 3.8, Paraformer, GLM-ASR, Vosk, SOSV, with cloud models first. Local models show their execution device: `Local / CPU / Vosk` and `Local / CPU / SOSV`. + +In Caption Engine Settings, each model's description appears at the top of the model settings, explaining its recognition method, configuration requirements and translation support. The description updates immediately when you select another model. Its bordered box uses the current theme's primary color palette for its background and border, adapting to light and dark themes. + +When the last color option (black) is selected in the light theme, model and translation descriptions use a light gray background and gray border to keep the text readable. + +Model, audio, custom engine and More Settings options come first. Translation and Enable Recording share a row in the following group, followed by target language, translation service and its model name, Base URL and API Key. A translation service description between the two groups uses the same style as the model description and updates immediately when you select Ollama, an OpenAI-compatible service or Google Translate. Qwen LiveTranslate shows a built-in translation description and omits external translation service settings. + +Hover over the Audio Type, Enable Recording, Custom Eng, Translation or More Settings label to see its function and configuration requirements. When Qwen LiveTranslate is selected, Workspace ID and Alibaba Cloud region also provide hover help explaining that the workspace, API Key and region must match, and that an empty workspace ID falls back to the environment variable. The Workspace ID help also links to [Alibaba Cloud Model Studio workspace settings](https://bailian.console.aliyun.com/cn-beijing/settings/workspace), where you can obtain your own workspace ID. Labels with hover help use the theme color. The translation help for Qwen LiveTranslate explains that disabling translation only hides translated text while the service still translates. + The so-called caption engine is essentially a subprogram that captures real-time streaming data from system audio input (recording) or output (playback), and invokes speech-to-text models to generate corresponding captions. The generated captions are converted into JSON-formatted strings and passed to the main program through standard output. The main program reads the caption data, processes it, and displays it in the window. -The software provides two default caption engines. If you need other caption engines, you can invoke them by enabling the custom engine option (other engines need to be specifically developed for this software). The engine path refers to the location of the custom caption engine on your computer, while the engine command represents the runtime parameters of the custom caption engine, which should be configured according to the rules of that particular caption engine. +The software provides five built-in caption engines. If you need other caption engines, you can invoke them by enabling the custom engine option (other engines need to be specifically developed for this software). The engine path refers to the location of the custom caption engine on your computer, while the engine command represents the runtime parameters of the custom caption engine, which should be configured according to the rules of that particular caption engine. ![](../img/02_en.png) @@ -152,9 +168,9 @@ The following parameter descriptions only include necessary parameters. #### `-e , --caption_engine` -The caption engine model to select, currently three options are available: `gummy, glm, vosk, sosv`. +The caption engine model to select, currently five options are available: `paraformer, livetranslate, glm, vosk, sosv`. -The default value is `gummy`. +The default value is `paraformer`. This applies to all models. @@ -204,21 +220,29 @@ Source language for recognition. Default value is `auto`, meaning no specific so Specifying the source language can improve recognition accuracy to some extent. You can specify the source language using the language codes above. -This applies to Gummy, GLM and SOSV models. +This applies to Paraformer, GLM and SOSV models. -The Gummy model can use all the languages mentioned above, plus Cantonese (`yue`). +Paraformer supports `zh, en, ja, ko, de, fr, ru, yue` and `auto`; Spanish and Italian recognition are unsupported. The GLM model supports specifying the following languages: English, Chinese, Japanese, Korean. The SOSV model supports specifying the following languages: English, Chinese, Japanese, Korean, and Cantonese. -#### `-k, --api_key` +#### `--dashscope_api_key` -Specify the Alibaba Cloud API KEY required for the `Gummy` model. +Specify the Alibaba Cloud API Key used by Paraformer and Qwen LiveTranslate. If empty, the engine reads `DASHSCOPE_API_KEY`. The key must match the service region. Default value is empty. -This only applies to the Gummy model. +This applies to Paraformer and Qwen LiveTranslate. + +#### `--dashscope_workspace_id` + +The Qwen LiveTranslate workspace ID. The default is empty, falling back to `DASHSCOPE_WORKSPACE_ID`. Obtain your ID from [Alibaba Cloud Model Studio workspace settings](https://bailian.console.aliyun.com/cn-beijing/settings/workspace). The workspace, API Key and region must match. + +#### `--dashscope_region` + +The Qwen LiveTranslate service region: `cn-beijing` (Beijing, default) or `ap-southeast-1` (Singapore). Paraformer uses the default Beijing endpoint and does not use this option. #### `-gkey, --glm_api_key` @@ -232,36 +256,37 @@ Specifies the model name to be used for the `glm` model. The default value is `g Specifies the API URL required for the `glm` model. The default value is: `https://open.bigmodel.cn/api/paas/v4/audio/transcriptions`. -#### `-tm, --translation_model` +#### `--translation_provider` -Specify the translation method for Vosk and SOSV models. Default is `ollama`. +Specifies the translation service used by the Paraformer, Vosk, SOSV, and GLM caption engines. The default is `ollama`. Supported values are: - `ollama` Use local Ollama model for translation. Users need to install Ollama software and corresponding models +- `openai` Use an OpenAI API-compatible translation service - `google` Use Google Translate API for translation. No additional configuration needed, but requires network access to Google -This only applies to Vosk and SOSV models. +This only applies to caption engines without built-in translation. -#### `-omn, --ollama_name` +#### `--translation_model` -Specifies the name of the translation model to be used, which can be either a local Ollama model or a cloud model compatible with the OpenAI API. If the Base URL field is not filled in, the local Ollama service will be called by default; otherwise, the API service at the specified address will be invoked via the Python OpenAI library. +Specifies the model name used by Ollama or an OpenAI-compatible service. Google Translate does not use this option. If using an Ollama model, it is recommended to use a model with fewer than 1B parameters, such as `qwen2.5:0.5b` or `qwen3:0.6b`. The corresponding model must be downloaded in Ollama for normal use. -The default value is empty and applies to models other than Gummy. +The default value is empty and applies to Paraformer, GLM, Vosk and SOSV. -#### `-ourl, --ollama_url` +#### `--translation_base_url` -The base request URL for calling the OpenAI API. If left blank, the local Ollama model on the default port will be called. +The base URL of the OpenAI-compatible service, such as `https://api.openai.com/v1`. Used only with `--translation_provider openai`. -The default value is empty and applies to models other than Gummy. +The default value is empty and applies to Paraformer, GLM, Vosk and SOSV. -#### `-okey, --ollama_api_key` +#### `--translation_api_key` -Specifies the API KEY for calling OpenAI-compatible models. +Specifies the API key for the OpenAI-compatible service. Used only with `--translation_provider openai`. -The default value is empty and applies to models other than Gummy. +The default value is empty and applies to Paraformer, GLM, Vosk and SOSV. #### `-vosk, --vosk_model` @@ -279,18 +304,26 @@ This only applies to the SOSV model. > The following content assumes users who use this method have knowledge of Python environment configuration and usage. -First, download the project source code locally. The caption engine source code is located in the `engine` directory of the project. Then configure the Python environment, where the project dependencies are listed in the `requirements.txt` file in the `engine` directory. +Download the source code, install [uv](https://docs.astral.sh/uv/getting-started/installation/), and enter the `engine` directory. Dependencies are declared in `pyproject.toml`; `uv.lock` is generated locally and ignored by Git, with Python 3.12 selected by default: -After configuration, enter the `engine` directory and execute commands to run the caption engine. +```bash +cd engine +uv sync +uv run python main.py --help +``` -For example, to use the Gummy model, specify audio type as system audio output, source language as English, and target language as Chinese, execute the following command: +No virtual environment activation is required. Run the source commands below from `engine` using `uv run python main.py`. See the README engine build section for platform audio dependencies. The packaged app uses its bundled engine and does not require uv. + +For example, to use the Paraformer model, specify audio type as system audio output, source language as English, and target language as Chinese, execute the following command: > Note: For better visualization, the commands below are written on multiple lines. If execution fails, try removing backslashes and executing as a single line command. ```bash -python main.py \ --e gummy \ --k sk-******************************** \ +uv run python main.py \ +-e paraformer \ +--dashscope_api_key sk-******************************** \ +--translation_provider ollama \ +--translation_model qwen2.5:0.5b \ -a 0 \ -d 1 \ -s en \ @@ -300,18 +333,33 @@ python main.py \ To specify the Vosk model, audio type as system audio output, translate to English, and use Ollama `qwen3:0.6b` model for translation: ```bash -python main.py \ +uv run python main.py \ -e vosk \ -vosk D:\Projects\auto-caption\engine\models\vosk-model-small-cn-0.22 \ -a 0 \ -d 1 \ -t en \ +--translation_provider ollama \ +--translation_model qwen3:0.6b +``` + +To use an OpenAI-compatible service instead, specify the service type, model, and Base URL explicitly: + +```bash +uv run python main.py \ +-e vosk \ +-vosk D:\Projects\auto-caption\engine\models\vosk-model-small-cn-0.22 \ +-t en \ +--translation_provider openai \ +--translation_model gpt-4.1-mini \ +--translation_base_url https://api.openai.com/v1 \ +--translation_api_key sk-******************************** ``` To specify the SOSV model, audio type as microphone, automatically select source language, and no translation: ```bash -python main.py \ +uv run python main.py \ -e sosv \ -sosv D:\\Projects\\auto-caption\\engine\\models\\sosv-int8 \ -a 1 \ @@ -320,7 +368,7 @@ python main.py \ -t none ``` -Running result using the Gummy model is shown below: +Running result using the Paraformer model is shown below: ![](../img/07.png) @@ -330,4 +378,6 @@ First, download the executable file for your platform from [GitHub Releases](htt Then open a terminal in the directory containing the caption engine executable file and execute commands to run the caption engine. -Simply replace `python main.py` in the above commands with the executable file name (for example: `engine-win.exe`). \ No newline at end of file +Simply replace `uv run python main.py` in the above commands with the executable file name (for example: `engine-win.exe`). + +LiveTranslate CLI: `--dashscope_workspace_id` specifies the workspace ID (falls back to the environment variable); `--dashscope_region` defaults to `cn-beijing`, also supporting `ap-southeast-1`. `--dashscope_api_key` is shared with Paraformer. `-s` is unused; `-t none` only hides translated text. External translation options do not apply to LiveTranslate. diff --git a/docs/user-manual/ja.md b/docs/user-manual/ja.md index 09f9a05..586cd9a 100644 --- a/docs/user-manual/ja.md +++ b/docs/user-manual/ja.md @@ -1,12 +1,10 @@ # Auto Caption ユーザーマニュアル -対応バージョン:v1.1.1 - -この文書は大規模モデルを使用して翻訳されていますので、内容に正確でない部分があるかもしれません。 +対応バージョン:v1.2.0 ## ソフトウェアの概要 -Auto Caption は、クロスプラットフォームの字幕表示ソフトウェアで、システムの音声入力(録音)または出力(音声再生)のストリーミングデータをリアルタイムで取得し、音声からテキストに変換するモデルを利用して対応する音声の字幕を生成します。このソフトウェアが提供するデフォルトの字幕エンジン(アリババクラウド Gummy モデルを使用)は、9つの言語(中国語、英語、日本語、韓国語、ドイツ語、フランス語、ロシア語、スペイン語、イタリア語)の認識と翻訳をサポートしています。 +Auto Caption は、クロスプラットフォームの字幕表示ソフトウェアで、システムの音声入力(録音)または出力(音声再生)のストリーミングデータをリアルタイムで取得し、音声からテキストに変換するモデルを利用して対応する音声の字幕を生成します。デフォルトの Paraformer エンジンは広東語を含む8言語を認識し、外部サービスで翻訳します。 現在のデフォルト字幕エンジンは Windows、macOS、Linux プラットフォームで完全な機能を有しています。macOSでシステムのオーディオ出力を取得するには追加設定が必要です。 @@ -22,24 +20,31 @@ Auto Caption は、クロスプラットフォームの字幕表示ソフトウ ![](../../assets/media/main_ja.png) +### プロジェクト情報 + +メイン画面の「プロジェクト情報」の下にある GitHub アイコンをクリックすると、情報ウィンドウが開きます。上部にソフトウェアのアイコンを表示し、ソフトウェアバージョン、更新日、作者と、プロジェクトリポジトリ、ユーザーマニュアル、字幕エンジンマニュアル、更新履歴へのリンクを2行2列のカードで提供します。各リンクには完全なアドレスを表示します。マニュアルのリンクは表示言語に合わせて切り替わり、更新履歴は共通の更新記録を参照します。配色はライト・ダークテーマに対応します。作者名 [HiMeditator](https://github.com/HiMeditator) をクリックすると、既定のブラウザで作者の GitHub プロフィールが開きます。 + ### ソフトウェアの欠点 -Gummy 字幕エンジンを使用するには、アリババクラウドの API KEY を取得する必要があります。 +Paraformer 字幕エンジンを使用するには、アリババクラウドの API KEY を取得する必要があります。 macOS プラットフォームでオーディオ出力を取得するには追加の設定が必要です。 ソフトウェアは Electron で構築されているため、そのサイズは避けられないほど大きいです。 -## Gummyエンジン使用前の準備 +## Paraformer Realtime v2 -ソフトウェアが提供するデフォルトの字幕エンジン(Alibaba Cloud Gummy)を使用するには、Alibaba Cloud百煉プラットフォームからAPI KEYを取得する必要があります。その後、API KEYをソフトウェア設定に追加するか、環境変数に設定します(Windowsプラットフォームのみ環境変数からのAPI KEY読み取りをサポート)。 +デフォルトエンジンはアリババクラウドの `paraformer-realtime-v2` です。DashScope Recognition SDK の双方向ストリーミング認識を使用し、16kHz・16bit・モノラル PCM を送信します。中国語、英語、日本語、韓国語、ドイツ語、フランス語、ロシア語、広東語に対応し、`auto` は言語ヒントを指定しません。内蔵翻訳はありません。翻訳を有効にする場合は Ollama、OpenAI 互換サービス、Google 翻訳を設定してください。確定した文を非同期で翻訳します。設定または全プラットフォームで利用できる `DASHSCOPE_API_KEY` 環境変数に API Key を指定します。CLI は `--dashscope_api_key` を使用します。現在は北京リージョンのデフォルト接続先を使用するため、同リージョンの API Key が必要です。旧 Gummy の選択と API Key は自動移行し、スペイン語・イタリア語の入力言語は自動検出に変更します。旧カスタムコマンドは手動で更新してください。[公式 SDK ドキュメント](https://help.aliyun.com/zh/model-studio/paraformer-real-time-speech-recognition-python-sdk)を参照してください。 -**Alibaba Cloudの国際版サービスではGummyモデルを提供していないため、現在中国以外のユーザーはデフォルトの字幕エンジンを使用できません。** +## Qwen LiveTranslate -この部分についてAlibaba Cloudは詳細なチュートリアルを提供しており、以下を参照できます: +Qwen LiveTranslate は `qwen3.8-livetranslate-flash-realtime` を使用し、1つの WebSocket セッションで原文と訳文をストリーミング出力します。外部翻訳サービスは不要です。デフォルトは Paraformer のままです。このエンジンを選択し、業務スペース ID とリージョン(北京・シンガポール)を設定し、詳細設定で同じ業務スペース・リージョンの API Key を入力してください。`DASHSCOPE_WORKSPACE_ID` と `DASHSCOPE_API_KEY` 環境変数も利用できます。入力言語は自動検出します。UI の翻訳先は中国語・英語・日本語・韓国語・ドイツ語・フランス語・ロシア語・スペイン語・イタリア語です。16kHz・16bit・モノラル PCM を送信し、文字のみを出力します。翻訳音声は再生しません。翻訳を無効にしても訳文を非表示にするだけで、サービスは翻訳を実行します。認識のみが必要な場合は Paraformer を選択してください。 -- [API KEY の取得(中国語)](https://help.aliyun.com/zh/model-studio/get-api-key) -- [環境変数を通じて API Key を設定(中国語)](https://help.aliyun.com/zh/model-studio/configure-api-key-through-environment-variables) +[API / WebSocket](https://help.aliyun.com/zh/model-studio/qwen3-5-livetranslate-flash-realtime) + +```bash +uv run python main.py -e livetranslate -t zh --dashscope_api_key --dashscope_workspace_id --dashscope_region cn-beijing +``` ## GLM エンジン使用前の準備 @@ -110,6 +115,8 @@ sudo yum install pulseaudio pavucontrol 字幕の設定は3つのカテゴリーに分かれます:一般的な設定、字幕エンジンの設定、字幕スタイルの設定。注意すべき点として、一般的な設定の変更は即座に適用されます。しかし、他の2つの設定については、変更後に該当する設定モジュール右上の「適用」オプションをクリックすることで初めて変更が有効になります。「変更を取り消す」を選択すると、現在の変更は保存されず、前回の状態に戻ります。 +字幕スタイルのプレビュー右上に、字幕ウィンドウと同じスタイルの閉じるアイコンがあり、原文字幕の文字色を使用します。クリックするとプレビューをすぐに非表示にし、字幕スタイル設定の「プレビュー」スイッチもオフにします。通知でプレビューを閉じたことと、字幕スタイル設定で再表示できることを案内します。スイッチをオンにすると再表示できます。「適用」でこの設定を保存し、「キャンセル」で前回保存した状態に戻します。 + ### 字幕の開始と停止 すべての設定を完了したら、インターフェースの「字幕エンジンを開始」ボタンをクリックして字幕を開始できます。独立した字幕表示ウィンドウが必要な場合は、インターフェースの「字幕ウィンドウを開く」ボタンをクリックして独立した字幕表示ウィンドウをアクティブ化します。字幕認識を一時停止する必要がある場合は、「字幕エンジンを停止」ボタンをクリックします。 @@ -126,12 +133,84 @@ sudo yum install pulseaudio pavucontrol ## 字幕エンジン +字幕エンジンは Qwen LiveTranslate 3.8、Paraformer、GLM-ASR、Vosk、SOSV の順に表示し、クラウドモデルを先に並べます。ローカルモデルには実行デバイスを表示します:`ローカル / CPU / Vosk`、`ローカル / CPU / SOSV`。 + +字幕エンジン設定では、各モデルの説明がモデル設定の先頭に表示され、認識方式・設定要件・翻訳対応を確認できます。モデルを切り替えると説明もすぐに更新されます。説明欄には枠線があり、背景と枠線には現在のテーマカラーに合わせた配色を使用し、ライト・ダークテーマに対応します。 + +ライトテーマで最後の色(黒)を選択した場合、モデルと翻訳の説明欄には薄いグレーの背景とグレーの枠線を使用し、文字を読みやすく表示します。 + +モデル、オーディオ、カスタムエンジン、詳細設定を先に表示します。後半では翻訳と録音を同じ行に表示し、その後に翻訳先の言語、翻訳サービスとそのモデル名・Base URL・API Key をまとめて表示します。両グループの間に、モデル説明と同じスタイルの翻訳サービス説明を表示し、Ollama、OpenAI API 互換サービス、Google 翻訳の選択に応じてすぐに更新します。Qwen LiveTranslate では内蔵翻訳の説明を表示し、外部翻訳サービスの設定項目は表示しません。 + +「オーディオ」「録音」「カスタム」「翻訳」「詳細設定」のラベルにマウスを合わせると、機能や設定要件を確認できます。Qwen LiveTranslate を選択した場合、「業務スペース ID」と「リージョン」にも説明を表示し、業務スペース・API Key・リージョンの一致要件と、業務スペース ID が空欄の場合は環境変数を使用することを案内します。業務スペース ID の説明には[アリババクラウド百煉の業務スペース設定](https://bailian.console.aliyun.com/cn-beijing/settings/workspace)へのリンクもあり、ご自身の業務スペース ID を取得できます。説明付きのラベルはテーマカラーで表示します。Qwen LiveTranslate の翻訳説明には、翻訳を無効にしても訳文を非表示にするだけで、サービスは翻訳を実行することを記載しています。 + 字幕エンジンとは、システムのオーディオ入力(録音)または出力(再生音)のストリーミングデータをリアルタイムで取得し、音声テキスト変換モデルを呼び出して対応する字幕を生成するサブプログラムです。生成された字幕は JSON 形式の文字列に変換され、標準出力を通じてメインプログラムに渡されます。メインプログラムは字幕データを読み取り、処理した後、ウィンドウに表示します。 -ソフトウェアには2つのデフォルトの字幕エンジンが用意されています。他の字幕エンジンが必要な場合、カスタムエンジンオプションを有効にすることで呼び出すことができます(他のエンジンはこのソフトウェア向けに特別に開発する必要があります)。エンジンパスはコンピュータ上のカスタム字幕エンジンの場所を指し、エンジンコマンドはカスタム字幕エンジンの実行パラメータを表します。これらは該当する字幕エンジンの規則に従って設定する必要があります。 +ソフトウェアには5つの組み込み字幕エンジンが用意されています。他の字幕エンジンが必要な場合、カスタムエンジンオプションを有効にすることで呼び出すことができます(他のエンジンはこのソフトウェア向けに特別に開発する必要があります)。エンジンパスはコンピュータ上のカスタム字幕エンジンの場所を指し、エンジンコマンドはカスタム字幕エンジンの実行パラメータを表します。これらは該当する字幕エンジンの規則に従って設定する必要があります。 ![](../img/02_ja.png) カスタム字幕エンジンを使用する場合、前の字幕エンジンの設定はすべて無効になります。カスタム字幕エンジンの設定は完全にエンジンコマンドによって行われます。 開発者の方で、カスタム字幕エンジンを開発したい場合は、[字幕エンジン説明文書](../engine-manual/ja.md)をご覧ください。 + +LiveTranslate CLI:`--dashscope_workspace_id` は業務スペース ID(省略時は環境変数)、`--dashscope_region` はリージョン(既定値 `cn-beijing`、`ap-southeast-1` も対応)です。`--dashscope_api_key` は Paraformer と共通です。`-s` は使用せず、`-t none` は訳文の非表示のみです。外部翻訳パラメータは使用しません。 + +## ソースコードでエンジンを実行する + +[uv](https://docs.astral.sh/uv/getting-started/installation/) をインストールし、リポジトリの `engine` ディレクトリで実行します。依存関係は `pyproject.toml` で宣言し、`uv.lock` はローカルで自動生成して Git の対象外とし、既定の Python は 3.12 です。 + +```bash +cd engine +uv sync +uv run python main.py --help +``` + +仮想環境の有効化は不要です。認識エンジン、API Key、音声入力、翻訳などの引数を `main.py` の後に指定してください。音声取得のシステム依存は README を参照してください。配布版アプリは同梱された実行ファイルを起動するため、uv や Python のインストールは不要です。自分で構築した単一実行ファイルは Windows が `main.exe`、macOS/Linux が `main` です。 + +### コマンドライン引数 + +`uv run python main.py --help` で全引数を確認できます。引数定義と開発例は[字幕エンジン説明](../engine-manual/ja.md)、独立実行・テスト・ビルド手順は[エンジン README(中国語)](../../engine/README.md)も参照してください。 + +| 引数 | 既定値 | 説明 | +| --- | --- | --- | +| `-e, --caption_engine` | `paraformer` | `paraformer`、`livetranslate`、`glm`、`vosk`、`sosv` | +| `-a, --audio_type` | `0` | `0` はシステム音声出力、`1` はマイク入力 | +| `-c, --chunk_rate` | `10` | 1秒あたりに取得する音声チャンク数 | +| `-p, --port` | `0` | TCP ポート。`0` は Socket サーバーを起動しない | +| `-d, --display_caption` | `0` | 端末表示の指定。`0` は無効、`1` は有効 | +| `-t, --target_language` | `none` | 翻訳先の言語コード。LiveTranslate の `none` は訳文の非表示のみ | +| `-s, --source_language` | `auto` | Paraformer、GLM、SOSV の入力言語。Vosk と LiveTranslate は使用しない | +| `-r, --record` | `0` | `1` で音声を WAV に録音 | +| `-rp, --record_path` | 空欄 | 録音の保存先。空欄は作業ディレクトリ | +| `--dashscope_api_key` | 空欄 | Paraformer と LiveTranslate の API Key。空欄は `DASHSCOPE_API_KEY` を使用 | +| `--dashscope_workspace_id` | 空欄 | LiveTranslate の業務スペース ID。空欄は `DASHSCOPE_WORKSPACE_ID` を使用 | +| `--dashscope_region` | `cn-beijing` | LiveTranslate のリージョン。`ap-southeast-1` も対応 | +| `--translation_provider` | `ollama` | 外部翻訳サービス:`ollama`、`openai`、`google` | +| `--translation_model` | 空欄 | Ollama または OpenAI 互換サービスのモデル名 | +| `--translation_base_url` | 空欄 | OpenAI 互換サービスの Base URL | +| `--translation_api_key` | 空欄 | OpenAI 互換サービスの API Key | +| `-vosk, --vosk_model` | 空欄 | Vosk のモデルディレクトリ | +| `-sosv, --sosv_model` | 未指定 | SOSV のモデルディレクトリ | +| `-gkey, --glm_api_key` | 空欄 | GLM の API Key | +| `-gmodel, --glm_model` | `glm-asr-2512` | GLM のモデル名 | +| `-gurl, --glm_url` | `https://open.bigmodel.cn/api/paas/v4/audio/transcriptions` | GLM の API URL | + +業務スペース ID は[アリババクラウド百煉の業務スペース設定](https://bailian.console.aliyun.com/cn-beijing/settings/workspace)で取得できます。業務スペース・API Key・リージョンを一致させてください。Paraformer は北京の既定エンドポイントを使用し、`--dashscope_region` は使用しません。 + +外部翻訳は Paraformer、GLM、Vosk、SOSV に適用します。翻訳を有効にし、Ollama または OpenAI 互換サービスを選択した場合、モデル名が必須です。OpenAI 互換サービスでは Base URL も必須です。Google 翻訳には追加設定が不要ですが、Google へのネットワーク接続が必要です。LiveTranslate は内蔵翻訳を使用するため、外部翻訳の引数を使用しません。 + +例:システム音声を日本語で認識し、Ollama で中国語に翻訳します(`engine` ディレクトリで実行): + +```bash +uv run python main.py -e paraformer -a 0 -s ja -t zh --dashscope_api_key --translation_provider ollama --translation_model qwen2.5:0.5b +``` + +Vosk を翻訳なしで使用する例: + +```bash +uv run python main.py -e vosk -a 0 -t none -vosk +``` + +### 単一実行ファイルを使用する + +Windows は `main.exe`、macOS/Linux は `./main` の後に同じ引数を指定します。`main.py` と uv は使用しません。ローカルモデルを使用する場合は、別途モデルをダウンロードしてパスを指定してください。Linux と macOS のシステム音声取得には上記の設定が必要です。 diff --git a/docs/user-manual/zh.md b/docs/user-manual/zh.md index 3b587e3..4fa5965 100644 --- a/docs/user-manual/zh.md +++ b/docs/user-manual/zh.md @@ -1,10 +1,10 @@ # Auto Caption 用户手册 -对应版本:v1.1.1 +对应版本:v1.2.0 ## 软件简介 -Auto Caption 是一个跨平台的字幕显示软件,能够实时获取系统音频输入(录音)或输出(播放声音)的流式数据,并调用音频转文字的模型生成对应音频的字幕。软件提供的默认字幕引擎(使用阿里云 Gummy 模型)支持九种语言(中、英、日、韩、德、法、俄、西、意)的识别与翻译。 +Auto Caption 是一个跨平台的字幕显示软件,能够实时获取系统音频输入(录音)或输出(播放声音)的流式数据,并调用音频转文字的模型生成对应音频的字幕。默认 Paraformer 引擎支持八种源语言(含粤语),翻译通过外部服务提供。 目前软件默认字幕引擎在 Windows、 macOS 和 Linux 平台下均拥有完整功能,在 macOS 要获取系统音频输出需要额外配置。 @@ -20,24 +20,31 @@ Auto Caption 是一个跨平台的字幕显示软件,能够实时获取系统 ![](../../assets/media/main_zh.png) +### 项目关于 + +点击主界面“项目关于”下的 GitHub 图标,打开关于窗口。窗口顶部展示软件图标,显示软件版本、更新日期和作者信息,并以两行两列的卡片布局提供项目仓库、用户手册、字幕引擎手册和更新日志入口,各链接显示完整地址。手册链接随界面语言切换,更新日志链接指向统一的更新记录,窗口配色跟随深浅主题。点击作者姓名 [HiMeditator](https://github.com/HiMeditator) 可在默认浏览器中打开作者的 GitHub 主页。 + ### 软件缺点 -要使用默认的 Gummy 字幕引擎需要获取阿里云的 API KEY。 +要使用默认的 Paraformer 字幕引擎需要获取阿里云的 API KEY。 在 macOS 平台获取音频输出需要额外配置。 软件使用 Electron 构建,因此软件体积不可避免的较大。 -## Gummy 引擎使用前准备 +## Paraformer Realtime v2 -要使用软件提供的默认字幕引擎(阿里云 Gummy),需要从阿里云百炼平台获取 API KEY,然后将 API KEY 添加到软件设置中或者配置到环境变量中(仅 Windows 平台支持读取环境变量中的 API KEY)。 +默认引擎为阿里云 `paraformer-realtime-v2`,通过 DashScope Recognition SDK 双向流式识别。上传音频为 16kHz、16bit、单声道 PCM;支持中文、英文、日语、韩语、德语、法语、俄语及粤语,`auto` 不指定语言提示。模型不提供内置翻译;开启翻译后需配置 Ollama、OpenAI 兼容服务或 Google 翻译,完整句子返回后异步翻译。设置中的阿里云 API Key 或 `DASHSCOPE_API_KEY` 环境变量用于鉴权(所有平台);CLI 参数为 `--dashscope_api_key`。当前使用北京地域默认端点,API Key 必须匹配该地域。旧 Gummy 引擎选项和 API Key 自动迁移,旧西班牙语/意大利语源语言改为自动检测;旧自定义命令需手动更新。详见[阿里云官方 SDK 文档](https://help.aliyun.com/zh/model-studio/paraformer-real-time-speech-recognition-python-sdk)。 -**国际版的阿里云服务并没有提供 Gummy 模型,因此目前非中国用户无法使用默认字幕引擎。** +## Qwen LiveTranslate -这部分阿里云提供了详细的教程,可参考: +Qwen LiveTranslate 使用 `qwen3.8-livetranslate-flash-realtime`,在同一个 WebSocket 会话中流式返回原文和译文,无需配置外部翻译服务。默认仍为 Paraformer。选择此引擎后填写业务空间 ID、地域(北京或新加坡),并在更多设置中填写该业务空间和地域的阿里云 API Key;也可使用 `DASHSCOPE_WORKSPACE_ID` 和 `DASHSCOPE_API_KEY` 环境变量。源语言自动检测,界面提供中、英、日、韩、德、法、俄、西、意作为翻译目标语言。音频上传为 16kHz、16bit、单声道 PCM,仅输出文字,不播放翻译语音。关闭翻译仅隐藏译文,服务端仍执行翻译;只需要识别时建议选择 Paraformer。 -- [获取 API KEY](https://help.aliyun.com/zh/model-studio/get-api-key) -- [将 API Key 配置到环境变量](https://help.aliyun.com/zh/model-studio/configure-api-key-through-environment-variables) +[API / WebSocket](https://help.aliyun.com/zh/model-studio/qwen3-5-livetranslate-flash-realtime) + +```bash +uv run python main.py -e livetranslate -t zh --dashscope_api_key --dashscope_workspace_id --dashscope_region cn-beijing +``` ## GLM 引擎使用前准备 @@ -107,6 +114,8 @@ sudo yum install pulseaudio pavucontrol 字幕设置可以分为三类:通用设置、字幕引擎设置、字幕样式设置。需要注意的是,修改通用设置是立即生效的。但是对于其他两类设置,修改后需要点击对应设置模块右上角的“应用”选项,更改才会真正生效。如果点击“取消更改”那么当前修改将不会被保存,而是回退到上次修改的状态。 +字幕样式预览框右上角提供与字幕窗口相同样式的关闭图标,图标颜色跟随原文字幕颜色。点击后立即隐藏预览,并将“字幕样式设置”中的“显示预览”开关关闭,同时弹出提示说明预览已关闭,可在字幕样式设置中重新开启。重新开启该开关即可显示预览;点击“应用样式”保存此设置,点击“取消更改”则恢复上次保存的状态。 + ### 启动和关闭字幕 在修改完全部配置后,点击界面的“启动字幕引擎”按钮,即可启动字幕。如果需要独立的字幕展示窗口,单击界面的“打开字幕窗口”按钮即可激活独立的字幕展示窗口。如果需要暂停字幕识别,单击界面的“关闭字幕引擎”按钮即可。 @@ -123,9 +132,19 @@ sudo yum install pulseaudio pavucontrol ## 字幕引擎 +字幕引擎选项按 Qwen LiveTranslate 3.8、Paraformer、GLM-ASR、Vosk、SOSV 排列,云端模型在前。本地模型标注运行位置:`本地 / CPU / Vosk`、`本地 / CPU / SOSV`。 + +在字幕引擎设置中,每个模型的描述显示在模型设置顶部,介绍识别方式、配置要求和翻译支持。切换模型时描述立即更新;描述框带有边框,背景和边框使用当前主题色的配色,并适配明暗主题。 + +浅色主题选择最后一种黑色主题色时,模型和翻译描述框使用浅灰背景与灰色边框,保持文字清晰可读。 + +模型、音频、自定义引擎和更多设置位于前半部分;启用翻译与启用录制在后半部分的同一行显示,其后为翻译语言、翻译服务及其模型名称、Base URL 和 API Key。两部分之间显示与模型描述相同样式的翻译服务说明,随 Ollama、OpenAI 兼容服务或 Google 翻译的选择立即更新。选择 Qwen LiveTranslate 时显示内置翻译说明,不显示外部翻译服务的配置项。 + +将鼠标悬停在“音频类型”“启用录制”“自定义引擎”“启用翻译”或“更多设置”的标签上,可查看对应功能和配置要求。选择 Qwen LiveTranslate 时,“业务空间 ID”和“阿里云地域”也提供悬浮说明,提示业务空间、API Key 与地域的匹配要求,以及业务空间 ID 留空时使用环境变量。业务空间 ID 的说明还提供[阿里云百炼业务空间设置](https://bailian.console.aliyun.com/cn-beijing/settings/workspace)链接,方便获取自己的业务空间 ID。带有悬浮说明的标签使用主题色显示;Qwen LiveTranslate 的翻译提示会说明关闭翻译仅隐藏译文、服务端仍执行翻译。 + 所谓的字幕引擎实际上是一个子程序,它会实时获取系统音频输入(录音)或输出(播放声音)的流式数据,并调用音频转文字的模型生成对应音频的字幕。生成的字幕通过转换为字符串的 JSON 数据,并通过标准输出传递给主程序。主程序读取字幕数据,处理后显示在窗口上。 -软件提供了两个默认的字幕引擎,如果你需要其他的字幕引擎,可以通过打开自定义引擎选项来调用其他字幕引擎(其他引擎需要针对该软件进行开发)。其中引擎路径是自定义字幕引擎在你的电脑上的路径,引擎指令是自定义字幕引擎的运行参数,这部分需要按该字幕引擎的规则进行填写。 +软件提供了五个内置字幕引擎,如果你需要其他的字幕引擎,可以通过打开自定义引擎选项来调用其他字幕引擎(其他引擎需要针对该软件进行开发)。其中引擎路径是自定义字幕引擎在你的电脑上的路径,引擎指令是自定义字幕引擎的运行参数,这部分需要按该字幕引擎的规则进行填写。 ![](../img/02_zh.png) @@ -149,9 +168,9 @@ sudo yum install pulseaudio pavucontrol #### `-e , --caption_engine` -需要选择的字幕引擎模型,目前有四个可用,分别为:`gummy, glm, vosk, sosv`。 +需要选择的字幕引擎模型,目前有五个可用,分别为:`paraformer, livetranslate, glm, vosk, sosv`。 -该项的默认值为 `gummy`。 +该项的默认值为 `paraformer`。 该项适用于所有模型。 @@ -201,21 +220,29 @@ sudo yum install pulseaudio pavucontrol 但是指定源语言能在一定程度上提高识别准确率,可用使用上面的语言代码指定源语言。 -该项适用于 Gummy、GLM 和 SOSV 模型。 +该项适用于 Paraformer、GLM 和 SOSV 模型。 -其中 Gummy 模型可用使用上述全部的语言,在加上粤语(`yue`)。 +Paraformer 支持 `zh, en, ja, ko, de, fr, ru, yue` 和 `auto`,不支持西班牙语和意大利语识别。 GLM 模型支持指定的语言有:英语、中文、日语、韩语。 SOSV 模型支持指定的语言有:英语、中文、日语、韩语、粤语。 -#### `-k, --api_key` +#### `--dashscope_api_key` -指定 `Gummy` 模型需要使用的阿里云 API KEY。 +指定 Paraformer 和 Qwen LiveTranslate 使用的阿里云 API Key;留空时读取 `DASHSCOPE_API_KEY` 环境变量,密钥须与服务地域匹配。 该项默认值为空。 -该项仅适用于 Gummy 模型。 +该项适用于 Paraformer 和 Qwen LiveTranslate 模型。 + +#### `--dashscope_workspace_id` + +Qwen LiveTranslate 的业务空间 ID,默认值为空,留空时读取 `DASHSCOPE_WORKSPACE_ID` 环境变量。请在[阿里云百炼业务空间设置](https://bailian.console.aliyun.com/cn-beijing/settings/workspace)中获取自己的业务空间 ID,业务空间、API Key 和地域必须匹配。 + +#### `--dashscope_region` + +Qwen LiveTranslate 的服务地域,默认 `cn-beijing`(北京),也支持 `ap-southeast-1`(新加坡)。Paraformer 当前使用北京默认端点,不使用此参数。 #### `-gkey, --glm_api_key` @@ -229,36 +256,37 @@ SOSV 模型支持指定的语言有:英语、中文、日语、韩语、粤语 指定 `glm` 模型需要使用的 API URL,默认值为:`https://open.bigmodel.cn/api/paas/v4/audio/transcriptions`。 -#### `-tm, --translation_model` +#### `--translation_provider` -指定 Vosk 和 SOSV 模型的翻译方式,默认为 `ollama`。 +指定 Paraformer、Vosk、SOSV 和 GLM 字幕引擎使用的翻译服务,默认为 `ollama`。 该项支持的值有: - `ollama` 使用本地 Ollama 模型进行翻译,需要用户安装 Ollama 软件和对应的模型 +- `openai` 使用 OpenAI API 兼容服务进行翻译 - `google` 使用 Google 翻译 API 进行翻译,无需额外配置,但是需要有能访问 Google 的网络 -该项仅适用于 Vosk 和 SOSV 模型。 +该项仅适用于没有内置翻译功能的字幕引擎。 -#### `-omn, --ollama_name` +#### `--translation_model` -指定要使用的翻译模型名称,可以是 Ollama 本地模型,也可以是 OpenAI API 兼容的云端模型。若未填写 Base URL 字段,则默认调用本地 Ollama 服务,否则会通过 Python OpenAI 库调用该地址指向的 API 服务。 +指定 Ollama 或 OpenAI API 兼容服务使用的模型名称。Google 翻译不使用此参数。 如果使用 Ollama 模型,建议使用参数量小于 1B 的模型,比如: `qwen2.5:0.5b`, `qwen3:0.6b`。需要在 Ollama 中下载了对应的模型才能正常使用。 -默认值为空,适用于除了 Gummy 外的其他模型。 +默认值为空。 -#### `-ourl, --ollama_url` +#### `--translation_base_url` -调用 OpenAI API 的基础请求地址,如果不填写则调用本地默认端口的 Ollama 模型。 +OpenAI API 兼容服务的基础请求地址,例如 `https://api.openai.com/v1`。仅当 `--translation_provider openai` 时使用。 -默认值为空,适用于除了 Gummy 外的其他模型。 +默认值为空,适用于 Paraformer、GLM、Vosk 和 SOSV。 -#### `-okey, --ollama_api_key` +#### `--translation_api_key` -指定调用 OpenAI 兼容模型的 API KEY。 +指定调用 OpenAI API 兼容服务的 API Key。仅当 `--translation_provider openai` 时使用。 -默认值为空,适用于除了 Gummy 外的其他模型。 +默认值为空,适用于 Paraformer、GLM、Vosk 和 SOSV。 #### `-vosk, --vosk_model` @@ -276,18 +304,26 @@ SOSV 模型支持指定的语言有:英语、中文、日语、韩语、粤语 > 以下内容默认使用该方式的用户对 Python 环境配置和使用有所了解。 -首先下载项目源代码到本地,其中字幕引擎源代码在项目的 `engine` 目录下。然后配置 Python 环境,其中项目依赖的 Python 包在 `engine` 目录下 `requirements.txt` 文件中。 +下载项目源代码并安装 [uv](https://docs.astral.sh/uv/getting-started/installation/),然后进入 `engine` 目录。依赖由 `pyproject.toml` 声明,`uv.lock` 在本地自动生成并由 Git 忽略,默认使用 Python 3.12: -配置好后进入 `engine` 目录,执行命令进行运行字幕引擎。 +```bash +cd engine +uv sync +uv run python main.py --help +``` -比如要使用 Gummy 模型,指定音频类型为系统音频输出,源语言为英语,翻译语言为中文,执行的命令如下: +无需激活虚拟环境;下列源码命令均在 `engine` 目录使用 `uv run python main.py` 执行。录音所需的平台系统依赖见 README 的引擎构建说明。发布版应用使用内置引擎,不需要安装 uv。 + +比如要使用 Paraformer 模型,指定音频类型为系统音频输出,源语言为英语,翻译语言为中文,执行的命令如下: > 注意:为了更直观,下面的命令写在了多行,如果执行失败,尝试去掉反斜杠,并改换单行命令执行。 ```bash -python main.py \ --e gummy \ --k sk-******************************** \ +uv run python main.py \ +-e paraformer \ +--dashscope_api_key sk-******************************** \ +--translation_provider ollama \ +--translation_model qwen2.5:0.5b \ -a 0 \ -d 1 \ -s en \ @@ -297,18 +333,33 @@ python main.py \ 指定 Vosk 模型,指定音频类型为系统音频输出,翻译语言为英语,使用 Ollama `qwen3:0.6b` 模型进行翻译: ```bash -python main.py \ +uv run python main.py \ -e vosk \ -vosk D:\Projects\auto-caption\engine\models\vosk-model-small-cn-0.22 \ -a 0 \ -d 1 \ -t en \ +--translation_provider ollama \ +--translation_model qwen3:0.6b +``` + +如果改用 OpenAI API 兼容服务,需要明确指定服务类型、模型和 Base URL: + +```bash +uv run python main.py \ +-e vosk \ +-vosk D:\Projects\auto-caption\engine\models\vosk-model-small-cn-0.22 \ +-t en \ +--translation_provider openai \ +--translation_model gpt-4.1-mini \ +--translation_base_url https://api.openai.com/v1 \ +--translation_api_key sk-******************************** ``` 指定 SOSV 模型,指定音频类型为麦克风,自动选择源语言,不翻译,执行的命令如下: ```bash -python main.py \ +uv run python main.py \ -e sosv \ -sosv D:\\Projects\\auto-caption\\engine\\models\\sosv-int8 \ -a 1 \ @@ -317,7 +368,7 @@ python main.py \ -t none ``` -使用 Gummy 模型的运行效果如下: +使用 Paraformer 模型的运行效果如下: ![](../img/07.png) @@ -327,4 +378,6 @@ python main.py \ 然后再字幕引擎可执行文件所在目录打开终端,执行命令进行运行字幕引擎。 -只需要将上述指令中的 `python main.py` 替换为可执行文件名称即可(比如:`engine-win.exe`)。 \ No newline at end of file +只需要将上述指令中的 `uv run python main.py` 替换为可执行文件名称即可(比如:`engine-win.exe`)。 + +LiveTranslate CLI:`--dashscope_workspace_id` 指定业务空间 ID(空值时读取环境变量),`--dashscope_region` 指定地域,默认为 `cn-beijing`,也支持 `ap-southeast-1`。`--dashscope_api_key` 与 Paraformer 共用。`-s` 不用于该模型;`-t none` 仅隐藏译文。外部翻译参数不用于 LiveTranslate。 diff --git a/electron-builder.yml b/electron-builder.yml index 55b23d9..c9f2e01 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,5 +1,6 @@ appId: com.himeditator.autocaption productName: Auto Caption +artifactName: ${name}-v${version}.${ext} directories: buildResources: build files: @@ -15,18 +16,14 @@ files: - '!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 + - '!.uv-cache/**' win: + extraResources: + - from: ./engine/dist/main.exe + to: ./engine/main.exe executableName: auto-caption icon: build/icon.png nsis: - artifactName: ${name}-${version}-setup.${ext} shortcutName: ${productName} uninstallDisplayName: ${productName} createDesktopShortcut: always @@ -34,6 +31,9 @@ nsis: perMachine: true allowToChangeInstallationDirectory: true mac: + extraResources: + - from: ./engine/dist/main + to: ./engine/main entitlementsInherit: build/entitlements.mac.plist extendInfo: - NSCameraUsageDescription: Application requests access to the device's camera. @@ -41,9 +41,10 @@ mac: - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. notarize: false -dmg: - artifactName: ${name}-${version}.${ext} linux: + extraResources: + - from: ./engine/dist/main + to: ./engine/main target: # - AppImage # - snap @@ -51,8 +52,6 @@ linux: - tar.gz maintainer: electronjs.org category: Utility -appImage: - artifactName: ${name}-${version}.${ext} npmRebuild: false publish: provider: generic diff --git a/engine/.python-version b/engine/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/engine/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/engine/README.md b/engine/README.md new file mode 100644 index 0000000..33f841b --- /dev/null +++ b/engine/README.md @@ -0,0 +1,152 @@ +# Auto Caption 字幕引擎 + +对应版本:v1.2.0 + +[项目首页](../README.md) + +Auto Caption 的 Python 字幕引擎负责系统音频或麦克风采集、语音识别和翻译。可以独立通过命令行运行,也可以作为 Electron 应用的子进程使用;独立使用无需 Node.js 或 npm。 + +## 环境准备 + +安装 [uv](https://docs.astral.sh/uv/getting-started/installation/),下载或克隆项目后进入 `engine`。除注明“仓库根目录”的 npm 命令外,下文命令均在此目录执行。 + +```bash +cd engine +uv sync +uv run python main.py --help +``` + +`uv.lock` 在本地由 uv 自动生成,已加入 `.gitignore`,不提交到仓库。`uv sync` 优先复用已有的本地锁文件,文件不存在或依赖配置变化时会生成或更新它;测试和构建使用的 `uv run` 也会自动同步环境。不同开发者或新克隆环境可能解析到不同版本。 + +`.python-version` 默认选择 Python 3.12。uv 按照 `pyproject.toml` 和 `uv.lock` 创建或同步 `.venv`,无需手动激活环境;首次准备需要联网下载依赖。默认包含 PyInstaller 开发依赖。仅运行源码时可用 `uv sync --no-dev`,后续对应使用 `uv run --no-dev python main.py ...`。 + +| 平台 | 音频准备 | +| ------- | -------------------------------------------------------------------------------------------------------------------- | +| Windows | uv 安装 PyAudioWPatch;系统音频输出通过播放设备的回环采集 | +| macOS | PyAudio 需要 PortAudio,例如 `brew install portaudio`;系统音频输出需要 BlackHole 和音频路由配置,麦克风需要系统授权 | +| Linux | 需要 `pactl`、`parec`,Ubuntu 可安装 `pulseaudio-utils`;需要可用的 PulseAudio 或 PipeWire PulseAudio 兼容服务 | + +macOS/Linux 的配置步骤见[用户手册](../docs/user-manual/zh.md)。 + +## 识别引擎 + +| `-e` | 引擎 | 运行前需要准备 | +| -------------------- | ---------------------------------- | --------------------------------------------------------- | +| `paraformer`(默认) | 阿里云 Paraformer Realtime v2 | 北京地域 API Key;翻译使用外部服务 | +| `livetranslate` | 阿里云 Qwen LiveTranslate | API Key、业务空间 ID、匹配的地域;自带流式翻译 | +| `glm` | GLM-ASR | 智谱 API Key,通过 `--glm_api_key` 指定;模型和端点可配置 | +| `vosk` | 本地 Vosk | 下载并解压模型,使用 `--vosk_model` 指定目录 | +| `sosv` | 本地 Sherpa-ONNX SenseVoice 整合包 | 下载 SOSV 整合包,使用 `--sosv_model` 指定目录 | + +uv 不下载本地识别模型。下载入口:[Vosk Models](https://alphacephei.com/vosk/models)、[SOSV 整合包](https://github.com/HiMeditator/auto-caption/releases/tag/sosv-model)。SOSV 需要整合包内的识别、端点检测和标点模型,不能只提供单个 SenseVoice 模型文件。 + +云端识别会将采集的音频发送给提供商;本地识别开启云端翻译时,会将字幕文本发送给翻译服务。 + +## 快速运行 + +示例均为单行命令,适用于 PowerShell 和常见 Unix Shell。替换模型路径、API Key 和业务空间 ID 后运行,按 `Ctrl+C` 停止。 + +### Vosk:麦克风识别,不翻译 + +```bash +uv run python main.py -e vosk -a 1 --vosk_model "./models/vosk-model-small-cn-0.22" -t none +``` + +### Paraformer:中文麦克风识别,不翻译 + +```bash +uv run python main.py -e paraformer -a 1 -s zh -t none --dashscope_api_key "your-api-key" +``` + +也可通过 `DASHSCOPE_API_KEY` 环境变量提供密钥。Paraformer 当前使用北京地域默认端点,密钥需要匹配该地域。 + +### LiveTranslate:麦克风识别并翻译为英语 + +```bash +uv run python main.py -e livetranslate -a 1 -t en --dashscope_api_key "your-api-key" --dashscope_workspace_id "your-workspace-id" --dashscope_region cn-beijing +``` + +业务空间 ID 可通过 `DASHSCOPE_WORKSPACE_ID` 提供。地域支持 `cn-beijing` 和 `ap-southeast-1`,需要与业务空间和密钥匹配。源语言自动检测;`-t none` 只隐藏译文,服务端仍执行翻译。只需要识别时可选择 Paraformer。 + +### Vosk + Ollama:本地识别与翻译 + +先启动 Ollama 并准备模型,再运行: + +```bash +uv run python main.py -e vosk -a 1 --vosk_model "./models/vosk-model-small-cn-0.22" -t en --translation_provider ollama --translation_model "qwen2.5:0.5b" +``` + +外部翻译还支持 `openai` 和 `google`。OpenAI 兼容服务需要 `--translation_model`、`--translation_base_url` 及服务要求的 `--translation_api_key`。Ollama 和 OpenAI 启用翻译时必须指定模型,OpenAI 还必须指定 Base URL。LiveTranslate 不使用外部翻译参数。 + +## 常用参数与输出 + +| 参数 | 作用及默认值 | +| -------------------------- | --------------------------------------------------------------- | +| `-e` / `--caption_engine` | 识别引擎,默认 `paraformer` | +| `-a` / `--audio_type` | `0` 为系统音频输出(默认),`1` 为麦克风 | +| `-s` / `--source_language` | 源语言,默认 `auto`;支持语言取决于引擎,LiveTranslate 自动检测 | +| `-t` / `--target_language` | 翻译目标语言,默认 `none`;LiveTranslate 的隐藏译文行为见上文 | +| `-c` / `--chunk_rate` | 每秒音频块数,默认 `10` | +| `-r` / `--record` | `1` 启用 WAV 录音,默认 `0` | +| `-rp` / `--record_path` | 录音目录,默认为当前工作目录;请预先创建目录 | +| `-p` / `--port` | 本地 TCP 命令端口,默认 `0` 表示不启动 Socket 服务 | + +字幕结果和引擎事件使用逐行 JSON,例如: + +```json +{ + "command": "caption", + "index": 1, + "time_s": "12:00:00.000", + "time_t": "12:00:01.000", + "text": "Hello", + "translation": "" +} +``` + +完整参数见 `--help` 和[用户手册](../docs/user-manual/zh.md#单独使用字幕引擎)。接入其他宿主时,按[字幕引擎通信协议](../docs/api-docs/caption-engine.md)处理消息、生命周期及当前通信限制。JSON 输出不会自动保存为 SRT 文件,桌面端提供字幕记录和导出功能。 + +## 测试与依赖维护 + +```bash +uv run python -m unittest discover -s tests -v +``` + +现有离线测试使用模拟服务,覆盖 Paraformer 和 LiveTranslate 的参数、事件处理及停止逻辑,不需要 API Key、音频设备或模型文件;环境准备仍可能需要联网。真实音频、云端服务及其他平台需要另行验证。 + +运行依赖使用 `uv add ` 添加,开发依赖使用 `uv add --dev `。有意更新本地锁定版本时执行 `uv lock --upgrade`,然后 `uv sync` 并运行测试。依赖声明变更提交 `pyproject.toml`,不提交本地 `uv.lock`。 + +Sherpa-ONNX 与 `sherpa-onnx-core` 固定为匹配版本,避免源码包元数据遗漏原生运行库,升级时应同时修改两个版本。检查依赖一致性可用 `uv pip check --python .venv/Scripts/python.exe`(Windows)或 `uv pip check --python .venv/bin/python`(macOS/Linux)。 + +## 打包 + +```bash +uv run pyinstaller --noconfirm --clean main.spec +``` + +Vosk 路径从当前 Python 环境自动发现,无需手动修改 `main.spec`。必须在目标操作系统上构建;PyInstaller 不支持跨操作系统编译。 + +| 平台 | 本目录内的单文件产物 | 桌面端资源目录中的位置 | +| ----------- | -------------------- | ---------------------- | +| Windows | `dist/main.exe` | `engine/main.exe` | +| macOS/Linux | `dist/main` | `engine/main` | + +Windows 可用 `./dist/main.exe --help` 检查产物,macOS/Linux 使用 `./dist/main --help`。打包后的引擎运行无需 uv 或 Python,本地模型和平台音频配置仍需单独准备。macOS 应用资源位于 `Contents/Resources`。 + +仓库根目录还提供 `npm run engine:sync`、`npm run engine:test`、`npm run build:engine`。`npm run build:win`、`npm run build:mac`、`npm run build:linux` 会先构建引擎,再构建桌面端安装包;这些命令需要桌面端的 Node.js/npm 环境。 + +## 目录与开发资料 + +```text +main.py CLI 入口与运行流程 +audio2text/ 五种识别引擎的适配器 +sysaudio/ 平台音频采集 +utils/ 音频处理、翻译、共享状态和通信 +tests/ 离线测试 +pyproject.toml uv 项目与依赖配置 +uv.lock 本地自动生成的依赖锁文件(Git 忽略) +.python-version 默认 Python 版本 +main.spec PyInstaller 配置 +``` + +`models/` 放置自行下载的模型,`.venv/`、`build/`、`dist/` 为本地环境或构建产物,不应作为源码提交。开发自定义引擎或新增适配器,请参考[引擎说明](../docs/engine-manual/zh.md)与[通信协议](../docs/api-docs/caption-engine.md)。许可证见 [LICENSE](../LICENSE)。 diff --git a/engine/audio2text/__init__.py b/engine/audio2text/__init__.py index c2f19d4..0445524 100644 --- a/engine/audio2text/__init__.py +++ b/engine/audio2text/__init__.py @@ -1,4 +1,5 @@ -from .gummy import GummyRecognizer +from .paraformer import ParaformerRecognizer +from .livetranslate import LiveTranslateRecognizer from .vosk import VoskRecognizer from .sosv import SosvRecognizer from .glm import GlmRecognizer diff --git a/engine/audio2text/glm.py b/engine/audio2text/glm.py index e39021c..b5729a1 100644 --- a/engine/audio2text/glm.py +++ b/engine/audio2text/glm.py @@ -8,7 +8,7 @@ from datetime import datetime from utils import shared_data -from utils import stdout_cmd, stdout_obj, google_translate, ollama_translate +from utils import stdout_cmd, stdout_obj, get_translation_function class GlmRecognizer: """ @@ -20,22 +20,19 @@ class GlmRecognizer: api_key: GLM-ASR API Key source: 源语言 target: 目标语言 - trans_model: 翻译模型名称 - ollama_name: Ollama 模型名称 + translation_provider: 翻译服务 + translation_model: 翻译模型名称 """ - def __init__(self, url: str, model: str, api_key: str, source: str, target: str | None, trans_model: str, ollama_name: str, ollama_url: str = '', ollama_api_key: str = ''): + def __init__(self, url: str, model: str, api_key: str, source: str, target: str | None, translation_provider: str, translation_model: str, translation_base_url: str = '', translation_api_key: str = ''): self.url = url self.model = model self.api_key = api_key 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.ollama_url = ollama_url - self.ollama_api_key = ollama_api_key + self.translation_function = get_translation_function(translation_provider) + self.translation_model = translation_model + self.translation_base_url = translation_base_url + self.translation_api_key = translation_api_key self.audio_buffer = [] self.is_speech = False @@ -140,18 +137,11 @@ def output_caption(self, text, time_s, index): } if self.target: - if self.trans_func == ollama_translate: - th = threading.Thread( - target=self.trans_func, - args=(self.ollama_name, self.target, caption['text'], time_s, self.ollama_url, self.ollama_api_key), - daemon=True - ) - else: - th = threading.Thread( - target=self.trans_func, - args=(self.ollama_name, self.target, caption['text'], time_s), - daemon=True - ) + th = threading.Thread( + target=self.translation_function, + args=(self.translation_model, self.target, caption['text'], time_s, self.translation_base_url, self.translation_api_key), + daemon=True + ) th.start() stdout_obj(caption) diff --git a/engine/audio2text/gummy.py b/engine/audio2text/gummy.py deleted file mode 100644 index 2f31a5c..0000000 --- a/engine/audio2text/gummy.py +++ /dev/null @@ -1,117 +0,0 @@ -from dashscope.audio.asr import ( - TranslationRecognizerCallback, - TranscriptionResult, - TranslationResult, - TranslationRecognizerRealtime -) -import dashscope -from dashscope.common.error import InvalidParameter -from datetime import datetime -from utils import stdout_cmd, stdout_obj, stdout_err -from utils import shared_data - -class Callback(TranslationRecognizerCallback): - """ - 语音大模型流式传输回调对象 - """ - def __init__(self): - super().__init__() - self.index = 0 - self.usage = 0 - self.cur_id = -1 - self.time_str = '' - - def on_open(self) -> None: - self.usage = 0 - self.cur_id = -1 - self.time_str = '' - stdout_cmd('info', 'Gummy translator started.') - - def on_close(self) -> None: - stdout_cmd('info', 'Gummy translator closed.') - stdout_cmd('usage', str(self.usage)) - - def on_event( - self, - request_id, - transcription_result: TranscriptionResult, - translation_result: TranslationResult, - usage - ) -> None: - caption = {} - - if transcription_result is not None: - if self.cur_id != transcription_result.sentence_id: - self.time_str = datetime.now().strftime('%H:%M:%S.%f')[:-3] - self.cur_id = transcription_result.sentence_id - self.index += 1 - caption['command'] = 'caption' - caption['index'] = self.index - caption['time_s'] = self.time_str - caption['time_t'] = datetime.now().strftime('%H:%M:%S.%f')[:-3] - caption['text'] = transcription_result.text - caption['translation'] = "" - - if translation_result is not None: - lang = translation_result.get_language_list()[0] - caption['translation'] = translation_result.get_translation(lang).text - - if usage: - self.usage += usage['duration'] - - if 'text' in caption: - stdout_obj(caption) - - -class GummyRecognizer: - """ - 使用 Gummy 引擎流式处理的音频数据,并在标准输出中输出与 Auto Caption 软件可读取的 JSON 字符串数据 - - 初始化参数: - rate: 音频采样率 - source: 源语言代码字符串(zh, en, ja 等) - target: 目标语言代码字符串(zh, en, ja 等) - api_key: 阿里云百炼平台 API KEY - """ - def __init__(self, rate: int, source: str, target: str | None, api_key: str | None): - if api_key: - dashscope.api_key = api_key - self.translator = TranslationRecognizerRealtime( - model = "gummy-realtime-v1", - format = "pcm", - sample_rate = rate, - transcription_enabled = True, - translation_enabled = (target is not None), - source_language = source, - translation_target_languages = [target], - callback = Callback() - ) - - def start(self): - """启动 Gummy 引擎""" - self.translator.start() - - 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 引擎""" - try: - self.translator.stop() - except Exception: - return diff --git a/engine/audio2text/livetranslate.py b/engine/audio2text/livetranslate.py new file mode 100644 index 0000000..fb2d0f8 --- /dev/null +++ b/engine/audio2text/livetranslate.py @@ -0,0 +1,183 @@ +"""Qwen3.8 LiveTranslate: streaming source transcription and translation.""" +import base64 +import json +import os +import queue +import re +import threading +from collections import OrderedDict +from datetime import datetime, timedelta + +import websocket +from utils import shared_data, stdout_cmd, stdout_obj, stdout_err + +MODEL = 'qwen3.8-livetranslate-flash-realtime' +LANGUAGES = ('zh', 'en', 'ja', 'ko', 'de', 'fr', 'ru', 'es', 'it', 'pt') + + +class LiveTranslateRecognizer: + def __init__(self, target, api_key='', workspace_id='', region='cn-beijing'): + self.api_key = api_key or os.getenv('DASHSCOPE_API_KEY', '') + workspace_id = workspace_id or os.getenv('DASHSCOPE_WORKSPACE_ID', '') + if not self.api_key: + raise ValueError('LiveTranslate requires an API key or DASHSCOPE_API_KEY.') + if not re.fullmatch(r'[A-Za-z0-9-]+', workspace_id): + raise ValueError('LiveTranslate requires a valid DashScope workspace ID.') + if region not in ('cn-beijing', 'ap-southeast-1'): + raise ValueError('Unsupported DashScope region.') + if target is not None and target not in LANGUAGES: + raise ValueError('Unsupported LiveTranslate target language: ' + target) + self.target = target + self.url = f'wss://{workspace_id}.{region}.maas.aliyuncs.com/api-ws/v1/realtime?model={MODEL}' + self.socket = None + self.receiver = None + self.ready = threading.Event() + self.finished = threading.Event() + self.stopping = False + self.failed = False + self.records = OrderedDict() + self.translations = OrderedDict() + self.index = 0 + self.last_time = datetime.min + + def fail(self, message): + if not self.failed: + self.failed = True + stdout_err(message) + shared_data.status = 'kill' + stdout_cmd('kill') + self.ready.set() + self.finished.set() + + def send(self, event): + self.socket.send(json.dumps(event)) + + def start(self): + self.socket = websocket.create_connection( + self.url, header={'Authorization': 'Bearer ' + self.api_key}, timeout=10) + self.socket.settimeout(0.5) + self.receiver = threading.Thread(target=self.receive, daemon=True) + self.receiver.start() + self.send({'type': 'session.update', 'session': { + 'output_modalities': ['text'], + 'audio': {'input': {'turn_detection': {'type': 'server_vad'}}}, + 'translation': {'language': self.target or 'en'} + }}) + if not self.ready.wait(10): + self.fail('LiveTranslate session configuration timed out.') + if not self.failed: + stdout_cmd('info', 'Qwen LiveTranslate recognizer started.') + + def receive(self): + try: + while not self.finished.is_set(): + try: + message = self.socket.recv() + except websocket.WebSocketTimeoutException: + continue + if not message: + if not self.finished.is_set(): + self.fail('LiveTranslate connection closed before session.finished.') + break + self.on_event(json.loads(message)) + except Exception as error: + if not self.finished.is_set(): + self.fail(str(error)) + + def record(self, item_id): + if item_id not in self.records: + self.index += 1 + now = max(datetime.now(), self.last_time + timedelta(milliseconds=1)) + self.last_time = now + self.records[item_id] = { + 'command': 'caption', 'index': self.index, 'update_by_time': True, + 'time_s': now.strftime('%H:%M:%S.%f')[:-3], + 'text': '', 'translation': '' + } + if len(self.records) > 256: + self.records.popitem(last=False) + return self.records[item_id] + + def publish(self, record): + if record['text'].strip(): + stdout_obj({**record, 'time_t': datetime.now().strftime('%H:%M:%S.%f')[:-3]}) + + def translated(self, item_id): + if item_id not in self.translations: + self.translations[item_id] = {'source_id': None, 'text': ''} + if len(self.translations) > 256: + self.translations.popitem(last=False) + return self.translations[item_id] + + def publish_translation(self, translation): + source_id = translation['source_id'] + if not self.target or not source_id: + return + record = self.record(source_id) + record['translation'] = translation['text'] + if record['text'].strip(): + stdout_obj({'command': 'translation', 'time_s': record['time_s'], + 'text': record['text'], 'translation': record['translation']}) + + def on_event(self, event): + kind = event.get('type', '') + if kind == 'session.updated': + self.ready.set() + elif kind == 'session.finished': + if not self.stopping: + self.fail('LiveTranslate session ended unexpectedly.') + self.finished.set() + elif kind in ('error', 'conversation.item.input_audio_transcription.failed'): + self.fail(str(event.get('error', {}).get('message', 'LiveTranslate request failed.'))) + elif kind == 'input_audio_buffer.speech_started': + self.record(event['item_id']) + elif kind == 'conversation.item.created' and event.get('item', {}).get('role') == 'assistant': + translation = self.translated(event['item']['id']) + translation['source_id'] = event.get('previous_item_id') + self.publish_translation(translation) + elif kind == 'conversation.item.input_audio_transcription.delta': + record = self.record(event['item_id']) + record['text'] += event.get('delta', '') + self.publish(record) + elif kind == 'conversation.item.input_audio_transcription.completed': + record = self.record(event['item_id']) + record['text'] = event.get('transcript', record['text']) + self.publish(record) + elif kind in ('response.text.delta', 'response.text.done'): + translation = self.translated(event['item_id']) + if kind.endswith('.delta'): + translation['text'] += event.get('delta', '') + else: + translation['text'] = event.get('text', translation['text']) + self.publish_translation(translation) + elif kind == 'response.done' and event.get('response', {}).get('status') in ('failed', 'incomplete'): + self.fail('LiveTranslate response failed or was incomplete.') + + def translate(self): + while shared_data.status == 'running': + try: + chunk = shared_data.chunk_queue.get(timeout=0.2) + except queue.Empty: + continue + if shared_data.status != 'running': + break + self.send({'type': 'input_audio_buffer.append', + 'audio': base64.b64encode(chunk).decode('ascii')}) + + def stop(self): + self.stopping = True + if self.socket is None: + return + try: + if not self.failed: + self.send({'type': 'session.finish'}) + if not self.finished.wait(2.5): + stdout_cmd('warn', 'LiveTranslate finish timed out; final results may be incomplete.') + except Exception as error: + if not self.failed: + stdout_err(str(error)) + finally: + self.finished.set() + self.socket.close() + if self.receiver: + self.receiver.join(timeout=0.5) diff --git a/engine/audio2text/paraformer.py b/engine/audio2text/paraformer.py new file mode 100644 index 0000000..5ed0e8b --- /dev/null +++ b/engine/audio2text/paraformer.py @@ -0,0 +1,117 @@ +"""DashScope Paraformer duplex streaming ASR adapter.""" +import os +import queue +import threading +from datetime import datetime + +import dashscope +from dashscope.audio.asr import Recognition, RecognitionCallback, RecognitionResult +from utils import shared_data, stdout_cmd, stdout_obj, stdout_err, get_translation_function + + +class Callback(RecognitionCallback): + def __init__(self, owner): + super().__init__() + self.owner = owner + self.index = 0 + self.sentence_key = None + self.time_str = '' + self.finished = False + + def on_open(self): + stdout_cmd('info', 'Paraformer recognizer started.') + + def on_close(self): + stdout_cmd('info', 'Paraformer recognizer closed.') + if not self.owner.stopping and shared_data.status == 'running': + self.owner.fail('Paraformer connection closed unexpectedly.') + + def on_complete(self): + if not self.owner.stopping and shared_data.status == 'running': + self.owner.fail('Paraformer recognition ended unexpectedly.') + + def on_error(self, result): + self.owner.fail(str(result.message)) + + def on_event(self, result: RecognitionResult): + sentence = result.get_sentence() + if not isinstance(sentence, dict) or not sentence.get('text', '').strip(): + return + key = (result.get_request_id(), sentence.get('begin_time')) + if key != self.sentence_key: + self.index += 1 + self.sentence_key = key + self.time_str = datetime.now().strftime('%H:%M:%S.%f')[:-3] + self.finished = False + if self.finished: + return + text = sentence['text'] + stdout_obj({ + 'command': 'caption', 'index': self.index, + 'time_s': self.time_str, + 'time_t': datetime.now().strftime('%H:%M:%S.%f')[:-3], + 'text': text, 'translation': '' + }) + if RecognitionResult.is_sentence_end(sentence): + self.finished = True + if self.owner.target: + threading.Thread( + target=self.owner.translation_function, + args=(self.owner.translation_model, self.owner.target, text, + self.time_str, self.owner.translation_base_url, + self.owner.translation_api_key), daemon=True + ).start() + + +class ParaformerRecognizer: + def __init__(self, rate, source, target, api_key, translation_provider, + translation_model, translation_base_url='', translation_api_key=''): + if source not in ('auto', 'zh', 'en', 'ja', 'yue', 'ko', 'de', 'fr', 'ru'): + raise ValueError('Unsupported Paraformer source language: ' + source) + dashscope.api_key = api_key or os.environ.get('DASHSCOPE_API_KEY', '') + if not dashscope.api_key: + raise ValueError('Paraformer requires an API key or DASHSCOPE_API_KEY.') + self.target = target + self.translation_function = get_translation_function(translation_provider) + self.translation_model = translation_model + self.translation_base_url = translation_base_url + self.translation_api_key = translation_api_key + self.stopping = False + self.failed = False + self.recognizer = Recognition( + model='paraformer-realtime-v2', format='pcm', sample_rate=rate, + language_hints=[] if source == 'auto' else [source], + heartbeat=True, callback=Callback(self) + ) + + def fail(self, message): + if not self.failed: + self.failed = True + stdout_err(message) + shared_data.status = 'kill' + stdout_cmd('kill') + + def start(self): + self.recognizer.start() + + def translate(self): + while shared_data.status == 'running': + try: + chunk = shared_data.chunk_queue.get(timeout=0.2) + except queue.Empty: + continue + if shared_data.status != 'running': + break + try: + self.recognizer.send_audio_frame(chunk) + except Exception as error: + self.fail(str(error)) + break + + def stop(self): + self.stopping = True + try: + self.recognizer.stop() + except Exception as error: + if not self.failed: + stdout_err(str(error)) diff --git a/engine/audio2text/sosv.py b/engine/audio2text/sosv.py index 4466fe3..855a05a 100644 --- a/engine/audio2text/sosv.py +++ b/engine/audio2text/sosv.py @@ -14,7 +14,7 @@ from utils import shared_data from utils import stdout_cmd, stdout_obj -from utils import google_translate, ollama_translate +from utils import get_translation_function class SosvRecognizer: @@ -26,10 +26,10 @@ class SosvRecognizer: vad_model: Silero VAD 模型路径 source: 识别源语言(auto, zh, en, ja, ko, yue) target: 翻译目标语言 - trans_model: 翻译模型名称 - ollama_name: Ollama 模型名称 + translation_provider: 翻译服务 + translation_model: 翻译模型名称 """ - def __init__(self, model_path: str, source: str, target: str | None, trans_model: str, ollama_name: str, ollama_url: str = '', ollama_api_key: str = ''): + def __init__(self, model_path: str, source: str, target: str | None, translation_provider: str, translation_model: str, translation_base_url: str = '', translation_api_key: str = ''): if model_path.startswith('"'): model_path = model_path[1:] if model_path.endswith('"'): @@ -40,13 +40,10 @@ def __init__(self, model_path: str, source: str, target: str | None, trans_model 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.ollama_url = ollama_url - self.ollama_api_key = ollama_api_key + self.translation_function = get_translation_function(translation_provider) + self.translation_model = translation_model + self.translation_base_url = translation_base_url + self.translation_api_key = translation_api_key self.time_str = '' self.cur_id = 0 self.prev_content = '' @@ -153,8 +150,8 @@ def send_audio_frame(self, data: bytes): stdout_obj(caption) if self.target: th = threading.Thread( - target=self.trans_func, - args=(self.ollama_name, self.target, caption['text'], self.time_str, self.ollama_url, self.ollama_api_key), + target=self.translation_function, + args=(self.translation_model, self.target, caption['text'], self.time_str, self.translation_base_url, self.translation_api_key), daemon=True ) th.start() diff --git a/engine/audio2text/vosk.py b/engine/audio2text/vosk.py index 480d298..751c5ba 100644 --- a/engine/audio2text/vosk.py +++ b/engine/audio2text/vosk.py @@ -5,7 +5,7 @@ from vosk import Model, KaldiRecognizer, SetLogLevel from utils import shared_data -from utils import stdout_cmd, stdout_obj, google_translate, ollama_translate +from utils import stdout_cmd, stdout_obj, get_translation_function class VoskRecognizer: @@ -15,10 +15,10 @@ class VoskRecognizer: 初始化参数: model_path: Vosk 识别模型路径 target: 翻译目标语言 - trans_model: 翻译模型名称 - ollama_name: Ollama 模型名称 + translation_provider: 翻译服务 + translation_model: 翻译模型名称 """ - def __init__(self, model_path: str, target: str | None, trans_model: str, ollama_name: str, ollama_url: str = '', ollama_api_key: str = ''): + def __init__(self, model_path: str, target: str | None, translation_provider: str, translation_model: str, translation_base_url: str = '', translation_api_key: str = ''): SetLogLevel(-1) if model_path.startswith('"'): model_path = model_path[1:] @@ -26,13 +26,10 @@ def __init__(self, model_path: str, target: str | None, trans_model: str, ollama 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.ollama_url = ollama_url - self.ollama_api_key = ollama_api_key + self.translation_function = get_translation_function(translation_provider) + self.translation_model = translation_model + self.translation_base_url = translation_base_url + self.translation_api_key = translation_api_key self.time_str = '' self.cur_id = 0 self.prev_content = '' @@ -67,8 +64,8 @@ def send_audio_frame(self, data: bytes): if self.target: th = threading.Thread( - target=self.trans_func, - args=(self.ollama_name, self.target, caption['text'], self.time_str, self.ollama_url, self.ollama_api_key), + target=self.translation_function, + args=(self.translation_model, self.target, caption['text'], self.time_str, self.translation_base_url, self.translation_api_key), daemon=True ) th.start() @@ -95,4 +92,4 @@ def translate(self): def stop(self): """停止 Vosk 引擎""" - stdout_cmd('info', 'Vosk recognizer closed.') \ No newline at end of file + stdout_cmd('info', 'Vosk recognizer closed.') diff --git a/engine/main.py b/engine/main.py index 5cc55d4..43bf974 100644 --- a/engine/main.py +++ b/engine/main.py @@ -5,7 +5,8 @@ from utils import stdout, stdout_cmd, change_caption_display from utils import shared_data, start_server from utils import merge_chunk_channels, resample_chunk_mono -from audio2text import GummyRecognizer +from audio2text import ParaformerRecognizer +from audio2text import LiveTranslateRecognizer from audio2text import VoskRecognizer from audio2text import SosvRecognizer from audio2text import GlmRecognizer @@ -46,7 +47,7 @@ def audio_recording(stream: AudioStream, resample: bool, record = False, path = stream.close_stream_signal() -def main_gummy(s: str, t: str, a: int, c: int, k: str, r: bool, rp: str): +def main_paraformer(s: str, t: str, a: int, c: int, k: str, translation_provider: str, translation_model: str, translation_base_url: str, translation_api_key: str, r: bool, rp: str): """ Parameters: s: Source language @@ -56,44 +57,67 @@ def main_gummy(s: str, t: str, a: int, c: int, k: str, r: bool, rp: str): rp: Path to save the recorded audio """ stream = AudioStream(a, c) - if t == 'none': - engine = GummyRecognizer(stream.RATE, s, None, k) - else: - engine = GummyRecognizer(stream.RATE, s, t, k) + engine = ParaformerRecognizer(16000, s, None if t == 'none' else t, k, + translation_provider, translation_model, + translation_base_url, translation_api_key) - engine.start() - stream_thread = threading.Thread( - target=audio_recording, - args=(stream, False, r, rp), - daemon=True - ) - stream_thread.start() try: + engine.start() + if engine.failed: + return + stream_thread = threading.Thread( + target=audio_recording, + args=(stream, True, r, rp), + daemon=True + ) + stream_thread.start() engine.translate() except KeyboardInterrupt: + shared_data.status = 'stopped' stdout("Keyboard interrupt detected. Exiting...") - engine.stop() + except Exception as error: + engine.fail(str(error)) + finally: + engine.stop() -def main_vosk(a: int, c: int, vosk: str, t: str, tm: str, omn: str, ourl: str, okey: str, r: bool, rp: str): +def main_livetranslate(a, c, t, key, workspace_id, region, record, record_path): + engine = LiveTranslateRecognizer(None if t == 'none' else t, key, workspace_id, region) + stream = AudioStream(a, c) + try: + engine.start() + if engine.failed: + return + threading.Thread(target=audio_recording, + args=(stream, True, record, record_path), daemon=True).start() + engine.translate() + except KeyboardInterrupt: + shared_data.status = 'stop' + except Exception as error: + engine.fail(str(error)) + finally: + engine.stop() + + +def main_vosk(a: int, c: int, vosk: str, t: str, translation_provider: str, translation_model: str, translation_base_url: str, translation_api_key: str, r: bool, rp: 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 - ourl: Ollama Base URL - okey: Ollama API Key + translation_provider: Translation service: ollama, openai or google + translation_model: Model name used by the translation service + translation_base_url: OpenAI-compatible API base URL + translation_api_key: OpenAI-compatible API key r: Whether to record the audio rp: Path to save the recorded audio """ stream = AudioStream(a, c) if t == 'none': - engine = VoskRecognizer(vosk, None, tm, omn, ourl, okey) + engine = VoskRecognizer(vosk, None, translation_provider, translation_model, translation_base_url, translation_api_key) else: - engine = VoskRecognizer(vosk, t, tm, omn, ourl, okey) + engine = VoskRecognizer(vosk, t, translation_provider, translation_model, translation_base_url, translation_api_key) engine.start() stream_thread = threading.Thread( @@ -109,7 +133,7 @@ def main_vosk(a: int, c: int, vosk: str, t: str, tm: str, omn: str, ourl: str, o engine.stop() -def main_sosv(a: int, c: int, sosv: str, s: str, t: str, tm: str, omn: str, ourl: str, okey: str, r: bool, rp: str): +def main_sosv(a: int, c: int, sosv: str, s: str, t: str, translation_provider: str, translation_model: str, translation_base_url: str, translation_api_key: str, r: bool, rp: str): """ Parameters: a: Audio source: 0 for output, 1 for input @@ -117,18 +141,18 @@ def main_sosv(a: int, c: int, sosv: str, s: str, t: str, tm: str, omn: str, ourl sosv: Sherpa-ONNX SenseVoice model path s: Source language t: Target language - tm: Translation model type, ollama or google - omn: Ollama model name - ourl: Ollama API URL - okey: Ollama API Key + translation_provider: Translation service: ollama, openai or google + translation_model: Model name used by the translation service + translation_base_url: OpenAI-compatible API base URL + translation_api_key: OpenAI-compatible API key r: Whether to record the audio rp: Path to save the recorded audio """ stream = AudioStream(a, c) if t == 'none': - engine = SosvRecognizer(sosv, s, None, tm, omn, ourl, okey) + engine = SosvRecognizer(sosv, s, None, translation_provider, translation_model, translation_base_url, translation_api_key) else: - engine = SosvRecognizer(sosv, s, t, tm, omn, ourl, okey) + engine = SosvRecognizer(sosv, s, t, translation_provider, translation_model, translation_base_url, translation_api_key) engine.start() stream_thread = threading.Thread( @@ -144,7 +168,7 @@ def main_sosv(a: int, c: int, sosv: str, s: str, t: str, tm: str, omn: str, ourl engine.stop() -def main_glm(a: int, c: int, url: str, model: str, key: str, s: str, t: str, tm: str, omn: str, ourl: str, okey: str, r: bool, rp: str): +def main_glm(a: int, c: int, url: str, model: str, key: str, s: str, t: str, translation_provider: str, translation_model: str, translation_base_url: str, translation_api_key: str, r: bool, rp: str): """ Parameters: a: Audio source @@ -154,18 +178,18 @@ def main_glm(a: int, c: int, url: str, model: str, key: str, s: str, t: str, tm: key: GLM API Key s: Source language t: Target language - tm: Translation model - omn: Ollama model name - ourl: Ollama API URL - okey: Ollama API Key + translation_provider: Translation service: ollama, openai or google + translation_model: Model name used by the translation service + translation_base_url: OpenAI-compatible API base URL + translation_api_key: OpenAI-compatible API key r: Record rp: Record path """ stream = AudioStream(a, c) if t == 'none': - engine = GlmRecognizer(url, model, key, s, None, tm, omn, ourl, okey) + engine = GlmRecognizer(url, model, key, s, None, translation_provider, translation_model, translation_base_url, translation_api_key) else: - engine = GlmRecognizer(url, model, key, s, t, tm, omn, ourl, okey) + engine = GlmRecognizer(url, model, key, s, t, translation_provider, translation_model, translation_base_url, translation_api_key) engine.start() stream_thread = threading.Thread( @@ -185,7 +209,7 @@ def main_glm(a: int, c: int, url: str, model: str, key: str, s: str, t: str, tm: if __name__ == "__main__": parser = argparse.ArgumentParser(description='Convert system audio stream to text') # all - parser.add_argument('-e', '--caption_engine', default='gummy', help='Caption engine: gummy, glm, vosk or sosv') + parser.add_argument('-e', '--caption_engine', default='paraformer', choices=['paraformer', 'livetranslate', 'glm', 'vosk', 'sosv'], help='Caption engine: paraformer, livetranslate, glm, vosk or sosv') parser.add_argument('-a', '--audio_type', type=int, default=0, help='Audio stream source: 0 for output, 1 for input') parser.add_argument('-c', '--chunk_rate', type=int, default=10, help='Number of audio stream chunks collected per second') parser.add_argument('-p', '--port', type=int, default=0, help='The port to run the server on, 0 for no server') @@ -193,15 +217,17 @@ def main_glm(a: int, c: int, url: str, model: str, key: str, s: str, t: str, tm: parser.add_argument('-t', '--target_language', default='none', help='Target language code, "none" for no translation') parser.add_argument('-r', '--record', type=int, default=0, help='Whether to record the audio, 0 for no recording, 1 for recording') parser.add_argument('-rp', '--record_path', default='', help='Path to save the recorded audio') - # gummy and sosv and glm + # paraformer and sosv and glm parser.add_argument('-s', '--source_language', default='auto', help='Source language code') - # gummy only - 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') - parser.add_argument('-ourl', '--ollama_url', default='', help='Ollama API URL') - parser.add_argument('-okey', '--ollama_api_key', default='', help='Ollama API Key') + # paraformer only + parser.add_argument('--dashscope_api_key', default='', help='API key for Alibaba Cloud caption engines') + parser.add_argument('--dashscope_workspace_id', default='', help='LiveTranslate workspace ID (or DASHSCOPE_WORKSPACE_ID)') + parser.add_argument('--dashscope_region', choices=['cn-beijing', 'ap-southeast-1'], default='cn-beijing', help='LiveTranslate API region') + # all engines: external translation + parser.add_argument('--translation_provider', choices=['ollama', 'openai', 'google'], default='ollama', help='Translation service provider') + parser.add_argument('--translation_model', default='', help='Translation model name (for Ollama or OpenAI-compatible services)') + parser.add_argument('--translation_base_url', default='', help='OpenAI-compatible API base URL') + parser.add_argument('--translation_api_key', default='', help='OpenAI-compatible API key') # vosk only parser.add_argument('-vosk', '--vosk_model', default='', help='The path to the vosk model.') # sosv only @@ -213,19 +239,33 @@ def main_glm(a: int, c: int, url: str, model: str, key: str, s: str, t: str, tm: args = parser.parse_args() + uses_external_translation = args.caption_engine != 'livetranslate' and args.target_language != 'none' + if uses_external_translation and args.translation_provider in ('ollama', 'openai') and not args.translation_model: + parser.error('--translation_model is required when translation is enabled with Ollama or OpenAI') + if uses_external_translation and args.translation_provider == 'openai' and not args.translation_base_url: + parser.error('--translation_base_url is required when translation is enabled with OpenAI') + if args.port != 0: threading.Thread(target=start_server, args=(args.port,), daemon=True).start() if args.display_caption == '1': change_caption_display(True) - if args.caption_engine == 'gummy': - main_gummy( + if args.caption_engine == 'livetranslate': + main_livetranslate(args.audio_type, args.chunk_rate, args.target_language, + args.dashscope_api_key, args.dashscope_workspace_id, + args.dashscope_region, bool(args.record), args.record_path) + elif args.caption_engine == 'paraformer': + main_paraformer( args.source_language, args.target_language, int(args.audio_type), int(args.chunk_rate), - args.api_key, + args.dashscope_api_key, + args.translation_provider, + args.translation_model, + args.translation_base_url, + args.translation_api_key, bool(int(args.record)), args.record_path ) @@ -235,10 +275,10 @@ def main_glm(a: int, c: int, url: str, model: str, key: str, s: str, t: str, tm: int(args.chunk_rate), args.vosk_model, args.target_language, + args.translation_provider, args.translation_model, - args.ollama_name, - args.ollama_url, - args.ollama_api_key, + args.translation_base_url, + args.translation_api_key, bool(int(args.record)), args.record_path ) @@ -249,10 +289,10 @@ def main_glm(a: int, c: int, url: str, model: str, key: str, s: str, t: str, tm: args.sosv_model, args.source_language, args.target_language, + args.translation_provider, args.translation_model, - args.ollama_name, - args.ollama_url, - args.ollama_api_key, + args.translation_base_url, + args.translation_api_key, bool(int(args.record)), args.record_path ) @@ -265,10 +305,10 @@ def main_glm(a: int, c: int, url: str, model: str, key: str, s: str, t: str, tm: args.glm_api_key, args.source_language, args.target_language, + args.translation_provider, args.translation_model, - args.ollama_name, - args.ollama_url, - args.ollama_api_key, + args.translation_base_url, + args.translation_api_key, bool(int(args.record)), args.record_path ) diff --git a/engine/main.spec b/engine/main.spec index 153a03c..f428479 100644 --- a/engine/main.spec +++ b/engine/main.spec @@ -1,20 +1,13 @@ # -*- mode: python ; coding: utf-8 -*- from pathlib import Path -import sys +from PyInstaller.utils.hooks import get_package_paths -if sys.platform == 'win32': - vosk_path = str(Path('./.venv/Lib/site-packages/vosk').resolve()) -else: - venv_lib = Path('./.venv/lib') - python_dirs = list(venv_lib.glob('python*')) - if python_dirs: - vosk_path = str((python_dirs[0] / 'site-packages' / 'vosk').resolve()) - else: - vosk_path = str(Path('./.venv/lib/python3.12/site-packages/vosk').resolve()) +engine_path = Path(SPECPATH) +_, vosk_path = get_package_paths('vosk') a = Analysis( - ['main.py'], + [str(engine_path / 'main.py')], pathex=[], binaries=[], datas=[(vosk_path, 'vosk')], diff --git a/engine/pyproject.toml b/engine/pyproject.toml new file mode 100644 index 0000000..cd3f877 --- /dev/null +++ b/engine/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "auto-caption-engine" +version = "1.2.0" +description = "Audio capture, speech recognition and translation engine for Auto Caption." +requires-python = ">=3.10" +dependencies = [ + "dashscope", + "websocket-client", + "numpy", + "resampy", + "vosk", + "pyaudio; sys_platform == 'darwin'", + "pyaudiowpatch; sys_platform == 'win32'", + "googletrans", + "ollama", + # The sdist omits the core dependency; keep both packages at the same version. + # https://github.com/k2-fsa/sherpa-onnx/issues/3689 + "sherpa-onnx", + "sherpa-onnx-core", + "requests", + "openai", +] + +[dependency-groups] +dev = ["pyinstaller"] + +[tool.uv] +package = false + +# [[tool.uv.index]] +# name = "pypi" +# url = "https://pypi.org/simple" +# default = true diff --git a/engine/requirements.txt b/engine/requirements.txt deleted file mode 100644 index 1e4140e..0000000 --- a/engine/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -dashscope -numpy -resampy -vosk -pyinstaller -pyaudio; sys_platform == 'darwin' -pyaudiowpatch; sys_platform == 'win32' -googletrans -ollama -sherpa_onnx -requests -openai diff --git a/engine/tests/test_livetranslate.py b/engine/tests/test_livetranslate.py new file mode 100644 index 0000000..2cf2578 --- /dev/null +++ b/engine/tests/test_livetranslate.py @@ -0,0 +1,131 @@ +import base64 +import importlib.util +import json +import queue +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + + +class LiveTranslateTests(unittest.TestCase): + def setUp(self): + self.output = Mock() + self.commands = Mock() + self.errors = Mock() + self.shared = types.SimpleNamespace(status='running', chunk_queue=queue.Queue()) + utils = types.ModuleType('utils') + utils.shared_data = self.shared + utils.stdout_obj = self.output + utils.stdout_cmd = self.commands + utils.stdout_err = self.errors + self.websocket = types.ModuleType('websocket') + self.websocket.create_connection = Mock() + self.websocket.WebSocketTimeoutException = type('Timeout', (Exception,), {}) + with patch.dict(sys.modules, {'utils': utils, 'websocket': self.websocket}): + spec = importlib.util.spec_from_file_location('live_adapter', + Path(__file__).parents[1] / 'audio2text/livetranslate.py') + self.module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.module) + self.engine = self.module.LiveTranslateRecognizer('zh', 'key', 'workspace') + + def event(self, kind, **kwargs): + self.engine.on_event({'type': kind, **kwargs}) + + def source(self, item_id, text): + self.event('conversation.item.input_audio_transcription.delta', item_id=item_id, delta=text) + + def link(self, target_id, source_id): + self.event('conversation.item.created', previous_item_id=source_id, + item={'id': target_id, 'role': 'assistant'}) + + def test_session_parameters_and_region(self): + self.engine.ready.set() + with patch.object(self.module.threading, 'Thread'): + self.engine.start() + args = self.websocket.create_connection.call_args + self.assertIn('workspace.cn-beijing.maas.aliyuncs.com', args.args[0]) + self.assertIn('qwen3.8-livetranslate-flash-realtime', args.args[0]) + self.assertEqual(args.kwargs['header'], {'Authorization': 'Bearer key'}) + config = json.loads(self.engine.socket.send.call_args.args[0])['session'] + self.assertEqual(config['output_modalities'], ['text']) + self.assertEqual(config['translation']['language'], 'zh') + + def test_interleaved_original_and_translation(self): + self.source('s1', 'Hello') + self.source('s2', 'Next') + self.link('t1', 's1') + self.event('response.text.delta', item_id='t1', delta='你') + self.event('response.text.delta', item_id='t1', delta='好') + self.event('conversation.item.input_audio_transcription.completed', item_id='s1', transcript='Hello!') + messages = [c.args[0] for c in self.output.call_args_list] + self.assertEqual(messages[-2]['translation'], '你好') + self.assertEqual(messages[0]['time_s'], messages[-2]['time_s']) + self.assertEqual(messages[-1]['index'], 1) + self.assertEqual(messages[-1]['translation'], '你好') + self.assertTrue(messages[-1]['update_by_time']) + + def test_translation_before_original_or_link(self): + self.event('response.text.delta', item_id='t1', delta='你好') + self.link('t1', 's1') + self.output.assert_not_called() + self.source('s1', 'Hello') + self.assertEqual(self.output.call_args.args[0]['translation'], '你好') + + def test_final_translation_replaces_partial(self): + self.source('s1', 'Hello') + self.link('t1', 's1') + self.event('response.text.delta', item_id='t1', delta='你') + self.event('response.text.done', item_id='t1', text='你好!') + self.assertEqual(self.output.call_args.args[0]['translation'], '你好!') + + def test_disabled_translation_keeps_original(self): + self.engine.target = None + self.source('s1', 'Hello') + self.link('t1', 's1') + self.event('response.text.delta', item_id='t1', delta='你好') + self.assertEqual(self.output.call_count, 1) + self.assertEqual(self.output.call_args.args[0]['translation'], '') + + def test_audio_is_base64_encoded(self): + self.engine.socket = Mock() + self.shared.chunk_queue.put(b'\x00\x01') + self.engine.socket.send.side_effect = lambda _: setattr(self.shared, 'status', 'stop') + self.engine.translate() + event = json.loads(self.engine.socket.send.call_args.args[0]) + self.assertEqual(event['type'], 'input_audio_buffer.append') + self.assertEqual(base64.b64decode(event['audio']), b'\x00\x01') + + def test_stop_flushes_then_closes(self): + self.engine.socket = Mock() + self.engine.socket.send.side_effect = lambda _: self.event('session.finished') + self.engine.stop() + self.assertEqual(json.loads(self.engine.socket.send.call_args.args[0])['type'], 'session.finish') + self.engine.socket.close.assert_called_once() + self.errors.assert_not_called() + + def test_server_errors_wake_waits_and_request_exit(self): + self.event('error', error={'message': 'Invalid API key'}) + self.assertTrue(self.engine.ready.is_set()) + self.assertTrue(self.engine.finished.is_set()) + self.assertEqual(self.shared.status, 'kill') + self.errors.assert_called_once_with('Invalid API key') + + def test_abnormal_close_reports_error(self): + self.engine.socket = Mock() + self.engine.socket.recv.return_value = '' + self.engine.receive() + self.assertTrue(self.engine.failed) + + def test_configuration_validation_and_environment(self): + with patch.dict('os.environ', {'DASHSCOPE_API_KEY': 'env-key', 'DASHSCOPE_WORKSPACE_ID': 'env-space'}): + engine = self.module.LiveTranslateRecognizer('ja', region='ap-southeast-1') + self.assertEqual(engine.api_key, 'env-key') + self.assertIn('env-space.ap-southeast-1.', engine.url) + with self.assertRaises(ValueError): self.module.LiveTranslateRecognizer('invalid', 'key', 'space') + with self.assertRaises(ValueError): self.module.LiveTranslateRecognizer('zh', 'key', 'space/bad') + + +if __name__ == '__main__': + unittest.main() diff --git a/engine/tests/test_paraformer.py b/engine/tests/test_paraformer.py new file mode 100644 index 0000000..780ca41 --- /dev/null +++ b/engine/tests/test_paraformer.py @@ -0,0 +1,121 @@ +"""Offline contract tests: no audio devices, API keys or network required.""" +import importlib.util +import queue +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + + +class ParaformerTests(unittest.TestCase): + def setUp(self): + self.shared = types.SimpleNamespace(status='running', chunk_queue=queue.Queue()) + self.output = Mock() + self.commands = Mock() + self.errors = Mock() + self.translation = Mock() + self.sdk = types.ModuleType('dashscope') + asr = types.ModuleType('dashscope.audio.asr') + asr.Recognition = Mock() + asr.RecognitionCallback = object + asr.RecognitionResult = types.SimpleNamespace( + is_sentence_end=lambda s: s.get('end_time') is not None) + utils = types.ModuleType('utils') + utils.shared_data = self.shared + utils.stdout_obj = self.output + utils.stdout_cmd = self.commands + utils.stdout_err = self.errors + utils.get_translation_function = Mock(return_value=self.translation) + with patch.dict(sys.modules, {'dashscope': self.sdk, + 'dashscope.audio.asr': asr, 'utils': utils}): + spec = importlib.util.spec_from_file_location( + 'paraformer_adapter', Path(__file__).parents[1] / 'audio2text/paraformer.py') + self.module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.module) + self.asr = asr + + def engine(self, source='auto', target=None, key='test-key'): + return self.module.ParaformerRecognizer(16000, source, target, key, 'google', '') + + def event(self, begin, text, end=None): + result = Mock() + result.get_request_id.return_value = 'request-1' + result.get_sentence.return_value = {'begin_time': begin, 'text': text, 'end_time': end} + return result + + def test_sdk_parameters_and_environment_key(self): + with patch.dict('os.environ', {'DASHSCOPE_API_KEY': 'env-key'}): + self.engine(key='') + self.assertEqual(self.sdk.api_key, 'env-key') + args = self.asr.Recognition.call_args.kwargs + self.assertEqual((args['model'], args['format'], args['sample_rate']), + ('paraformer-realtime-v2', 'pcm', 16000)) + self.assertEqual(args['language_hints'], []) + self.assertTrue(args['heartbeat']) + self.engine(source='ja') + self.assertEqual(self.asr.Recognition.call_args.kwargs['language_hints'], ['ja']) + + def test_missing_key_and_unsupported_language(self): + with patch.dict('os.environ', {}, clear=True): + with self.assertRaises(ValueError): self.engine(key='') + with self.assertRaises(ValueError): self.engine(source='es') + + def test_partial_final_identity_and_next_sentence(self): + callback = self.module.Callback(self.engine()) + callback.on_event(self.event(0, 'Hello')) + callback.on_event(self.event(0, 'Hello world', 200)) + callback.on_event(self.event(300, 'Next', 500)) + captions = [c.args[0] for c in self.output.call_args_list] + self.assertEqual([c['index'] for c in captions], [1, 1, 2]) + self.assertEqual(captions[0]['time_s'], captions[1]['time_s']) + self.assertEqual(captions[1]['translation'], '') + self.translation.assert_not_called() + + def test_only_final_sentence_translates_once(self): + callback = self.module.Callback(self.engine(target='zh')) + with patch.object(self.module.threading, 'Thread') as thread: + callback.on_event(self.event(0, 'Hello')) + thread.assert_not_called() + callback.on_event(self.event(0, 'Hello world', 200)) + callback.on_event(self.event(0, 'Hello world', 200)) + thread.assert_called_once() + self.assertEqual(thread.call_args.kwargs['args'][1:3], ('zh', 'Hello world')) + thread.return_value.start.assert_called_once() + + def test_empty_events_are_ignored(self): + callback = self.module.Callback(self.engine()) + callback.on_event(self.event(0, ' ')) + result = Mock() + result.get_sentence.return_value = None + callback.on_event(result) + self.output.assert_not_called() + + def test_error_and_abnormal_close_request_exit(self): + engine = self.engine() + callback = self.module.Callback(engine) + callback.on_error(types.SimpleNamespace(message='Authentication failed')) + callback.on_close() + self.assertEqual(self.shared.status, 'kill') + self.errors.assert_called_once_with('Authentication failed') + self.commands.assert_any_call('kill') + + def test_normal_stop_does_not_request_kill(self): + engine = self.engine() + engine.stop() + callback = self.module.Callback(engine) + callback.on_complete() + callback.on_close() + self.assertEqual(self.shared.status, 'running') + self.errors.assert_not_called() + + def test_send_failure_stops_loop(self): + engine = self.engine() + engine.recognizer.send_audio_frame.side_effect = RuntimeError('Disconnected') + self.shared.chunk_queue.put(b'pcm') + engine.translate() + self.assertEqual(self.shared.status, 'kill') + + +if __name__ == '__main__': + unittest.main() diff --git a/engine/utils/__init__.py b/engine/utils/__init__.py index aaf2b4a..40f6b73 100644 --- a/engine/utils/__init__.py +++ b/engine/utils/__init__.py @@ -3,4 +3,4 @@ from .sysout import change_caption_display from .shared import shared_data from .server import start_server -from .translation import ollama_translate, google_translate \ No newline at end of file +from .translation import get_translation_function, google_translate, ollama_translate, openai_translate diff --git a/engine/utils/translation.py b/engine/utils/translation.py index e935d60..1345a0f 100644 --- a/engine/utils/translation.py +++ b/engine/utils/translation.py @@ -1,4 +1,4 @@ -from ollama import chat, Client +from ollama import chat from ollama import ChatResponse try: from openai import OpenAI @@ -21,43 +21,14 @@ 'zh-cn': 'Chinese' } -def ollama_translate(model: str, target: str, text: str, time_s: str, url: str = '', key: str = ''): - content = "" - try: - if url: - if OpenAI: - client = OpenAI(base_url=url, api_key=key if key else "ollama") - openai_response = client.chat.completions.create( - 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 = openai_response.choices[0].message.content or "" - else: - client = Client(host=url) - response: ChatResponse = client.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 "" - else: - 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 "" - except Exception as e: - stdout_cmd("warn", f"Translation failed: {str(e)}") - return +def _translation_messages(target: str, text: str): + return [ + {"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} + ] + +def _output_translation(content: str, text: str, time_s: str): if content.startswith(''): index = content.find('') if index != -1: @@ -69,7 +40,37 @@ def ollama_translate(model: str, target: str, text: str, time_s: str, url: str = "translation": content.strip() }) -def google_translate(model: str, target: str, text: str, time_s: str): + +def ollama_translate(model: str, target: str, text: str, time_s: str, base_url: str = '', api_key: str = ''): + content = "" + try: + response: ChatResponse = chat(model=model, messages=_translation_messages(target, text)) + content = response.message.content or "" + except Exception as e: + stdout_cmd("warn", f"Ollama translation failed: {str(e)}") + return + + _output_translation(content, text, time_s) + + +def openai_translate(model: str, target: str, text: str, time_s: str, base_url: str = '', api_key: str = ''): + if OpenAI is None: + stdout_cmd("warn", "OpenAI translation failed: the openai package is not installed") + return + try: + client = OpenAI(base_url=base_url, api_key=api_key or "not-required") + response = client.chat.completions.create( + model=model, + messages=_translation_messages(target, text) + ) + content = response.choices[0].message.content or "" + except Exception as e: + stdout_cmd("warn", f"OpenAI-compatible translation failed: {str(e)}") + return + + _output_translation(content, text, time_s) + +def google_translate(model: str, target: str, text: str, time_s: str, base_url: str = '', api_key: str = ''): translator = Translator() try: res = asyncio.run(translator.translate(text, dest=target)) @@ -79,5 +80,13 @@ def google_translate(model: str, target: str, text: str, time_s: str): "text": text, "translation": res.text }) - except Exception as e: + except Exception: stdout_cmd("warn", f"Google translation request failed, please check your network connection...") + + +def get_translation_function(provider: str): + return { + 'ollama': ollama_translate, + 'openai': openai_translate, + 'google': google_translate + }[provider] diff --git a/eslint.config.mjs b/eslint.config.mjs index d8bb4db..3bf3ea8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,9 +4,24 @@ import eslintPluginVue from 'eslint-plugin-vue' import vueParser from 'vue-eslint-parser' export default tseslint.config( - { ignores: ['**/node_modules', '**/dist', '**/out'] }, + { + ignores: [ + '**/node_modules/**', + '**/dist/**', + '**/out/**', + '**/.venv/**', + '**/.uv-cache/**', + 'engine/build/**', + 'engine/models/**', + 'engine/notebook/**', + 'engine/portaudio/**', + 'engine/pyinstaller_cache/**', + '.repomap/**', + '.virtualme/**' + ] + }, tseslint.configs.recommended, - eslintPluginVue.configs['essential'], + ...eslintPluginVue.configs['flat/essential'], { files: ['**/*.vue'], languageOptions: { diff --git a/package-lock.json b/package-lock.json index d70c789..75a940d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "auto-caption", - "version": "1.0.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "auto-caption", - "version": "1.0.0", + "version": "1.2.0", "hasInstallScript": true, "dependencies": { "@electron-toolkit/preload": "^3.0.1", diff --git a/package.json b/package.json index 258b724..e0a1c40 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "auto-caption", "productName": "Auto Caption", - "version": "1.1.1", + "version": "1.2.0", "description": "A cross-platform subtitle display software.", "main": "./out/main/index.js", "author": "himeditator", @@ -14,12 +14,15 @@ "typecheck": "npm run typecheck:node && npm run typecheck:web", "start": "electron-vite preview", "dev": "electron-vite dev", + "engine:sync": "uv sync --project engine", + "engine:test": "uv run --project engine python -m unittest discover -s engine/tests -v", + "build:engine": "uv run --project engine pyinstaller --noconfirm --clean --distpath engine/dist --workpath engine/build engine/main.spec", "build": "npm run typecheck && electron-vite build", "postinstall": "electron-builder install-app-deps", - "build:unpack": "npm run build && electron-builder --dir", - "build:win": "npm run build && electron-builder --win", - "build:mac": "npm run build && electron-builder --mac", - "build:linux": "npm run build && electron-builder --linux" + "build:unpack": "npm run build:engine && npm run build && electron-builder --dir", + "build:win": "npm run build:engine && npm run build && electron-builder --win", + "build:mac": "npm run build:engine && npm run build && electron-builder --mac", + "build:linux": "npm run build:engine && npm run build && electron-builder --linux" }, "dependencies": { "@electron-toolkit/preload": "^3.0.1", diff --git a/src/main/CaptionWindow.ts b/src/main/CaptionWindow.ts index c489e69..873654a 100644 --- a/src/main/CaptionWindow.ts +++ b/src/main/CaptionWindow.ts @@ -55,7 +55,7 @@ class CaptionWindow { } } - public handleMessage() { + public handleMessage(): void { ipcMain.on('caption.controlWindow.activate', () => { if(!controlWindow.window){ controlWindow.createWindow() diff --git a/src/main/ControlWindow.ts b/src/main/ControlWindow.ts index 2918785..7aa211f 100644 --- a/src/main/ControlWindow.ts +++ b/src/main/ControlWindow.ts @@ -53,7 +53,7 @@ class ControlWindow { } } - public handleMessage() { + public handleMessage(): void { nativeTheme.on('updated', () => { if(allConfig.uiTheme === 'system'){ if(nativeTheme.shouldUseDarkColors && this.window){ @@ -92,7 +92,9 @@ class ControlWindow { pid: 0, ppid: 0, port: 0, cpu: 0, mem: 0, elapsed: 0 } if(captionEngine.status !== 'running') return info - const stats = await pidusage(captionEngine.process.pid) + const pid = captionEngine.process?.pid + if(pid === undefined) return info + const stats = await pidusage(pid) info.pid = stats.pid info.ppid = stats.ppid info.port = captionEngine.port @@ -168,7 +170,7 @@ class ControlWindow { }) } - public sendErrorMessage(message: string) { + public sendErrorMessage(message: string): void { this.window?.webContents.send('control.error.occurred', message) } } diff --git a/src/main/i18n/lang/en.ts b/src/main/i18n/lang/en.ts index 4e0bc7b..e606faa 100644 --- a/src/main/i18n/lang/en.ts +++ b/src/main/i18n/lang/en.ts @@ -1,5 +1,6 @@ export default { - "gummy.key.missing": "API KEY is not set, and the DASHSCOPE_API_KEY environment variable is not detected. To use the gummy engine, you need to obtain an API KEY from the Alibaba Cloud Bailian platform and add it to the settings or configure it in the local environment variables.", + "dashscope.key.missing": "Alibaba Cloud engines require an API Key in More Settings or the DASHSCOPE_API_KEY environment variable.", + "livetranslate.workspace.missing": "Qwen LiveTranslate requires a workspace ID in settings or the DASHSCOPE_WORKSPACE_ID environment variable.", "platform.unsupported": "Unsupported platform: ", "engine.start.error": "Caption engine failed to start: ", "engine.output.parse.error": "Unable to parse caption engine output as a JSON object: ", diff --git a/src/main/i18n/lang/ja.ts b/src/main/i18n/lang/ja.ts index 76e7fd0..f4aee5b 100644 --- a/src/main/i18n/lang/ja.ts +++ b/src/main/i18n/lang/ja.ts @@ -1,5 +1,6 @@ export default { - "gummy.key.missing": "API KEY が設定されておらず、DASHSCOPE_API_KEY 環境変数も検出されていません。Gummy エンジンを使用するには、Alibaba Cloud Bailian プラットフォームから API KEY を取得し、設定に追加するか、ローカルの環境変数に設定する必要があります。", + "dashscope.key.missing": "アリババクラウドのエンジンには、詳細設定または DASHSCOPE_API_KEY 環境変数で API Key を指定してください。", + "livetranslate.workspace.missing": "Qwen LiveTranslate には、設定または DASHSCOPE_WORKSPACE_ID 環境変数で業務スペース ID を指定してください。", "platform.unsupported": "サポートされていないプラットフォーム: ", "engine.start.error": "字幕エンジンの起動に失敗しました: ", "engine.output.parse.error": "字幕エンジンの出力を JSON オブジェクトとして解析できませんでした: ", diff --git a/src/main/i18n/lang/zh.ts b/src/main/i18n/lang/zh.ts index 1ed6845..0e685b0 100644 --- a/src/main/i18n/lang/zh.ts +++ b/src/main/i18n/lang/zh.ts @@ -1,5 +1,6 @@ export default { - "gummy.key.missing": "没有设置 API KEY,也没有检测到 DASHSCOPE_API_KEY 环境变量。如果要使用 gummy 引擎,需要在阿里云百炼平台获取 API KEY,并在添加到设置中或者配置到本机环境变量。", + "dashscope.key.missing": "阿里云引擎需要 API Key,请在更多设置中填写,或设置 DASHSCOPE_API_KEY 环境变量。", + "livetranslate.workspace.missing": "Qwen LiveTranslate 需要业务空间 ID,请填写业务空间 ID 或设置 DASHSCOPE_WORKSPACE_ID 环境变量。", "platform.unsupported": "不支持的平台:", "engine.start.error": "字幕引擎启动失败:", "engine.output.parse.error": "字幕引擎输出内容无法解析为 JSON 对象:", diff --git a/src/main/types/index.ts b/src/main/types/index.ts index ce94e4b..943969c 100644 --- a/src/main/types/index.ts +++ b/src/main/types/index.ts @@ -6,15 +6,18 @@ export interface Controls { engineEnabled: boolean, sourceLang: string, targetLang: string, - transModel: string, - ollamaName: string, - ollamaUrl: string, - ollamaApiKey: string, + translationProvider: 'ollama' | 'openai' | 'google', + ollamaModel: string, + openaiModel: string, + openaiBaseUrl: string, + openaiApiKey: string, engine: string, audio: 0 | 1, translation: boolean, recording: boolean, - API_KEY: string, + dashscopeApiKey: string, + dashscopeWorkspaceId: string, + dashscopeRegion: 'cn-beijing' | 'ap-southeast-1', voskModelPath: string, sosvModelPath: string, glmUrl: string, @@ -83,4 +86,4 @@ export interface EngineInfo { cpu: number, mem: number, elapsed: number -} \ No newline at end of file +} diff --git a/src/main/utils/AllConfig.ts b/src/main/utils/AllConfig.ts index 9fe82a7..bffc1e2 100644 --- a/src/main/utils/AllConfig.ts +++ b/src/main/utils/AllConfig.ts @@ -14,7 +14,7 @@ interface CaptionTranslation { translation: string } -function getDesktopPath() { +function getDesktopPath(): string { const homeDir = os.homedir() return path.join(homeDir, 'Desktop') } @@ -44,14 +44,17 @@ const defaultStyles: Styles = { const defaultControls: Controls = { sourceLang: 'en', targetLang: 'zh', - transModel: 'ollama', - ollamaName: 'qwen2.5:0.5b', - ollamaUrl: 'http://localhost:11434', - ollamaApiKey: '', - engine: 'gummy', + translationProvider: 'ollama', + ollamaModel: 'qwen2.5:0.5b', + openaiModel: '', + openaiBaseUrl: 'https://api.openai.com/v1', + openaiApiKey: '', + engine: 'paraformer', audio: 0, engineEnabled: false, - API_KEY: '', + dashscopeApiKey: '', + dashscopeWorkspaceId: '', + dashscopeRegion: 'cn-beijing', voskModelPath: '', sosvModelPath: '', glmUrl: 'https://open.bigmodel.cn/api/paas/v4/audio/transcriptions', @@ -80,9 +83,7 @@ class AllConfig { lastLogIndex: number = -1; captionLog: CaptionItem[] = []; - constructor() {} - - public readConfig() { + public readConfig(): void { const configPath = path.join(app.getPath('userData'), 'config.json') if(fs.existsSync(configPath)){ Log.info('Read Config from:', configPath) @@ -97,7 +98,7 @@ class AllConfig { } } - public writeConfig() { + public writeConfig(): void { const config = { captionWindowWidth: this.captionWindowWidth, uiLanguage: this.uiLanguage, @@ -126,8 +127,8 @@ class AllConfig { } } - public setStyles(args: Object) { - for(let key in this.styles) { + public setStyles(args: object): void { + for(const key in this.styles) { if(key in args) { this.styles[key] = args[key] } @@ -135,32 +136,73 @@ class AllConfig { Log.info('Set Styles:', this.styles) } - public resetStyles() { + public resetStyles(): void { this.setStyles(defaultStyles) } - public sendStyles(window: BrowserWindow) { + public sendStyles(window: BrowserWindow): void { window.webContents.send('both.styles.set', this.styles) Log.info(`Send Styles to #${window.id}:`, this.styles) } - public setControls(args: Object) { + public setControls(args: object): void { + const legacy = args as Record + const migratedArgs: Record = { ...legacy } + if(!('translationProvider' in legacy) && typeof legacy.transModel === 'string') { + const legacyUrl = typeof legacy.ollamaUrl === 'string' ? legacy.ollamaUrl : '' + const usesOpenAI = Boolean(legacyUrl && !/^https?:\/\/(localhost|127\.0\.0\.1):11434\/?$/i.test(legacyUrl)) + migratedArgs.translationProvider = legacy.transModel === 'ollama' && usesOpenAI + ? 'openai' + : legacy.transModel + } + if(!('ollamaModel' in legacy) && typeof legacy.ollamaName === 'string') { + migratedArgs.ollamaModel = legacy.ollamaName + } + if(!('openaiModel' in legacy) && migratedArgs.translationProvider === 'openai' && typeof legacy.ollamaName === 'string') { + migratedArgs.openaiModel = legacy.ollamaName + } + if(!('openaiBaseUrl' in legacy) && migratedArgs.translationProvider === 'openai' && typeof legacy.ollamaUrl === 'string' && legacy.ollamaUrl) { + migratedArgs.openaiBaseUrl = legacy.ollamaUrl + } + if(!('openaiApiKey' in legacy) && typeof legacy.ollamaApiKey === 'string') { + migratedArgs.openaiApiKey = legacy.ollamaApiKey + } + if(!('dashscopeApiKey' in legacy)) { + if(typeof legacy.gummyApiKey === 'string') migratedArgs.dashscopeApiKey = legacy.gummyApiKey + else if(typeof legacy.API_KEY === 'string') migratedArgs.dashscopeApiKey = legacy.API_KEY + } + if(legacy.engine === 'gummy') { + migratedArgs.engine = 'paraformer' + if(legacy.sourceLang === 'es' || legacy.sourceLang === 'it') migratedArgs.sourceLang = 'auto' + } const engineEnabled = this.controls.engineEnabled - for(let key in this.controls){ - if(key in args) { - this.controls[key] = args[key] + for(const key in this.controls){ + if(key in migratedArgs) { + this.controls[key] = migratedArgs[key] } } this.controls.engineEnabled = engineEnabled Log.info('Set Controls:', passwordMaskingForObject(this.controls)) } - public sendControls(window: BrowserWindow, info = true) { + public sendControls(window: BrowserWindow, info = true): void { window.webContents.send('control.controls.set', this.controls) - if(info) Log.info(`Send Controls to #${window.id}:`, this.controls) + if(info) Log.info(`Send Controls to #${window.id}:`, passwordMaskingForObject(this.controls)) } - public updateCaptionLog(log: CaptionItem) { + public updateCaptionLog(log: CaptionItem, matchByTime = false): void { + // Source transcription can finish after the next speech segment has started. + const existingIndex = matchByTime + ? this.captionLog.findLastIndex(item => item.time_s === log.time_s) + : -1 + if(existingIndex >= 0) { + log.index = this.captionLog[existingIndex].index + this.captionLog.splice(existingIndex, 1, log) + for(const window of BrowserWindow.getAllWindows()) { + this.sendCaptionLog(window, 'upd', existingIndex) + } + return + } let command: 'add' | 'upd' = 'add' if( this.captionLog.length && @@ -179,7 +221,7 @@ class AllConfig { } } - public updateCaptionTranslation(trans: CaptionTranslation){ + public updateCaptionTranslation(trans: CaptionTranslation): void { 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 @@ -194,7 +236,7 @@ class AllConfig { window: BrowserWindow, command: 'add' | 'upd' | 'set', index: number | undefined = undefined - ) { + ): void { if(command === 'add'){ window.webContents.send(`both.captionLog.add`, this.captionLog.at(-1)) } diff --git a/src/main/utils/CaptionEngine.ts b/src/main/utils/CaptionEngine.ts index d6d5bb5..74dcd9c 100644 --- a/src/main/utils/CaptionEngine.ts +++ b/src/main/utils/CaptionEngine.ts @@ -1,4 +1,4 @@ -import { exec, spawn } from 'child_process' +import { exec, spawn, type ChildProcessWithoutNullStreams } from 'child_process' import { app } from 'electron' import { is } from '@electron-toolkit/utils' import * as path from 'path' @@ -8,17 +8,38 @@ import { allConfig } from './AllConfig' import { i18n } from '../i18n' import { Log } from './Log' import { passwordMaskingForList } from './UtilsFunc' +import type { CaptionItem } from '../types' + +type EngineMessage = + | (CaptionItem & { command: 'caption'; update_by_time?: boolean }) + | { command: 'translation'; time_s: string; translation: string } + | { command: 'connect' | 'kill' | 'print' | 'info' | 'warn' | 'error' | 'usage'; content: string } export class CaptionEngine { appPath: string = '' command: string[] = [] - process: any | undefined + process: ChildProcessWithoutNullStreams | undefined client: net.Socket | undefined port: number = 8080 status: 'running' | 'starting' | 'stopping' | 'stopped' | 'starting-timeout' = 'stopped' timerID: NodeJS.Timeout | undefined startTimeoutID: NodeJS.Timeout | undefined + private addTranslationArguments(): void { + const controls = allConfig.controls + this.command.push('--translation_provider', controls.translationProvider) + if(controls.translationProvider === 'ollama') { + this.command.push('--translation_model', controls.ollamaModel) + } + else if(controls.translationProvider === 'openai') { + this.command.push('--translation_model', controls.openaiModel) + this.command.push('--translation_base_url', controls.openaiBaseUrl) + if(controls.openaiApiKey) { + this.command.push('--translation_api_key', controls.openaiApiKey) + } + } + } + private getApp(): boolean { if (allConfig.controls.customized) { Log.info('Using customized caption engine') @@ -28,10 +49,16 @@ export class CaptionEngine { this.command.push('-p', this.port.toString()) } else { - if(allConfig.controls.engine === 'gummy' && - !allConfig.controls.API_KEY && !process.env.DASHSCOPE_API_KEY + if(['paraformer', 'livetranslate'].includes(allConfig.controls.engine) && + !allConfig.controls.dashscopeApiKey && !process.env.DASHSCOPE_API_KEY + ) { + controlWindow.sendErrorMessage(i18n('dashscope.key.missing')) + return false + } + if(allConfig.controls.engine === 'livetranslate' && + !allConfig.controls.dashscopeWorkspaceId && !process.env.DASHSCOPE_WORKSPACE_ID ) { - controlWindow.sendErrorMessage(i18n('gummy.key.missing')) + controlWindow.sendErrorMessage(i18n('livetranslate.workspace.missing')) return false } this.command = [] @@ -44,12 +71,11 @@ export class CaptionEngine { this.command.push(path.join( app.getAppPath(), 'engine', 'main.py' )) - // this.appPath = path.join(app.getAppPath(), 'engine', 'dist', 'main.exe') } else { this.appPath = path.join( app.getAppPath(), 'engine', - '.venv', 'bin', 'python3' + '.venv', 'bin', 'python' ) this.command.push(path.join( app.getAppPath(), 'engine', 'main.py' @@ -61,7 +87,7 @@ export class CaptionEngine { this.appPath = path.join(process.resourcesPath, 'engine', 'main.exe') } else { - this.appPath = path.join(process.resourcesPath, 'engine', 'main', 'main') + this.appPath = path.join(process.resourcesPath, 'engine', 'main') } } this.command.push('-a', allConfig.controls.audio ? '1' : '0') @@ -76,29 +102,31 @@ export class CaptionEngine { allConfig.controls.targetLang : 'none' ) - if(allConfig.controls.engine === 'gummy') { - this.command.push('-e', 'gummy') + if(['paraformer', 'livetranslate'].includes(allConfig.controls.engine)) { + this.command.push('-e', allConfig.controls.engine) this.command.push('-s', allConfig.controls.sourceLang) - if(allConfig.controls.API_KEY) { - this.command.push('-k', allConfig.controls.API_KEY) + if(allConfig.controls.dashscopeApiKey) { + this.command.push('--dashscope_api_key', allConfig.controls.dashscopeApiKey) + } + if(allConfig.controls.engine === 'livetranslate') { + this.command.push('--dashscope_region', allConfig.controls.dashscopeRegion) + if(allConfig.controls.dashscopeWorkspaceId) { + this.command.push('--dashscope_workspace_id', allConfig.controls.dashscopeWorkspaceId) + } + } else { + this.addTranslationArguments() } } else if(allConfig.controls.engine === 'vosk'){ this.command.push('-e', 'vosk') this.command.push('-vosk', `"${allConfig.controls.voskModelPath}"`) - this.command.push('-tm', allConfig.controls.transModel) - this.command.push('-omn', allConfig.controls.ollamaName) - if(allConfig.controls.ollamaUrl) this.command.push('-ourl', allConfig.controls.ollamaUrl) - if(allConfig.controls.ollamaApiKey) this.command.push('-okey', allConfig.controls.ollamaApiKey) + this.addTranslationArguments() } else if(allConfig.controls.engine === 'sosv'){ this.command.push('-e', 'sosv') this.command.push('-s', allConfig.controls.sourceLang) this.command.push('-sosv', `"${allConfig.controls.sosvModelPath}"`) - this.command.push('-tm', allConfig.controls.transModel) - this.command.push('-omn', allConfig.controls.ollamaName) - if(allConfig.controls.ollamaUrl) this.command.push('-ourl', allConfig.controls.ollamaUrl) - if(allConfig.controls.ollamaApiKey) this.command.push('-okey', allConfig.controls.ollamaApiKey) + this.addTranslationArguments() } else if(allConfig.controls.engine === 'glm'){ this.command.push('-e', 'glm') @@ -108,10 +136,7 @@ export class CaptionEngine { if(allConfig.controls.glmApiKey) { this.command.push('-gkey', allConfig.controls.glmApiKey) } - this.command.push('-tm', allConfig.controls.transModel) - this.command.push('-omn', allConfig.controls.ollamaName) - if(allConfig.controls.ollamaUrl) this.command.push('-ourl', allConfig.controls.ollamaUrl) - if(allConfig.controls.ollamaApiKey) this.command.push('-okey', allConfig.controls.ollamaApiKey) + this.addTranslationArguments() } } Log.info('Engine Path:', this.appPath) @@ -119,7 +144,11 @@ export class CaptionEngine { return true } - public connect() { + public connect(): void { + if(!this.process?.pid) { + Log.warn('Caption engine process is not available, ignoring connection request') + return + } if(this.client) { Log.warn('Client already exists, ignoring...') } if (this.startTimeoutID) { clearTimeout(this.startTimeoutID) @@ -139,7 +168,7 @@ export class CaptionEngine { } } - public sendCommand(command: string, content: string = "") { + public sendCommand(command: string, content: string = ""): void { if(this.client === undefined) { Log.error('Client not initialized yet') return @@ -149,7 +178,7 @@ export class CaptionEngine { Log.info(`Send data to python server: ${data}`); } - public start() { + public start(): void { if (this.status !== 'stopped') { Log.warn('Caption engine is not stopped, current status:', this.status) return @@ -170,7 +199,7 @@ export class CaptionEngine { } }, timeoutMs) - this.process.stdout.on('data', (data: any) => { + this.process.stdout.on('data', (data: Buffer) => { const lines = data.toString().split('\n') lines.forEach((line: string) => { if (line.trim()) { @@ -185,7 +214,7 @@ export class CaptionEngine { }); }); - this.process.stderr.on('data', (data: any) => { + this.process.stderr.on('data', (data: Buffer) => { const lines = data.toString().split('\n') lines.forEach((line: string) => { if(line.trim()){ @@ -194,7 +223,7 @@ export class CaptionEngine { }) }); - this.process.on('close', (code: any) => { + this.process.on('close', (code: number | null) => { this.process = undefined; this.client = undefined allConfig.controls.engineEnabled = false @@ -212,7 +241,7 @@ export class CaptionEngine { }); } - public stop() { + public stop(): void { if(this.status !== 'running'){ Log.warn('Trying to stop engine which is not running, current status:', this.status) } @@ -229,7 +258,7 @@ export class CaptionEngine { }, 4000); } - public kill(){ + public kill(): void { 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) @@ -260,7 +289,7 @@ export class CaptionEngine { } } -function handleEngineData(data: any) { +function handleEngineData(data: EngineMessage): void { if(data.command === 'connect'){ captionEngine.connect() } @@ -271,7 +300,7 @@ function handleEngineData(data: any) { } } else if(data.command === 'caption') { - allConfig.updateCaptionLog(data); + allConfig.updateCaptionLog(data, data.update_by_time === true); } else if(data.command === 'translation') { allConfig.updateCaptionTranslation(data); diff --git a/src/main/utils/Log.ts b/src/main/utils/Log.ts index 7a36842..56512bd 100644 --- a/src/main/utils/Log.ts +++ b/src/main/utils/Log.ts @@ -4,7 +4,7 @@ import { type SoftwareLogItem } from "../types" let logIndex = 0 const logQueue: SoftwareLogItem[] = [] -function getTimeString() { +function getTimeString(): string { const now = new Date() const HH = String(now.getHours()).padStart(2, '0') const MM = String(now.getMinutes()).padStart(2, '0') @@ -14,13 +14,13 @@ function getTimeString() { } export class Log { - static getAndClearLogQueue() { + static getAndClearLogQueue(): SoftwareLogItem[] { const copiedQueue = structuredClone(logQueue) logQueue.length = 0 return copiedQueue } - static handleLog(logType: "INFO" | "WARN" | "ERROR", ...msg: any[]) { + static handleLog(logType: "INFO" | "WARN" | "ERROR", ...msg: unknown[]): void { const timeStr = getTimeString() const logPre = `[${logType} ${timeStr}]` let logStr = "" @@ -44,15 +44,15 @@ export class Log { } } - static info(...msg: any[]){ + static info(...msg: unknown[]): void { this.handleLog("INFO", ...msg) } - static warn(...msg: any[]){ + static warn(...msg: unknown[]): void { this.handleLog("WARN", ...msg) } - static error(...msg: any[]){ + static error(...msg: unknown[]): void { this.handleLog("ERROR", ...msg) } } diff --git a/src/main/utils/UtilsFunc.ts b/src/main/utils/UtilsFunc.ts index 09cd890..daee051 100644 --- a/src/main/utils/UtilsFunc.ts +++ b/src/main/utils/UtilsFunc.ts @@ -1,23 +1,24 @@ -function passwordMasking(pwd: string) { +function passwordMasking(pwd: string): string { return pwd.replace(/./g, '*') } -export function passwordMaskingForList(args: string[]) { +export function passwordMaskingForList(args: string[]): string[] { const maskedArgs = [...args] for(let i = 1; i < maskedArgs.length; i++) { - if(maskedArgs[i-1] === '-k' || maskedArgs[i-1] === '-okey' || maskedArgs[i-1] === '-gkey') { + if(maskedArgs[i-1] === '--dashscope_api_key' || maskedArgs[i-1] === '--translation_api_key' || maskedArgs[i-1] === '-gkey') { maskedArgs[i] = passwordMasking(maskedArgs[i]) } } return maskedArgs } -export function passwordMaskingForObject(args: Record) { - const maskedArgs = {...args} +export function passwordMaskingForObject(args: object): Record { + const maskedArgs: Record = {...args} for(const key in maskedArgs) { const lKey = key.toLowerCase() - if(lKey.includes('api') && lKey.includes('key')) { - maskedArgs[key] = passwordMasking(maskedArgs[key]) + const value = maskedArgs[key] + if(lKey.includes('api') && lKey.includes('key') && typeof value === 'string') { + maskedArgs[key] = passwordMasking(value) } } return maskedArgs diff --git a/src/renderer/index.html b/src/renderer/index.html index aea6762..3832946 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2,7 +2,7 @@ - Auto Caption v1.1.1 + Auto Caption v1.2.0 - +
+ + diff --git a/src/renderer/src/components/CaptionLog.vue b/src/renderer/src/components/CaptionLog.vue index c5e14f9..cb066e7 100644 --- a/src/renderer/src/components/CaptionLog.vue +++ b/src/renderer/src/components/CaptionLog.vue @@ -216,7 +216,7 @@ const columns = [ }, ] -function changeBaseTime() { +function changeBaseTime(): void { if(baseHH.value < 0) baseHH.value = 0 if(baseMM.value < 0) baseMM.value = 0 if(baseMM.value > 59) baseMM.value = 59 @@ -240,7 +240,7 @@ function changeBaseTime() { } } -function exportCaptions() { +function exportCaptions(): void { const exportData = getExportData() const blob = new Blob([exportData], { type: exportFormat.value === 'json' ? 'application/json' : 'text/plain' @@ -256,7 +256,7 @@ function exportCaptions() { URL.revokeObjectURL(url) } -function getExportData() { +function getExportData(): string { if(exportFormat.value === 'json') return JSON.stringify(captionData.value, null, 2) let content = '' for(let i = 0; i < captionData.value.length; i++){ @@ -270,7 +270,7 @@ function getExportData() { return content } -function copyCaptions() { +function copyCaptions(): void { let content = '' let start = 0 if(copyNum.value > 0) { @@ -289,7 +289,7 @@ function copyCaptions() { message.success(t('log.copySuccess')) } -function clearCaptions() { +function clearCaptions(): void { captionLog.clear() } diff --git a/src/renderer/src/components/CaptionStyle.vue b/src/renderer/src/components/CaptionStyle.vue index 141338c..aaa31c4 100644 --- a/src/renderer/src/components/CaptionStyle.vue +++ b/src/renderer/src/components/CaptionStyle.vue @@ -188,6 +188,16 @@ textShadow: currentTextShadow ? `${currentOffsetX}px ${currentOffsetY}px ${currentBlur}px ${currentTextShadowColor}` : 'none' }" > + @@ -282,11 +369,12 @@ 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 { notification, theme } from 'ant-design-vue' import { ExclamationCircleOutlined, FolderOpenOutlined ,InfoCircleOutlined } from '@ant-design/icons-vue'; import { useI18n } from 'vue-i18n' const { t } = useI18n() +const { token } = theme.useToken() const showMore = ref(false) const engineControl = useEngineControlStore() @@ -295,17 +383,31 @@ const { captionEngine, audioType, changeSignal } = storeToRefs(engineControl) const generalSetting = useGeneralSettingStore() const { uiColor } = storeToRefs(generalSetting) +const descriptionStyle = computed(() => ({ + // The generated primary palette for black is too dark for light-theme text. + backgroundColor: uiColor.value === '#000000' + ? token.value.colorFillTertiary + : token.value.colorPrimaryBg, + borderColor: uiColor.value === '#000000' + ? token.value.colorBorder + : token.value.colorPrimaryBorder, + color: token.value.colorText +})) + const currentSourceLang = ref('auto') const currentTargetLang = ref('zh') -const currentEngine = ref('gummy') +const currentEngine = ref('paraformer') const currentAudio = ref<0 | 1>(0) const currentTranslation = ref(true) const currentRecording = ref(false) -const currentTransModel = ref('ollama') -const currentOllamaName = ref('') -const currentOllamaUrl = ref('') -const currentOllamaApiKey = ref('') -const currentAPI_KEY = ref('') +const currentTranslationProvider = ref<'ollama' | 'openai' | 'google'>('ollama') +const currentOllamaModel = ref('') +const currentOpenaiModel = ref('') +const currentOpenaiBaseUrl = ref('https://api.openai.com/v1') +const currentOpenaiApiKey = ref('') +const currentDashscopeApiKey = ref('') +const currentDashscopeWorkspaceId = ref('') +const currentDashscopeRegion = ref<'cn-beijing' | 'ap-southeast-1'>('cn-beijing') const currentVoskModelPath = ref('') const currentSosvModelPath = ref('') const currentGlmUrl = ref('') @@ -317,6 +419,12 @@ const currentCustomizedApp = ref('') const currentCustomizedCommand = ref('') const currentStartTimeoutSeconds = ref(30) +const translationDescriptionKey = computed(() => + currentEngine.value === 'livetranslate' + ? 'engine.translationDescriptions.livetranslate' + : `engine.translationDescriptions.${currentTranslationProvider.value}` +) + const sLangList = computed(() => { for(let item of captionEngine.value){ if(item.value === currentEngine.value) { @@ -335,23 +443,25 @@ const tLangList = computed(() => { return [] }) -const transModel = computed(() => { +const translationProviders = computed(() => { for(let item of captionEngine.value){ if(item.value === currentEngine.value) { - return item.transModel + return item.translationProviders ?? [] } } return [] }) -function applyChange(){ +function applyChange(): void { if( - currentTranslation.value && transModel.value && - currentTransModel.value === 'ollama' && !currentOllamaName.value.trim() + currentTranslation.value && translationProviders.value.length && + ((currentTranslationProvider.value === 'ollama' && !currentOllamaModel.value.trim()) || + (currentTranslationProvider.value === 'openai' && + (!currentOpenaiModel.value.trim() || !currentOpenaiBaseUrl.value.trim()))) ) { notification.open({ - message: t('noti.ollamaNameNull'), - description: t('noti.ollamaNameNullNote'), + message: t('noti.translationConfigMissing'), + description: t('noti.translationConfigMissingNote'), duration: null, icon: () => h(ExclamationCircleOutlined, { style: 'color: #ff4d4f' }) }) @@ -360,15 +470,18 @@ function applyChange(){ engineControl.sourceLang = currentSourceLang.value engineControl.targetLang = currentTargetLang.value - engineControl.transModel = currentTransModel.value - engineControl.ollamaName = currentOllamaName.value + engineControl.translationProvider = currentTranslationProvider.value + engineControl.ollamaModel = currentOllamaModel.value engineControl.engine = currentEngine.value - engineControl.ollamaUrl = currentOllamaUrl.value ?? "http://localhost:11434" - engineControl.ollamaApiKey = currentOllamaApiKey.value + engineControl.openaiModel = currentOpenaiModel.value + engineControl.openaiBaseUrl = currentOpenaiBaseUrl.value || "https://api.openai.com/v1" + engineControl.openaiApiKey = currentOpenaiApiKey.value engineControl.audio = currentAudio.value engineControl.translation = currentTranslation.value engineControl.recording = currentRecording.value - engineControl.API_KEY = currentAPI_KEY.value + engineControl.dashscopeApiKey = currentDashscopeApiKey.value + engineControl.dashscopeWorkspaceId = currentDashscopeWorkspaceId.value + engineControl.dashscopeRegion = currentDashscopeRegion.value engineControl.voskModelPath = currentVoskModelPath.value engineControl.sosvModelPath = currentSosvModelPath.value engineControl.glmUrl = currentGlmUrl.value ?? "https://open.bigmodel.cn/api/paas/v4/audio/transcriptions" @@ -389,18 +502,21 @@ function applyChange(){ }); } -function cancelChange(){ +function cancelChange(): void { currentSourceLang.value = engineControl.sourceLang currentTargetLang.value = engineControl.targetLang - currentTransModel.value = engineControl.transModel - currentOllamaName.value = engineControl.ollamaName - currentOllamaUrl.value = engineControl.ollamaUrl - currentOllamaApiKey.value = engineControl.ollamaApiKey + currentTranslationProvider.value = engineControl.translationProvider + currentOllamaModel.value = engineControl.ollamaModel + currentOpenaiModel.value = engineControl.openaiModel + currentOpenaiBaseUrl.value = engineControl.openaiBaseUrl + currentOpenaiApiKey.value = engineControl.openaiApiKey currentEngine.value = engineControl.engine currentAudio.value = engineControl.audio currentTranslation.value = engineControl.translation currentRecording.value = engineControl.recording - currentAPI_KEY.value = engineControl.API_KEY + currentDashscopeApiKey.value = engineControl.dashscopeApiKey + currentDashscopeWorkspaceId.value = engineControl.dashscopeWorkspaceId + currentDashscopeRegion.value = engineControl.dashscopeRegion currentVoskModelPath.value = engineControl.voskModelPath currentSosvModelPath.value = engineControl.sosvModelPath currentGlmUrl.value = engineControl.glmUrl @@ -413,7 +529,7 @@ function cancelChange(){ currentStartTimeoutSeconds.value = engineControl.startTimeoutSeconds } -function selectFolderPath(type: 'vosk' | 'sosv' | 'rec') { +function selectFolderPath(type: 'vosk' | 'sosv' | 'rec'): void { window.electron.ipcRenderer.invoke('control.folder.select').then((folderPath) => { if(!folderPath) return if(type == 'vosk') @@ -450,11 +566,24 @@ watch(currentEngine, (val) => {