15 Commits
Author SHA1 Message Date
MeloriseandCodex 43faa05c30 build: 迁移依赖管理至 pnpm
AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-08-26 23:42:53 +08:00
MeloriseandCodex 16ddf53186 fix: 修复 Arch 远程源码打包并发布 0.1.15
AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-08-26 20:51:57 +08:00
github-actions[bot]andCodex cc4d747a3a build: 更新 flake.lock
AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-08-26 12:43:10 +00:00
MeloriseandCodex 88a91892d9 docs: 补充维护与交付约束
AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-08-26 20:41:42 +08:00
MeloriseandCodex e6f67be337 perf: 缓存会话编辑器并发布 0.1.14
AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-08-26 20:36:16 +08:00
MeloriseandCodex cbe34e91ef ui: 缩小设置窗口默认尺寸
AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-08-24 00:20:00 +08:00
MeloriseandCodex 8f8244af05 perf: 设置页改为按需创建
AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-08-24 00:20:00 +08:00
Melorise 6ea57c6375 update README 2026-08-23 23:56:03 +08:00
Melorise adfa4df236 Update README.md 2026-08-23 23:55:31 +08:00
MeloriseandCodex cd3fc9a7d7 perf: 禁用后台网页 GPU 加速
AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-08-23 22:01:27 +08:00
MeloriseandCodex 6c3013b0ae fix: 缩短悬浮球完成状态停留时间
AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-08-23 20:33:44 +08:00
MeloriseandCodex 7687350ca4 refactor: 移除悬浮球交互模式
AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-08-23 20:33:44 +08:00
MeloriseandCodex e3759efa48 fix: 切换后销毁非活动后端
AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-08-23 20:33:44 +08:00
Melorise 4e58ca6d1d Update README with Nix Cachix cache details
Add Nix Cachix cache source information to README.
2026-08-23 19:32:14 +08:00
Melorise 599cb2a120 Remove memory usage details for Electron version
Removed information about current Electron version memory usage.
2026-08-23 19:30:07 +08:00
38 changed files with 2832 additions and 4333 deletions
+13 -6
View File
@@ -87,10 +87,15 @@ jobs:
with:
ref: ${{ needs.update-flake-lock.outputs.commit_sha }}
- uses: pnpm/action-setup@v4
with:
version: 11.21.0
run_install: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache: pnpm
- name: 安装系统构建依赖
run: |
@@ -105,9 +110,9 @@ jobs:
MELOASR_DEB_ARCH: ${{ matrix.deb_arch }}
MELOASR_ELECTRON_APP_DIR: ${{ matrix.electron_app_dir }}
run: |
npm ci
npm run package:deb
npm run package:rpm
pnpm install --frozen-lockfile
pnpm run package:deb
pnpm run package:rpm
mkdir -p release-artifacts
cp packaging/out/*.deb packaging/out/*.rpm release-artifacts/
@@ -204,8 +209,10 @@ jobs:
path: release
merge-multiple: true
- name: 附带构建使用的 flake.lock
run: cp flake.lock release/flake.lock
- name: 附带发行元数据
run: |
cp flake.lock release/flake.lock
cp packaging/arch/PKGBUILD release/PKGBUILD
- uses: softprops/action-gh-release@v2
with:
+34 -4
View File
@@ -2,12 +2,14 @@
## 项目范围
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.10`。
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.16`。
不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。
除非用户另有要求,文档、界面文字和代码注释使用简体中文。
`AGENTS.md` 与 `CLAUDE.md` 必须始终逐字一致。修改其中任意一份时必须把相同内容同步到另一份,并在提交前使用字节比较确认一致。
## 核心不变量
- 网页编辑器中的当前完整内容是唯一可信识别结果。
@@ -73,12 +75,20 @@ MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `M
核心命令:
```bash
npm test
npm run build
npm run build:fcitx5
pnpm test
pnpm run build
pnpm run build:fcitx5
ctest --test-dir linux/fcitx5/build --output-on-failure
```
验证必须与实际改动范围对应,禁止为了展示“完整”而机械扩大测试范围:
- TypeScript、preload 或 renderer 改动运行 `pnpm test` 和 `pnpm run build`;
- 只有修改 Fcitx5 C++、CMake 构建逻辑或本地协议实现时,才运行 `pnpm run build:fcitx5` 和 `ctest`;仅同步发行版本号不要求重测未改动的 Fcitx5 逻辑;
- FHS 发行版是首要维护目标,FHS 构建或打包问题必须在对应 Debian、Ubuntu、RPM 或 Arch 环境处理,不得默认用 Nix 环境替代;Nix 只用于验证 Nix 打包路径;
- 项目依赖未安装时,按 `pnpm-lock.yaml` 运行 `pnpm install --frozen-lockfile`,不能以 `tsc` 或其它工具缺失为由跳过正常验证;
- 不得声称添加了未实际添加的测试,也不得把无关环境中的成功构建计入目标平台验证结果。
最低动态回归:连续三轮;文本缩短、改词和补标点;finish 仅一次;cancel/reset/disconnect;ready gate;登录探测;后端切换;多屏位置和键盘微调;托盘退出/重启;自启动;GTK、Qt、Chromium 和终端;X11 与 Wayland。
不要声称没有真实验证的发行版包或元宝端到端识别已经通过。
@@ -87,6 +97,26 @@ ctest --test-dir linux/fcitx5/build --output-on-failure
deb、rpm、Arch/pacman 和 Nix 都必须包含 Electron 应用、desktop/icon/autostart 文件和 Fcitx5 addon。桌面 ID、包名、Socket、addon 名称和版本保持一致。仓库地址和许可证确定前,不得把占位值作为正式发行元数据。
版本发布时同步 `package.json`、`pnpm-lock.yaml`、CHANGELOG、AppStream、Fcitx5 addon 版本和各发行版打包元数据,并运行 `scripts/release-metadata.mjs <version>` 校验发布说明。
`v*` Git 标签由远程 Release 工作流产生。除非用户明确要求,本地禁止创建、移动或删除发布标签,也禁止推送提交或标签。“更新版本”或“更新发版”本身不等于授权本地打标签。
## 工程判断与性能修改
- 先区分稳定对象与对象内部的可变内容。一次语音会话中编辑器根元素视为稳定对象,可以缓存;识别文本所在的子树内容持续变化,必须重新序列化完整结果。
- 不得用没有实际证据的极端场景否定正常工程假设,例如假设同一录音会话中的编辑器根元素会被持续重建。若稳定对象意外失效,应采用明确失败策略,不能为猜测场景增加常态热路径开销。
- 性能结论必须区分静态代码分析与真实测量。没有 profiler、计时或内存采样时,不得给出伪精确的性能收益。
- 优化热路径时先消除重复 DOM 查询、重复序列化、重复 IPC 和无意义轮询,不得破坏网页常驻、实时启动录音和完整文本同步这些产品前提。
## 工作边界与交付检查
- 开始操作前确认实际 Git 仓库根目录,不能仅依赖外层工作区路径。
- 用户要求实现修改时,应完成代码、对应验证、版本与 CHANGELOG 更新等明确要求;遇到缺失依赖要解决或准确报告真实阻塞,不能自行缩减交付范围。
- 不得为了反驳用户而引入没有证据的假设。用户指出实现与事实矛盾时,应先核对代码链路并修正判断。
- 只修改任务相关文件,不运行与改动无关的构建,不擅自扩大发布、标签、推送或外部操作范围。
- 提交前检查 `git diff --check`、发行元数据校验和对应测试;提交后检查 `git status --short`、提交内容和工作树是否干净。
- 只要修改了仓库文件,就必须在本轮创建对应 Git commit,不得留下未提交改动;如果不准备或不能提交,就不得修改文件。Tag 和 push 仍需分别获得用户明确授权,不能从修改或 commit 推定。
## 提交约定
按功能粒度提交,只包含相关文件。提交前运行对应验证。AI 辅助提交尾部统一添加:
+43
View File
@@ -2,6 +2,49 @@
本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 的格式。
## [0.1.16] - 2026-08-26
### 修复
- 项目依赖管理、构建脚本、Arch 打包和 Release 工作流统一迁移到 pnpm,避免 npm 将第三方镜像地址识别为禁用的 remote 包。
- 使用不包含 registry 下载地址的 `pnpm-lock.yaml` 替代 `package-lock.json`,使锁文件不再固化生成环境的 npm 镜像。
## [0.1.15] - 2026-08-26
### 修复
- Arch PKGBUILD 改为直接下载 GitHub Release 标签对应的源码归档,不再要求发布者或用户手动放置源码包。
- 修正 GitHub 标签归档的解压目录处理,使 `makepkg` 可以直接完成构建、测试和打包。
- 远程 Release 现在附带当前版本的 `PKGBUILD`,Arch 用户无需再从源码树中单独提取打包文件。
## [0.1.14] - 2026-08-26
### 性能
- 语音会话开始时缓存网页编辑器根元素,后续文本更新直接序列化该元素,避免每次更新重复查询 DOM、计算样式和读取布局。
- 复用会话启动时的初始文本序列化结果,减少一次重复的完整编辑器遍历。
## [0.1.13] - 2026-08-24
### 变更
- 设置页改为按需创建,关闭窗口后立即销毁其 renderer 以降低常驻内存。
- 缩小设置页默认与最小窗口尺寸,使其匹配当前功能密度。
## [0.1.12] - 2026-08-23
### 变更
- 全平台启动时强制禁用 Electron GPU 硬件加速,降低后台网页 renderer 的无效显存与内存占用。
## [0.1.11] - 2026-08-23
### 变更
- 切换后端时销毁非当前后端的网页窗口,保留登录会话但不再保活其 renderer。
- 移除悬浮球点击录音和常驻显示功能,悬浮球仅用于显示语音会话状态。
- 缩短正常完成后的绿色状态显示时间。
## [0.1.10] - 2026-08-23
### 修复
+34 -4
View File
@@ -2,12 +2,14 @@
## 项目范围
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.10`。
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.16`。
不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。
除非用户另有要求,文档、界面文字和代码注释使用简体中文。
`AGENTS.md` 与 `CLAUDE.md` 必须始终逐字一致。修改其中任意一份时必须把相同内容同步到另一份,并在提交前使用字节比较确认一致。
## 核心不变量
- 网页编辑器中的当前完整内容是唯一可信识别结果。
@@ -73,12 +75,20 @@ MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `M
核心命令:
```bash
npm test
npm run build
npm run build:fcitx5
pnpm test
pnpm run build
pnpm run build:fcitx5
ctest --test-dir linux/fcitx5/build --output-on-failure
```
验证必须与实际改动范围对应,禁止为了展示“完整”而机械扩大测试范围:
- TypeScript、preload 或 renderer 改动运行 `pnpm test` 和 `pnpm run build`;
- 只有修改 Fcitx5 C++、CMake 构建逻辑或本地协议实现时,才运行 `pnpm run build:fcitx5` 和 `ctest`;仅同步发行版本号不要求重测未改动的 Fcitx5 逻辑;
- FHS 发行版是首要维护目标,FHS 构建或打包问题必须在对应 Debian、Ubuntu、RPM 或 Arch 环境处理,不得默认用 Nix 环境替代;Nix 只用于验证 Nix 打包路径;
- 项目依赖未安装时,按 `pnpm-lock.yaml` 运行 `pnpm install --frozen-lockfile`,不能以 `tsc` 或其它工具缺失为由跳过正常验证;
- 不得声称添加了未实际添加的测试,也不得把无关环境中的成功构建计入目标平台验证结果。
最低动态回归:连续三轮;文本缩短、改词和补标点;finish 仅一次;cancel/reset/disconnect;ready gate;登录探测;后端切换;多屏位置和键盘微调;托盘退出/重启;自启动;GTK、Qt、Chromium 和终端;X11 与 Wayland。
不要声称没有真实验证的发行版包或元宝端到端识别已经通过。
@@ -87,6 +97,26 @@ ctest --test-dir linux/fcitx5/build --output-on-failure
deb、rpm、Arch/pacman 和 Nix 都必须包含 Electron 应用、desktop/icon/autostart 文件和 Fcitx5 addon。桌面 ID、包名、Socket、addon 名称和版本保持一致。仓库地址和许可证确定前,不得把占位值作为正式发行元数据。
版本发布时同步 `package.json`、`pnpm-lock.yaml`、CHANGELOG、AppStream、Fcitx5 addon 版本和各发行版打包元数据,并运行 `scripts/release-metadata.mjs <version>` 校验发布说明。
`v*` Git 标签由远程 Release 工作流产生。除非用户明确要求,本地禁止创建、移动或删除发布标签,也禁止推送提交或标签。“更新版本”或“更新发版”本身不等于授权本地打标签。
## 工程判断与性能修改
- 先区分稳定对象与对象内部的可变内容。一次语音会话中编辑器根元素视为稳定对象,可以缓存;识别文本所在的子树内容持续变化,必须重新序列化完整结果。
- 不得用没有实际证据的极端场景否定正常工程假设,例如假设同一录音会话中的编辑器根元素会被持续重建。若稳定对象意外失效,应采用明确失败策略,不能为猜测场景增加常态热路径开销。
- 性能结论必须区分静态代码分析与真实测量。没有 profiler、计时或内存采样时,不得给出伪精确的性能收益。
- 优化热路径时先消除重复 DOM 查询、重复序列化、重复 IPC 和无意义轮询,不得破坏网页常驻、实时启动录音和完整文本同步这些产品前提。
## 工作边界与交付检查
- 开始操作前确认实际 Git 仓库根目录,不能仅依赖外层工作区路径。
- 用户要求实现修改时,应完成代码、对应验证、版本与 CHANGELOG 更新等明确要求;遇到缺失依赖要解决或准确报告真实阻塞,不能自行缩减交付范围。
- 不得为了反驳用户而引入没有证据的假设。用户指出实现与事实矛盾时,应先核对代码链路并修正判断。
- 只修改任务相关文件,不运行与改动无关的构建,不擅自扩大发布、标签、推送或外部操作范围。
- 提交前检查 `git diff --check`、发行元数据校验和对应测试;提交后检查 `git status --short`、提交内容和工作树是否干净。
- 只要修改了仓库文件,就必须在本轮创建对应 Git commit,不得留下未提交改动;如果不准备或不能提交,就不得修改文件。Tag 和 push 仍需分别获得用户明确授权,不能从修改或 commit 推定。
## 提交约定
按功能粒度提交,只包含相关文件。提交前运行对应验证。AI 辅助提交尾部统一添加:
+16 -14
View File
@@ -1,10 +1,10 @@
# MeloASR
大模型的进步推动了自然语言处理的进化,语音输入正在成为主流。在其它桌面平台下,微信输入法,豆包输入法,千问输入法等支持语音输入甚至以此为卖点的新兴中文输入法正在成为主流。
大模型的进步推动了自然语言处理的进化,语音输入正在成为主流。在其它桌面平台下,微信输入法,豆包输入法,千问输入法等支持语音输入甚至以此为卖点的新兴中文输入法层出不穷。
而在linux下,相关领域却长期空白,现有实现主要聚焦于以下几种类型:
api调用——调用各大ai厂商的语音转文字api。显然,该方案最大的问题在于——要钱。
api调用——调用各大ai厂商的语音转文字api。显然,该方案最大的问题在于——要钱。
本地模型——识别准确率低,技术落后,和其他平台的能实时校正的ai语音输入根本不是一个量级的东西,尤其是针对中文环境。
我们注意到,很多大模型的网页对话窗口,都支持语音输入,而且完全免费——因为语音输入最后只是输入到文本框,并没有发送实际的对话请求,而大模型就算是付费也往往是根据对话请求来计费的。
@@ -20,8 +20,6 @@ api调用——调用各大ai厂商的语音转文字api。显然,该方案最
### 本项目暂不考虑迁移到Tauri。我们充分理解这种需要后台常驻的系统性应用,在Tauri下开发似乎更加合适,事实上我们也确实进行了两次Tauri迁移。但元宝和千问web版对于webkit2gtk的表现实在是过于糟糕了(至少是在我的目标设备上),即使使用默认的gnome浏览器进行语音输入都会卡死,更不用说后续自动化的抓取了。还请各位理解,该项目是一个偏个人向的项目(毕竟使用的这种方式,大规模公开那就是商业侵权),而我的目标开发设备是一台非常老旧的设备,我没法说因为为了在他人的设备上获得更出色的性能而放弃对自己设备的适配。也许未来我们会在这些web端的兼容性得到改善,或者该目标设备被淘汰以后,才会考虑向Tauri迁移。
### 当前electron版的常驻占用内存在200mb左右。
### 项目暂不支持豆包后端。豆包的ASR输入是直接发送请求,而非像元宝和千问一样输出到输入框,这就需要拦截或自行重新实现请求,技术难度高且有风控风险。
### 同时该项目是中文输入主导的,也不考虑适配chatgpt等非中文优先的平台。
@@ -54,12 +52,12 @@ api调用——调用各大ai厂商的语音转文字api。显然,该方案最
依赖:Node.js 22+、CMake 3.16+、支持 C++20 的编译器、Fcitx5 Core/Utils 开发包、nlohmann-json。
```bash
npm install
npm test
npm run build:fcitx5
pnpm install --frozen-lockfile
pnpm test
pnpm run build:fcitx5
./linux/fcitx5/install-user.sh
fcitx5 -r
npm start
pnpm start
```
应用默认由托盘常驻,设置页与后端调试窗口默认隐藏。首次打开某个后端的调试窗口时,应用会提示登录;登录状态保存在该后端自己的 persistent partition 中。
@@ -80,18 +78,22 @@ npm start
## Linux 分发
```bash
npm run build:linux:dir
npm run package:deb
npm run package:rpm
pnpm run build:linux:dir
pnpm run package:deb
pnpm run package:rpm
```
Arch/pacman 配置位于 `packaging/arch/`,Nix 入口为 `flake.nix`。详细依赖、验证状态和发布前替换项见 `packaging/README.md`。
## Nix Cachix缓存源
缓存源:https://meloasr.cachix.org
公钥:meloasr.cachix.org-1:oMmpfj7D1pQTtJx6cHYupvPDJPFlkBB0JIpJYvkR5hc=
## 验证
```bash
npm test
npm run build
npm run build:fcitx5
pnpm test
pnpm run build
pnpm run build:fcitx5
ctest --test-dir linux/fcitx5/build --output-on-failure
```
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1787360063,
"narHash": "sha256-dt4WdcvsA8/RCe+VZZwqU0X+XMM3wBbGCWA0/sFWzGo=",
"lastModified": 1787498568,
"narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2c423e03bbafcff28bfadc6781a4a8257f205cb5",
"rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188",
"type": "github"
},
"original": {
+1 -1
View File
@@ -28,6 +28,7 @@
default = pkgs.mkShell {
packages = with pkgs; [
nodejs
pnpm_11
cmake
pkg-config
fcitx5
@@ -37,4 +38,3 @@
});
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.16)
project(meloasr-fcitx5 VERSION 0.1.10 LANGUAGES CXX)
project(meloasr-fcitx5 VERSION 0.1.16 LANGUAGES CXX)
include(GNUInstallDirs)
include(CTest)
+1 -1
View File
@@ -6,7 +6,7 @@ Comment[zh_CN]=将 MeloASR 网页后端连接到当前输入上下文
Type=SharedLibrary
Library=@MELOASR_LIBRARY_PATH@
Category=Module
Version=0.1.10
Version=0.1.16
OnDemand=False
Configurable=False
-4158
View File
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -1,21 +1,22 @@
{
"name": "meloasr",
"productName": "MeloASR",
"version": "0.1.10",
"version": "0.1.16",
"private": true,
"packageManager": "pnpm@11.21.0",
"author": "MeloASR contributors",
"description": "基于网页语音识别后端和 Fcitx5 的 Linux 实时语音输入工具",
"main": "dist/main/index.js",
"scripts": {
"clean": "node scripts/clean.mjs",
"build": "npm run clean && tsc && esbuild src/preload/backend.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist/preload/backend.js && node scripts/copy-static.mjs",
"build": "pnpm run clean && tsc && esbuild src/preload/backend.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist/preload/backend.js && node scripts/copy-static.mjs",
"typecheck": "tsc --noEmit",
"start": "npm run build && electron --ozone-platform=x11 .",
"start": "pnpm run build && electron --ozone-platform=x11 .",
"build:fcitx5": "cmake -S linux/fcitx5 -B linux/fcitx5/build -DCMAKE_BUILD_TYPE=Release && cmake --build linux/fcitx5/build --parallel",
"build:linux:dir": "npm run build && electron-builder --config packaging/electron-builder.yml --linux dir",
"package:deb": "npm run build:linux:dir && packaging/deb/build-deb.sh",
"package:rpm": "npm run build:linux:dir && packaging/rpm/build-rpm.sh",
"test": "npm run typecheck && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js"
"build:linux:dir": "pnpm run build && electron-builder --config packaging/electron-builder.yml --linux dir",
"package:deb": "pnpm run build:linux:dir && packaging/deb/build-deb.sh",
"package:rpm": "pnpm run build:linux:dir && packaging/rpm/build-rpm.sh",
"test": "pnpm run typecheck && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js"
},
"devDependencies": {
"@types/node": "^24.0.0",
+7 -7
View File
@@ -1,6 +1,6 @@
# Linux 分发打包
MeloASR 0.1.10 的系统包由两部分组成:
MeloASR 0.1.16 的系统包由两部分组成:
- Electron 应用安装到 `/opt/meloasr`,命令入口为 `/usr/bin/meloasr`;
- Fcitx5 常驻 addon 由目标发行版中的 CMake/Fcitx5 开发包编译,并安装到该环境报告的 addon 目录。
@@ -12,7 +12,7 @@ MeloASR 0.1.10 的系统包由两部分组成:
deb、rpm 和 Arch 配置期望项目提供:
```bash
npm run build:linux:dir
pnpm run build:linux:dir
```
该命令应在 `release/linux-unpacked/` 生成可直接运行的 Electron 目录,并且主程序名必须是 `meloasr`。仓库提供的 `electron-builder.yml` 只生成这一中间目录;deb/rpm/Arch 的系统文件仍由各自脚本管理,避免用安装钩子复制未被包管理器追踪的 Fcitx5 文件。
@@ -21,7 +21,7 @@ npm run build:linux:dir
```json
{
"build:linux:dir": "npm run build && electron-builder --config packaging/electron-builder.yml --linux dir"
"build:linux:dir": "pnpm run build && electron-builder --config packaging/electron-builder.yml --linux dir"
}
```
@@ -36,18 +36,18 @@ packaging/scripts/stage-linux.sh release/linux-unpacked packaging/out/root
在 Debian/Ubuntu 对应目标环境中安装 Node.js 构建依赖、CMake、C++ 编译器、Fcitx5 开发包与 nlohmann-json 开发包,然后执行:
```bash
npm run build:linux:dir
pnpm run build:linux:dir
packaging/deb/build-deb.sh
```
产物位于 `packaging/out/meloasr_0.1.10_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。
产物位于 `packaging/out/meloasr_0.1.16_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。
## rpm
在目标 RPM 发行版中准备相同的编译依赖和 `rpmbuild`,然后执行:
```bash
npm run build:linux:dir
pnpm run build:linux:dir
packaging/rpm/build-rpm.sh
```
@@ -64,7 +64,7 @@ nix build
nix run
```
flake 使用 nixpkgs 的 Electron,Node 依赖由 `package-lock.json` 的 integrity 数据导入,不需要手写固定输出 hash。仓库首次确定发布地址后,需要锁定并提交 `flake.lock`。当前环境没有 Nix,尚未实际执行上述命令。
flake 使用 nixpkgs 的 Electron,Node 依赖由 `pnpm-lock.yaml` 和固定输出的 pnpm store 导入。仓库首次确定发布地址后,需要锁定并提交 `flake.lock`。
## 发布前必须完成
+11 -9
View File
@@ -1,19 +1,21 @@
pkgname=meloasr
pkgver=0.1.10
pkgver=0.1.16
pkgrel=1
pkgdesc='Web-backed voice input for Fcitx5'
arch=('x86_64')
url='https://github.com/Melorise/MeloASR'
license=('MPL2')
depends=('fcitx5' 'gtk3' 'nss' 'libxss' 'mesa' 'alsa-lib')
makedepends=('nodejs' 'npm' 'cmake' 'gcc' 'nlohmann-json')
source=("${pkgname}-${pkgver}.tar.gz")
makedepends=('nodejs' 'pnpm' 'cmake' 'gcc' 'nlohmann-json')
source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz")
sha256sums=('SKIP')
_source_dir="MeloASR-${pkgver}"
build() {
cd "${srcdir}/${pkgname}-${pkgver}"
npm ci
npm run build:linux:dir
cd "${srcdir}/${_source_dir}"
pnpm install --frozen-lockfile
pnpm run build:linux:dir
cmake -S linux/fcitx5 -B build/fcitx5 \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr
@@ -21,13 +23,13 @@ build() {
}
check() {
cd "${srcdir}/${pkgname}-${pkgver}"
npm test
cd "${srcdir}/${_source_dir}"
pnpm test
ctest --test-dir build/fcitx5 --output-on-failure
}
package() {
cd "${srcdir}/${pkgname}-${pkgver}"
cd "${srcdir}/${_source_dir}"
install -d "${pkgdir}/opt/meloasr"
cp -a release/linux-unpacked/. "${pkgdir}/opt/meloasr/"
+4 -2
View File
@@ -1,9 +1,11 @@
# Arch Linux 构建
发布时把项目源码归档为 `meloasr-0.1.10.tar.gz`,与 `PKGBUILD` 放在同一目录,随后运行:
远程 Release 会附带当前版本的 `PKGBUILD`。下载后直接运行:
```bash
curl --fail --location --remote-name \
https://github.com/Melorise/MeloASR/releases/download/v0.1.16/PKGBUILD
makepkg --syncdeps --cleanbuild
```
正式发布必须把 `source` 改为仓库的不可变 release URL,并以 `updpkgsums` 写入真实 SHA-256;当前源码包仍保留 `SKIP`,不能直接作为正式 Arch 发布源。
`PKGBUILD` 会根据 `pkgver` 直接下载 GitHub Release 标签对应的源码归档,无需手动下载、重命名或放置源码包。源码地址固定为 `https://github.com/Melorise/MeloASR/archive/refs/tags/v${pkgver}.tar.gz`,因此同一份 `PKGBUILD` 可以重复构建该版本。
+6
View File
@@ -16,6 +16,12 @@
<binary>meloasr</binary>
</provides>
<releases>
<release version="0.1.16" date="2026-08-26"/>
<release version="0.1.15" date="2026-08-26"/>
<release version="0.1.14" date="2026-08-26"/>
<release version="0.1.13" date="2026-08-24"/>
<release version="0.1.12" date="2026-08-23"/>
<release version="0.1.11" date="2026-08-23"/>
<release version="0.1.10" date="2026-08-23"/>
<release version="0.1.9" date="2026-08-23"/>
<release version="0.1.8" date="2026-08-23"/>
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
version="${MELOASR_VERSION:-0.1.10}"
version="${MELOASR_VERSION:-0.1.16}"
arch="${MELOASR_DEB_ARCH:-$(dpkg --print-architecture)}"
app_dir="${1:-${MELOASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
work_dir="${project_dir}/packaging/out/deb-root"
+16 -12
View File
@@ -1,7 +1,9 @@
{ lib
, stdenv
, importNpmLock
, fetchPnpmDeps
, nodejs
, pnpm_11
, pnpmConfigHook
, electron
, cmake
, pkg-config
@@ -10,19 +12,26 @@
, nlohmann_json
}:
let
pnpm = pnpm_11;
in
stdenv.mkDerivation (finalAttrs: {
pname = "meloasr";
version = (builtins.fromJSON (builtins.readFile ../../package.json)).version;
src = ../..;
npmDeps = importNpmLock {
npmRoot = finalAttrs.src;
pnpmDeps = fetchPnpmDeps {
inherit (finalAttrs) pname version src;
inherit pnpm;
fetcherVersion = 4;
hash = "sha256-KoxRIhZVhshxTpl5rzzfUnaSAuGo3gCvFD2FsltN1gc=";
};
nativeBuildInputs = [
nodejs
importNpmLock.npmConfigHook
pnpm
pnpmConfigHook
cmake
pkg-config
makeWrapper
@@ -30,7 +39,7 @@ stdenv.mkDerivation (finalAttrs: {
buildInputs = [ fcitx5 nlohmann_json ];
# 保留 npmConfigHook 的 postConfigure 生命周期,但不对项目根目录运行默认 CMake 配置。
# 保留 pnpmConfigHook 的 postConfigure 生命周期,但不对项目根目录运行默认 CMake 配置。
configurePhase = ''
runHook preConfigure
runHook postConfigure
@@ -38,7 +47,7 @@ stdenv.mkDerivation (finalAttrs: {
buildPhase = ''
runHook preBuild
npm run build
pnpm run build
cmake -S linux/fcitx5 -B build/fcitx5 \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$out" \
@@ -49,7 +58,7 @@ stdenv.mkDerivation (finalAttrs: {
checkPhase = ''
runHook preCheck
npm test
pnpm test
ctest --test-dir build/fcitx5 --output-on-failure
runHook postCheck
'';
@@ -65,11 +74,6 @@ stdenv.mkDerivation (finalAttrs: {
cp package.json "$appDir/"
cp -r dist "$appDir/"
if [ -d node_modules ]; then
npm prune --omit=dev --ignore-scripts
cp -r node_modules "$appDir/"
fi
install -Dm644 packaging/assets/meloasr.desktop \
"$out/share/applications/meloasr.desktop"
install -Dm644 packaging/assets/meloasr-autostart.desktop \
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
version="${MELOASR_VERSION:-0.1.10}"
version="${MELOASR_VERSION:-0.1.16}"
app_dir="${1:-${MELOASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
stage_dir="${project_dir}/packaging/out/rpm-root"
top_dir="${project_dir}/packaging/out/rpmbuild"
+19
View File
@@ -36,6 +36,25 @@ tar -C %{buildroot} -xzf %{SOURCE0}
/etc/xdg/autostart/meloasr.desktop
%changelog
* Wed Aug 26 2026 MeloASR contributors - 0.1.16-1
- Migrate dependency installation and build commands from npm to pnpm.
* Wed Aug 26 2026 MeloASR contributors - 0.1.15-1
- Download the Arch package source directly from the matching GitHub release tag.
* Wed Aug 26 2026 MeloASR contributors - 0.1.14-1
- Cache the active web editor for the duration of each speech session.
* Mon Aug 24 2026 MeloASR contributors - 0.1.13-1
- Create the settings renderer only when it is opened and destroy it on close.
* Sun Aug 23 2026 MeloASR contributors - 0.1.12-1
- Disable Electron GPU acceleration for background web renderers.
* Sun Aug 23 2026 MeloASR contributors - 0.1.11-1
- Release only the selected speech backend renderer.
- Keep the overlay as a non-interactive session status indicator.
* Sun Aug 23 2026 MeloASR contributors - 0.1.10-1
- Restore the Nix npm configuration hook before building the application.
+2530
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
electron: true
electron-winstaller: false
esbuild: true
+24 -2
View File
@@ -12,6 +12,7 @@ interface BackendRecord {
page: BrowserWindow;
windows: Set<BrowserWindow>;
exposed: boolean;
disposed: boolean;
status: BackendRuntimeStatus;
}
@@ -72,6 +73,7 @@ export class BackendManager extends EventEmitter {
page: root,
windows: new Set([root]),
exposed: false,
disposed: false,
status: loadingStatus(definition)
};
this.records.set(id, record);
@@ -89,7 +91,12 @@ export class BackendManager extends EventEmitter {
async select(id: string): Promise<void> {
requireBackend(id);
this.hideRecord(this.records.get(this.settings.get().backendId));
const previousId = this.settings.get().backendId;
if (previousId === id) {
await this.ensure(id);
return;
}
this.disposeRecord(previousId);
this.settings.update({ backendId: id });
await this.ensure(id);
this.emit('active-changed', id);
@@ -200,7 +207,7 @@ export class BackendManager extends EventEmitter {
private registerWindow(window: BrowserWindow, record: BackendRecord): void {
window.on('close', (event) => {
if ((app as typeof app & { isQuitting?: boolean }).isQuitting) return;
if (record.disposed || (app as typeof app & { isQuitting?: boolean }).isQuitting) return;
event.preventDefault();
this.hideRecord(record);
});
@@ -209,6 +216,10 @@ export class BackendManager extends EventEmitter {
overrideBrowserWindowOptions: { show: record.exposed, skipTaskbar: !record.exposed, focusable: true }
}));
window.webContents.on('did-create-window', (child) => {
if (record.disposed) {
child.destroy();
return;
}
record.windows.add(child);
child.on('closed', () => record.windows.delete(child));
this.registerWindow(child, record);
@@ -241,6 +252,17 @@ export class BackendManager extends EventEmitter {
for (const window of record.windows) this.parkWindow(window);
}
private disposeRecord(id: string): void {
const record = this.records.get(id);
if (!record) return;
record.disposed = true;
this.records.delete(id);
for (const window of [...record.windows]) {
if (!window.isDestroyed()) window.destroy();
}
record.windows.clear();
}
private parkWindow(window: BrowserWindow): void {
if (window.isDestroyed()) return;
window.hide();
+3 -19
View File
@@ -10,6 +10,8 @@ import { TrayController } from './tray-controller';
import type { BackendStatusPayload, Point } from '../shared/contracts';
app.setName('MeloASR');
app.disableHardwareAcceleration();
app.commandLine.appendSwitch('disable-gpu');
const singleInstance = app.requestSingleInstanceLock();
if (!singleInstance) {
@@ -37,9 +39,7 @@ async function bootstrap(): Promise<void> {
const repositoryUrl = process.env.MELOASR_REPOSITORY_URL?.trim() ||
'https://github.com/Melorise/MeloASR';
const settingsWindow = new SettingsWindow(settings, backends, overlay, sessions, repositoryUrl);
const settingsWindowReady = settingsWindow.create();
registerIpc({ settings, autoStart, bridge, backends, overlay, sessions, settingsWindow });
await settingsWindowReady;
const tray = new TrayController(
backends,
settingsWindow,
@@ -66,7 +66,7 @@ async function bootstrap(): Promise<void> {
await backends.ensure(settings.get().backendId);
tray.refresh();
app.on('second-instance', () => settingsWindow.show());
app.on('second-instance', () => void settingsWindow.show());
app.on('before-quit', () => { (app as typeof app & { isQuitting?: boolean }).isQuitting = true; });
app.on('will-quit', () => {
tray.destroy();
@@ -88,9 +88,6 @@ function registerIpc(services: Services): void {
const { settings, autoStart, bridge, backends, overlay, sessions, settingsWindow } = services;
const settingsSender = (senderId: number): boolean =>
Boolean(settingsWindow.window && !settingsWindow.window.isDestroyed() && settingsWindow.window.webContents.id === senderId);
const overlaySender = (senderId: number): boolean =>
Boolean(overlay.window && !overlay.window.isDestroyed() && overlay.window.webContents.id === senderId);
ipcMain.handle('settings:get-state', (event) => {
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的设置请求');
return settingsWindow.state();
@@ -115,10 +112,6 @@ function registerIpc(services: Services): void {
overlay.applyPreset(preset, displayId);
return settingsWindow.state();
});
ipcMain.handle('settings:preview-overlay', (event) => {
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的预览请求');
overlay.preview();
});
ipcMain.handle('settings:begin-positioning', (event) => {
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的位置调整请求');
overlay.beginPositioning();
@@ -141,12 +134,6 @@ function registerIpc(services: Services): void {
settings.update({ autoStart: enabled });
return settingsWindow.state();
});
ipcMain.handle('settings:set-overlay-persistent', (event, enabled: unknown) => {
if (!settingsSender(event.sender.id) || typeof enabled !== 'boolean') throw new Error('无效的悬浮球设置');
settings.update({ overlayPersistent: enabled });
overlay.reconcilePersistent();
return settingsWindow.state();
});
ipcMain.handle('settings:set-diagnostic-logging', (event, enabled: unknown) => {
if (!settingsSender(event.sender.id) || typeof enabled !== 'boolean') throw new Error('无效的日志设置');
settings.update({ diagnosticLogging: enabled });
@@ -157,9 +144,6 @@ function registerIpc(services: Services): void {
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的外部链接请求');
await settingsWindow.openRepository();
});
ipcMain.on('overlay:toggle-recording', (event) => {
if (overlaySender(event.sender.id)) sessions.requestStartFromOverlay();
});
ipcMain.on('backend-status', (event, payload: BackendStatusPayload) => backends.handleStatus(event, payload));
ipcMain.on('backend-transcript', (event, payload: { backend?: string; text?: string }) => {
if (typeof payload?.backend === 'string' && typeof payload.text === 'string' && backends.senderIsActive(event, payload.backend)) {
+2 -21
View File
@@ -35,9 +35,9 @@ export class OverlayController {
}
});
this.window.setAlwaysOnTop(true, 'floating');
this.window.setIgnoreMouseEvents(true);
await this.window.loadFile(path.join(__dirname, '..', 'renderer', 'overlay.html'));
this.position();
this.reconcilePersistent();
}
position(point = this.settings.get().overlayPosition): Point {
@@ -68,7 +68,6 @@ export class OverlayController {
if (!this.window || this.window.isDestroyed()) return;
this.positioning = true;
this.clearHideTimer();
this.window.setIgnoreMouseEvents(true);
this.window.webContents.send('overlay-state', { state: 'idle' });
this.window.showInactive();
}
@@ -77,8 +76,7 @@ export class OverlayController {
if (!this.positioning) return;
this.positioning = false;
if (!this.window || this.window.isDestroyed()) return;
this.window.setIgnoreMouseEvents(false);
this.reconcilePersistent();
this.window.hide();
}
showState(state: SessionState | 'done' | 'error'): void {
@@ -90,26 +88,9 @@ export class OverlayController {
hideLater(delay = 900): void {
this.clearHideTimer();
if (this.settings.get().overlayPersistent) return;
this.hideTimer = setTimeout(() => this.window?.hide(), delay);
}
preview(): void {
this.position();
this.showState('recording');
this.hideLater(1800);
}
reconcilePersistent(): void {
if (!this.window || this.window.isDestroyed()) return;
if (this.positioning) return;
if (this.settings.get().overlayPersistent) {
this.showState('idle');
} else {
this.window.hide();
}
}
private clearHideTimer(): void {
if (this.hideTimer) clearTimeout(this.hideTimer);
this.hideTimer = null;
+3 -9
View File
@@ -6,6 +6,8 @@ import type { BackendManager } from './backend-manager';
import type { FcitxBridge } from './fcitx-bridge';
import type { OverlayController } from './overlay-controller';
const COMPLETION_INDICATOR_DURATION_MS = 450;
export class SessionController extends EventEmitter {
state: SessionState = 'idle';
private mirroredText = '';
@@ -54,14 +56,6 @@ export class SessionController extends EventEmitter {
if (this.stopRequestedDuringStart) this.stop();
}
requestStartFromOverlay(): void {
if (this.state === 'recording' || this.state === 'starting') {
this.stop();
return;
}
if (!this.bridge.ready || !this.bridge.requestInputStart()) this.reject('Fcitx5 插件尚未连接');
}
stop(): void {
if (this.state === 'starting') {
this.stopRequestedDuringStart = true;
@@ -105,7 +99,7 @@ export class SessionController extends EventEmitter {
this.state = 'idle';
this.mirroredText = '';
this.overlay.showState('done');
this.overlay.hideLater();
this.overlay.hideLater(COMPLETION_INDICATOR_DURATION_MS);
this.emitState();
}
+3 -2
View File
@@ -8,7 +8,6 @@ const DEFAULT_SETTINGS: AppSettings = {
backendId: 'qianwen',
shortcut: 'Control+Shift+space',
autoStart: true,
overlayPersistent: false,
diagnosticLogging: false,
overlayPosition: null,
loginNoticeShown: {}
@@ -22,10 +21,12 @@ export class SettingsStore extends EventEmitter {
try {
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) as Partial<AppSettings> & {
backend?: string;
overlayPersistent?: unknown;
};
const { overlayPersistent: _obsoleteOverlayPersistent, ...saved } = parsed;
this.value = {
...DEFAULT_SETTINGS,
...parsed,
...saved,
backendId: parsed.backendId ?? parsed.backend ?? DEFAULT_SETTINGS.backendId,
overlayPosition: this.isPoint(parsed.overlayPosition) ? {
x: Math.round(parsed.overlayPosition.x), y: Math.round(parsed.overlayPosition.y)
+29 -17
View File
@@ -9,6 +9,7 @@ import type { SettingsStore } from './settings-store';
export class SettingsWindow {
window: BrowserWindow | null = null;
private creating: Promise<BrowserWindow> | null = null;
constructor(
private readonly settings: SettingsStore,
@@ -18,12 +19,23 @@ export class SettingsWindow {
private readonly repositoryUrl: string | null
) {}
async create(): Promise<void> {
this.window = new BrowserWindow({
width: 720,
height: 820,
minWidth: 640,
minHeight: 680,
private async ensureWindow(): Promise<BrowserWindow> {
if (this.window && !this.window.isDestroyed()) return this.window;
if (this.creating) return this.creating;
this.creating = this.createWindow();
try {
return await this.creating;
} finally {
this.creating = null;
}
}
private async createWindow(): Promise<BrowserWindow> {
const window = new BrowserWindow({
width: 600,
height: 650,
minWidth: 560,
minHeight: 560,
show: false,
title: 'MeloASR 设置',
autoHideMenuBar: true,
@@ -35,18 +47,19 @@ export class SettingsWindow {
sandbox: true
}
});
this.window.on('close', (event) => {
if ((app as typeof app & { isQuitting?: boolean }).isQuitting) return;
event.preventDefault();
this.window?.hide();
this.window = window;
window.on('closed', () => {
this.overlay.endPositioning();
if (this.window === window) this.window = null;
});
this.window.on('hide', () => this.overlay.endPositioning());
await this.window.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html'));
await window.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html'));
return window;
}
show(): void {
this.window?.show();
this.window?.focus();
async show(): Promise<void> {
const window = await this.ensureWindow();
window.show();
window.focus();
this.publish();
}
@@ -60,7 +73,7 @@ export class SettingsWindow {
const definition = this.backends.activeDefinition();
const decision = resolveDebugLoginAction(Boolean(current.loginNoticeShown[definition.id]), confirmed);
if (decision.action === 'prompt') {
this.show();
await this.show();
this.window?.webContents.send('settings:login-notice', { backendLabel: definition.label });
return;
}
@@ -81,7 +94,6 @@ export class SettingsWindow {
sessionState: this.sessions.state,
shortcut: current.shortcut,
autoStart: current.autoStart,
overlayPersistent: current.overlayPersistent,
diagnosticLogging: current.diagnosticLogging,
overlayPosition: position,
displays: screen.getAllDisplays().map((display, index) => ({
+2 -2
View File
@@ -31,7 +31,7 @@ export class TrayController {
create(): void {
this.tray = new Tray(trayImage('loading'));
this.tray.setToolTip('MeloASR · 正在加载');
this.tray.on('click', () => this.settingsWindow.show());
this.tray.on('click', () => void this.settingsWindow.show());
this.refresh();
}
@@ -44,7 +44,7 @@ export class TrayController {
this.tray.setImage(trayImage(state));
this.tray.setToolTip(`MeloASR · ${status.detail}`);
this.tray.setContextMenu(Menu.buildFromTemplate([
{ label: '设置', click: () => this.settingsWindow.show() },
{ label: '设置', click: () => void this.settingsWindow.show() },
{ label: '打开登录/调试页面', click: () => void this.settingsWindow.openDebug() },
{ type: 'separator' },
{ label: '重启', click: this.onRestart },
+10 -7
View File
@@ -11,6 +11,7 @@ const adapter = getBackendWebAdapter(backendId);
let editorObserver: MutationObserver | undefined;
let pageObserver: MutationObserver | undefined;
let activeEditor: HTMLElement | null = null;
let active = false;
let stopping = false;
let lastSpeechText = '';
@@ -68,12 +69,11 @@ function schedulePageStatus(): void {
function emitCurrentEditorText(): void {
emitScheduled = false;
if (!active || !adapter) return;
const editor = adapter.findEditor(document);
if (!editor) {
if (!activeEditor?.isConnected) {
fail(`${adapter.definition.label}输入框在录音期间消失,本轮已停止`);
return;
}
const text = adapter.serialize(editor);
const text = adapter.serialize(activeEditor);
if (text === lastSpeechText) return;
lastSpeechText = text;
transcriptRevision += 1;
@@ -119,6 +119,7 @@ function resetSession(): void {
stopping = false;
lastTranscriptAt = 0;
editorObserver?.disconnect();
activeEditor = null;
diagnose('session-reset');
}
@@ -147,28 +148,30 @@ function startSpeech(): void {
return;
}
const initialText = adapter.serialize(editor);
diagnose('start-targets', {
editorLength: adapter.serialize(editor).length,
editorLength: initialText.length,
microphone: controlSnapshot(microphone)
});
clearTimeout(stopTimer);
placeCaretAtEnd(editor);
lastSpeechText = adapter.serialize(editor);
lastSpeechText = initialText;
transcriptRevision = 0;
lastTranscriptAt = Date.now();
active = true;
stopping = false;
activeEditor = editor;
observeEditor(editor);
microphone.click();
diagnose('start-clicked', { microphone: controlSnapshot(adapter.findMicrophone(document)) });
window.setTimeout(() => diagnose('start-after-250ms', {
microphone: controlSnapshot(adapter.findMicrophone(document)),
editorLength: adapter.findEditor(document) ? adapter.serialize(adapter.findEditor(document)!).length : null
editorLength: active && editor.isConnected ? adapter.serialize(editor).length : null
}), 250);
window.setTimeout(() => diagnose('start-after-1000ms', {
microphone: controlSnapshot(adapter.findMicrophone(document)),
editorLength: adapter.findEditor(document) ? adapter.serialize(adapter.findEditor(document)!).length : null
editorLength: active && editor.isConnected ? adapter.serialize(editor).length : null
}), 1_000);
}
-1
View File
@@ -1,7 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('meloOverlay', {
toggleRecording: (): void => ipcRenderer.send('overlay:toggle-recording'),
onState: (listener: (state: string) => void): void => {
ipcRenderer.on('overlay-state', (_event, payload: { state: string }) => listener(payload.state));
}
-3
View File
@@ -9,13 +9,10 @@ contextBridge.exposeInMainWorld('meloSettings', {
ipcRenderer.invoke('settings:set-position', position, displayId),
setPreset: (preset: string, displayId: string): Promise<SettingsViewState> =>
ipcRenderer.invoke('settings:set-preset', preset, displayId),
previewOverlay: (): Promise<void> => ipcRenderer.invoke('settings:preview-overlay'),
beginPositioning: (): Promise<void> => ipcRenderer.invoke('settings:begin-positioning'),
endPositioning: (): Promise<void> => ipcRenderer.invoke('settings:end-positioning'),
setShortcut: (shortcut: string): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-shortcut', shortcut),
setAutoStart: (enabled: boolean): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-auto-start', enabled),
setOverlayPersistent: (enabled: boolean): Promise<SettingsViewState> =>
ipcRenderer.invoke('settings:set-overlay-persistent', enabled),
setDiagnosticLogging: (enabled: boolean): Promise<SettingsViewState> =>
ipcRenderer.invoke('settings:set-diagnostic-logging', enabled),
openRepository: (): Promise<void> => ipcRenderer.invoke('settings:open-repository'),
-3
View File
@@ -8,19 +8,16 @@ declare global {
openDebug(confirmed?: boolean): Promise<void>;
setPosition(position: Point, displayId: string): Promise<SettingsViewState>;
setPreset(preset: string, displayId: string): Promise<SettingsViewState>;
previewOverlay(): Promise<void>;
beginPositioning(): Promise<void>;
endPositioning(): Promise<void>;
setShortcut(shortcut: string): Promise<SettingsViewState>;
setAutoStart(enabled: boolean): Promise<SettingsViewState>;
setOverlayPersistent(enabled: boolean): Promise<SettingsViewState>;
setDiagnosticLogging(enabled: boolean): Promise<SettingsViewState>;
openRepository(): Promise<void>;
onState(listener: (state: SettingsViewState) => void): void;
onLoginNotice(listener: (backendLabel: string) => void): void;
};
meloOverlay: {
toggleRecording(): void;
onState(listener: (state: string) => void): void;
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; }
body { display: grid; place-items: center; }
.voice-overlay { position: relative; display: grid; place-items: center; width: 68px; height: 68px; padding: 0; border: 0; border-radius: 50%; background: radial-gradient(circle at 35% 28%, #71aaff, #2568de 70%); box-shadow: 0 8px 24px rgba(23,79,177,.35); cursor: pointer; }
.voice-overlay { position: relative; display: grid; place-items: center; width: 68px; height: 68px; border-radius: 50%; background: radial-gradient(circle at 35% 28%, #71aaff, #2568de 70%); box-shadow: 0 8px 24px rgba(23,79,177,.35); }
.ring { position: absolute; inset: 0; border: 2px solid rgba(69,136,245,.45); border-radius: 50%; opacity: 0; }
.voice-overlay[data-state="recording"] .ring { animation: ripple 1.7s ease-out infinite; }
.voice-overlay[data-state="recording"] .ring.two { animation-delay: .55s; }
+2 -2
View File
@@ -6,10 +6,10 @@
<link rel="stylesheet" href="overlay.css">
</head>
<body>
<button class="voice-overlay" data-state="idle" aria-label="开始或停止语音输入">
<div class="voice-overlay" data-state="idle" aria-hidden="true">
<span class="ring one"></span><span class="ring two"></span>
<span class="bars"><i></i><i></i><i></i><i></i></span>
</button>
</div>
<script src="overlay.js"></script>
</body>
</html>
-4
View File
@@ -1,7 +1,3 @@
const root = document.querySelector<HTMLElement>('.voice-overlay')!;
root.addEventListener('click', () => window.meloOverlay.toggleRecording());
root.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') window.meloOverlay.toggleRecording();
});
window.meloOverlay.onState((state) => { root.dataset.state = state; });
-1
View File
@@ -18,7 +18,6 @@
<section>
<h2>悬浮球</h2>
<label class="switch"><input id="overlay-persistent" type="checkbox"><span>始终显示悬浮球</span></label>
<button id="open-position">调整悬浮球位置</button>
</section>
-6
View File
@@ -10,7 +10,6 @@ interface SettingsViewState {
sessionState: 'idle' | 'starting' | 'recording' | 'stopping';
shortcut: string;
autoStart: boolean;
overlayPersistent: boolean;
diagnosticLogging: boolean;
overlayPosition: { x: number; y: number };
displays: Array<{ id: string; label: string; workArea: { x: number; y: number; width: number; height: number } }>;
@@ -26,7 +25,6 @@ const x = document.querySelector<HTMLInputElement>('#position-x')!;
const y = document.querySelector<HTMLInputElement>('#position-y')!;
const shortcut = document.querySelector<HTMLButtonElement>('#shortcut')!;
const autoStart = document.querySelector<HTMLInputElement>('#auto-start')!;
const overlayPersistent = document.querySelector<HTMLInputElement>('#overlay-persistent')!;
const diagnosticLogging = document.querySelector<HTMLInputElement>('#diagnostic-logging')!;
const version = document.querySelector<HTMLElement>('#version')!;
const repository = document.querySelector<HTMLButtonElement>('#repository')!;
@@ -71,7 +69,6 @@ function render(next: SettingsViewState): void {
renderPositionPreview(next);
shortcut.textContent = capturingShortcut ? '请按下新快捷键…' : displayShortcut(next.shortcut);
autoStart.checked = next.autoStart;
overlayPersistent.checked = next.overlayPersistent;
diagnosticLogging.checked = next.diagnosticLogging;
version.textContent = next.version;
repository.textContent = next.repositoryUrl ?? '尚未配置';
@@ -197,9 +194,6 @@ shortcut.addEventListener('click', () => {
shortcut.focus();
});
autoStart.addEventListener('change', () => void apply(() => window.meloSettings.setAutoStart(autoStart.checked)));
overlayPersistent.addEventListener('change', () => void apply(
() => window.meloSettings.setOverlayPersistent(overlayPersistent.checked)
));
diagnosticLogging.addEventListener('change', () => void apply(
() => window.meloSettings.setDiagnosticLogging(diagnosticLogging.checked)
));
-2
View File
@@ -11,7 +11,6 @@ export interface AppSettings {
backendId: string;
shortcut: string;
autoStart: boolean;
overlayPersistent: boolean;
diagnosticLogging: boolean;
overlayPosition: Point | null;
loginNoticeShown: Record<string, boolean>;
@@ -43,7 +42,6 @@ export interface SettingsViewState {
sessionState: SessionState;
shortcut: string;
autoStart: boolean;
overlayPersistent: boolean;
diagnosticLogging: boolean;
overlayPosition: Point;
displays: DisplayInfo[];