17 Commits
Author SHA1 Message Date
Melorise 3a5b5d84ff Merge pull request #1 from Melorise/dev-1.0.2
Dev 1.0.2
2026-09-04 22:23:32 +08:00
MeloriseandCodex 857953dd1f chore: 更新版本至 1.0.2
同步应用、Fcitx5 addon、AppStream、发行版打包元数据、维护约定和更新日志。

AI-assisted-by: Codex
AI-model: gpt-5.6-sol
AI-reasoning-effort: medium
Co-authored-by: Codex <codex@openai.com>
2026-09-04 17:36:03 +08:00
MeloriseandCodex 29809d9cd3 fix: 避免快速停止重新开启网页录音
停止请求早于网页录音状态落地时,等待实际进入录音后再点击一次停止;网页仍在录音或尚未点击停止时不再完成外部会话。

AI-assisted-by: Codex
AI-model: gpt-5.6-sol
AI-reasoning-effort: medium
Co-authored-by: Codex <codex@openai.com>
2026-09-04 17:32:12 +08:00
github-actions[bot] 34f682b53d build: 更新 flake.lock 2026-09-01 19:57:15 +00:00
MeloriseandCodex e9f7bc99e9 build: 调整 Arch 包为 deb 重打包
默认 PKGBUILD 改为复用官方 deb,并保留 PKGBUILD-from-source 源码构建入口。

AI-assisted-by: Codex
AI-model: gpt-5.6-sol
AI-reasoning-effort: medium
Co-authored-by: Codex <codex@openai.com>
2026-09-01 22:58:01 +08:00
MeloriseandCodex 22784a38e7 feat: 增加软件来源与隐私说明
在设置页底部说明官方渠道版本的数据边界、非官方来源风险和 MPL-2.0 许可证。

AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-09-01 22:40:30 +08:00
Melorise 443a462421 update readme 2026-09-01 22:37:25 +08:00
MeloriseandCodex 376473f92e fix: 移除不准确的显示器分辨率
缩放环境中的 workArea 是逻辑尺寸,不再将其作为物理分辨率展示。

AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-09-01 22:18:07 +08:00
MeloriseandCodex 368bc643a4 feat: 重新设计设置界面并发布 1.0.1
统一设置主页和悬浮球定位页的视觉层级、状态反馈与无障碍交互,并同步发行元数据。

AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-09-01 22:01:14 +08:00
MeloriseandCodex 2100ebab6b feat: rename project to TamaASR 1.0.0
Migrate internal identifiers, installation paths, packaging metadata, and repository references while preserving package upgrades from meloasr.

AI-assisted-by: Codex
AI-model: gpt-5.6-sol
AI-reasoning-effort: medium
Co-authored-by: Codex <codex@openai.com>
2026-09-01 20:55:14 +08:00
MeloriseandCodex 39ddfd8eaa chore: remove obsolete icon assets
Remove the unused root logo and obsolete tray template SVG.

AI-assisted-by: Codex
AI-model: gpt-5.6-sol
AI-reasoning-effort: medium
Co-authored-by: Codex <codex@openai.com>
2026-09-01 20:36:05 +08:00
MeloriseandCodex edd352e3d4 feat: replace application and tray icons
Update release metadata to 0.1.17 and use ready/not-ready tray assets.

AI-assisted-by: Codex
AI-model: gpt-5.6-sol
AI-reasoning-effort: medium
Co-authored-by: Codex <codex@openai.com>
2026-09-01 20:36:05 +08:00
github-actions[bot] ed1bf16e24 build: 更新 flake.lock 2026-08-30 16:49:27 +00:00
github-actions[bot] d2c53823cd build: 更新 flake.lock 2026-08-27 04:13:23 +00:00
MeloriseandCodex 26d54fb8e9 ci: 定期更新 Nix 缓存
AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
2026-08-27 12:12:12 +08:00
Melorise 2233cc6cae update nix input 2026-08-27 12:09:55 +08:00
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
62 changed files with 3648 additions and 4661 deletions
+18 -12
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: |
@@ -101,13 +106,13 @@ jobs:
- name: 构建 deb 与 rpm
env:
MELOASR_VERSION: ${{ needs.update-flake-lock.outputs.version }}
MELOASR_DEB_ARCH: ${{ matrix.deb_arch }}
MELOASR_ELECTRON_APP_DIR: ${{ matrix.electron_app_dir }}
TAMA_ASR_VERSION: ${{ needs.update-flake-lock.outputs.version }}
TAMA_ASR_DEB_ARCH: ${{ matrix.deb_arch }}
TAMA_ASR_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/
@@ -160,17 +165,17 @@ jobs:
env:
CACHIX_CACHE_NAME: ${{ vars.CACHIX_CACHE_NAME }}
run: |
nix build ".#packages.${{ matrix.system }}.meloasr"
nix build ".#packages.${{ matrix.system }}.tama-asr"
store_path="$(readlink -f result)"
cachix push "$CACHIX_CACHE_NAME" "$store_path"
cachix pin --keep-revisions 1 "$CACHIX_CACHE_NAME" \
"meloasr-${{ matrix.system }}" "$store_path"
printf '%s\n' "$store_path" > "meloasr-${{ matrix.system }}.txt"
"tama-asr-${{ matrix.system }}" "$store_path"
printf '%s\n' "$store_path" > "tama-asr-${{ matrix.system }}.txt"
- uses: actions/upload-artifact@v4
with:
name: nix-build-${{ matrix.system }}
path: meloasr-${{ matrix.system }}.txt
path: tama-asr-${{ matrix.system }}.txt
publish-release:
name: 创建 GitHub Release
@@ -208,12 +213,13 @@ jobs:
run: |
cp flake.lock release/flake.lock
cp packaging/arch/PKGBUILD release/PKGBUILD
cp packaging/arch/PKGBUILD-from-source release/PKGBUILD-from-source
- uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.update-flake-lock.outputs.version }}
target_commitish: ${{ needs.update-flake-lock.outputs.commit_sha }}
name: MeloASR ${{ needs.update-flake-lock.outputs.version }}
name: TamaASR ${{ needs.update-flake-lock.outputs.version }}
body_path: release-notes.md
files: release/*
fail_on_unmatched_files: true
+94
View File
@@ -0,0 +1,94 @@
name: Update Nix Cache
on:
schedule:
- cron: "17 17 * * 2"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: update-nix-cache-master
cancel-in-progress: false
jobs:
update-flake-lock:
name: 更新 Nix 锁文件
runs-on: ubuntu-24.04
outputs:
commit_sha: ${{ steps.commit.outputs.commit_sha }}
steps:
- uses: actions/checkout@v4
with:
ref: master
fetch-depth: 0
- uses: DeterminateSystems/nix-installer-action@v20
- name: 更新 flake.lock
run: nix flake update
- id: commit
name: 提交锁文件更新
env:
GIT_AUTHOR_NAME: github-actions[bot]
GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: github-actions[bot]
GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
run: |
if [[ -n "$(git status --porcelain -- flake.lock)" ]]; then
git add flake.lock
git commit -m "build: 更新 flake.lock"
git push origin HEAD:master
fi
echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
build-nix:
name: Nix ${{ matrix.system }}
needs: update-flake-lock
strategy:
fail-fast: false
matrix:
include:
- system: x86_64-linux
runner: ubuntu-24.04
- system: aarch64-linux
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.update-flake-lock.outputs.commit_sha }}
- uses: DeterminateSystems/nix-installer-action@v20
- uses: cachix/cachix-action@v15
with:
name: ${{ vars.CACHIX_CACHE_NAME }}
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: 校验并配置 Cachix
env:
CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }}
CACHIX_CACHE_NAME: ${{ vars.CACHIX_CACHE_NAME }}
CACHIX_PUBLIC_KEY: ${{ vars.CACHIX_PUBLIC_KEY }}
run: |
test -n "$CACHIX_AUTH_TOKEN"
test -n "$CACHIX_CACHE_NAME"
test -n "$CACHIX_PUBLIC_KEY"
mkdir -p "$HOME/.config/nix"
{
echo "extra-substituters = https://$CACHIX_CACHE_NAME.cachix.org"
echo "extra-trusted-public-keys = $CACHIX_PUBLIC_KEY"
} >> "$HOME/.config/nix/nix.conf"
- name: 构建、上传并更新最新 pin
env:
CACHIX_CACHE_NAME: ${{ vars.CACHIX_CACHE_NAME }}
run: |
nix build ".#packages.${{ matrix.system }}.tama-asr"
store_path="$(readlink -f result)"
cachix push "$CACHIX_CACHE_NAME" "$store_path"
cachix pin --keep-revisions 1 "$CACHIX_CACHE_NAME" \
"tama-asr-${{ matrix.system }}" "$store_path"
+10 -10
View File
@@ -1,8 +1,8 @@
# MeloASR 维护约定
# TamaASR 维护约定
## 项目范围
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.15`。
TamaASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `TamaASR`,分发包名为 `tama-asr`,当前版本为 `1.0.2`。
不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。
@@ -44,7 +44,7 @@ MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `M
## Fcitx5 与本地协议
- Socket:`$XDG_RUNTIME_DIR/meloasr/fcitx5.sock`;目录 `0700`,Socket `0600`;
- Socket:`$XDG_RUNTIME_DIR/tama-asr/fcitx5.sock`;目录 `0700`,Socket `0600`;
- JSONL 协议版本:2;
- `sessionId` 隔离轮次,`revision` 单调递增;
- `text` 永远是当前完整文本;
@@ -75,18 +75,18 @@ 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 改动运行 `npm test` 和 `npm run build`;
- 只有修改 Fcitx5 C++、CMake 构建逻辑或本地协议实现时,才运行 `npm run build:fcitx5` 和 `ctest`;仅同步发行版本号不要求重测未改动的 Fcitx5 逻辑;
- 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 打包路径;
- 项目依赖未安装时,按 `package-lock.json` 运行 `npm ci`,不能以 `tsc` 或其它工具缺失为由跳过正常验证;
- 项目依赖未安装时,按 `pnpm-lock.yaml` 运行 `pnpm install --frozen-lockfile`,不能以 `tsc` 或其它工具缺失为由跳过正常验证;
- 不得声称添加了未实际添加的测试,也不得把无关环境中的成功构建计入目标平台验证结果。
最低动态回归:连续三轮;文本缩短、改词和补标点;finish 仅一次;cancel/reset/disconnect;ready gate;登录探测;后端切换;多屏位置和键盘微调;托盘退出/重启;自启动;GTK、Qt、Chromium 和终端;X11 与 Wayland。
@@ -97,7 +97,7 @@ 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`、`package-lock.json`、CHANGELOG、AppStream、Fcitx5 addon 版本和各发行版打包元数据,并运行 `scripts/release-metadata.mjs <version>` 校验发布说明。
版本发布时同步 `package.json`、`pnpm-lock.yaml`、CHANGELOG、AppStream、Fcitx5 addon 版本和各发行版打包元数据,并运行 `scripts/release-metadata.mjs <version>` 校验发布说明。
`v*` Git 标签由远程 Release 工作流产生。除非用户明确要求,本地禁止创建、移动或删除发布标签,也禁止推送提交或标签。“更新版本”或“更新发版”本身不等于授权本地打标签。
+34
View File
@@ -2,6 +2,40 @@
本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 的格式。
## [1.0.2] - 2026-09-04
### 修复
- 修复快速松开快捷键时,网页尚未完成异步启动便再次点击麦克风,导致外部会话已结束但网页仍在录音的问题。
## [1.0.1] - 2026-09-01
### 变更
- 重新设计设置界面,统一 TamaASR 品牌色、控件层级、服务状态、快捷键、开关与即时保存反馈。
- 改进悬浮球位置调整页,增加桌面预览、九宫格选中状态、精确坐标与方向微调控件。
- 补充键盘焦点、辅助文本、减少动态效果偏好和窄窗口布局支持。
## [1.0.0] - 2026-09-01
### 变更
- 项目更名为 TamaASR,分发包名改为 `tama-asr`,并迁移应用标识、Fcitx5 addon、本地 Socket、持久会话、桌面入口及安装路径。
- 将仓库地址更新为 <https://github.com/Melorise/TamaASR>,并为 deb、RPM 和 Arch 包声明替换旧包的关系。
## [0.1.17] - 2026-09-01
### 变更
- 更新应用图标,并将托盘图标统一为“已就绪”和“未就绪”两种状态。
## [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
### 修复
+10 -10
View File
@@ -1,8 +1,8 @@
# MeloASR 维护约定
# TamaASR 维护约定
## 项目范围
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.15`。
TamaASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `TamaASR`,分发包名为 `tama-asr`,当前版本为 `1.0.2`。
不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。
@@ -44,7 +44,7 @@ MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `M
## Fcitx5 与本地协议
- Socket:`$XDG_RUNTIME_DIR/meloasr/fcitx5.sock`;目录 `0700`,Socket `0600`;
- Socket:`$XDG_RUNTIME_DIR/tama-asr/fcitx5.sock`;目录 `0700`,Socket `0600`;
- JSONL 协议版本:2;
- `sessionId` 隔离轮次,`revision` 单调递增;
- `text` 永远是当前完整文本;
@@ -75,18 +75,18 @@ 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 改动运行 `npm test` 和 `npm run build`;
- 只有修改 Fcitx5 C++、CMake 构建逻辑或本地协议实现时,才运行 `npm run build:fcitx5` 和 `ctest`;仅同步发行版本号不要求重测未改动的 Fcitx5 逻辑;
- 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 打包路径;
- 项目依赖未安装时,按 `package-lock.json` 运行 `npm ci`,不能以 `tsc` 或其它工具缺失为由跳过正常验证;
- 项目依赖未安装时,按 `pnpm-lock.yaml` 运行 `pnpm install --frozen-lockfile`,不能以 `tsc` 或其它工具缺失为由跳过正常验证;
- 不得声称添加了未实际添加的测试,也不得把无关环境中的成功构建计入目标平台验证结果。
最低动态回归:连续三轮;文本缩短、改词和补标点;finish 仅一次;cancel/reset/disconnect;ready gate;登录探测;后端切换;多屏位置和键盘微调;托盘退出/重启;自启动;GTK、Qt、Chromium 和终端;X11 与 Wayland。
@@ -97,7 +97,7 @@ 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`、`package-lock.json`、CHANGELOG、AppStream、Fcitx5 addon 版本和各发行版打包元数据,并运行 `scripts/release-metadata.mjs <version>` 校验发布说明。
版本发布时同步 `package.json`、`pnpm-lock.yaml`、CHANGELOG、AppStream、Fcitx5 addon 版本和各发行版打包元数据,并运行 `scripts/release-metadata.mjs <version>` 校验发布说明。
`v*` Git 标签由远程 Release 工作流产生。除非用户明确要求,本地禁止创建、移动或删除发布标签,也禁止推送提交或标签。“更新版本”或“更新发版”本身不等于授权本地打标签。
+20 -28
View File
@@ -1,29 +1,21 @@
# MeloASR
# TamaASR
大模型的进步推动了自然语言处理的进化,语音输入正在成为主流。在其它桌面平台下,微信输入法,豆包输入法,千问输入法等支持语音输入甚至以此为卖点的新兴中文输入法层出不穷。
而在linux下,相关领域却长期空白,现有实现主要聚焦于以下几种类型:
TamaASR 是面向 Linux/Fcitx5 的桌面语音输入工具。
和常规ASR软件不同,本软件不运行本地模型或要求用户提供ASR api进行调用,而是通过将语音输入转发到支持语音识别的AI网页对话助手,再将识别到的文字抓取并发送到fcitx5完成输入。
api调用——调用各大ai厂商的语音转文字api。显然,该方案最大的问题在于——要钱。
本地模型——识别准确率低,技术落后,和其他平台的能实时校正的ai语音输入根本不是一个量级的东西,尤其是针对中文环境。
您需要在本软件的内置浏览器里登录对应的AI助手平台,然后在外部按住快捷键即可进行输入。
我们注意到,很多大模型的网页对话窗口,都支持语音输入,而且完全免费——因为语音输入最后只是输入到文本框,并没有发送实际的对话请求,而大模型就算是付费也往往是根据对话请求来计费的。
从官方渠道(Github Releases,星火商店等)获取到的本软件,不会获取您的任何个人信息、AI平台账户信息、对话记录或语音输入记录
如果您是从其它渠道获取,且无法确认来源可信,请立即卸载并更换为官方来源的版本以防止个人信息泄露
本项目遵循MPL-2.0许可证开源
因此,本项目使用了一种全新的思路:在后台运行一个electron应用,并在里面登录千问,元宝等网页版对话作为后端,实时抓取语音输入到输入框中的结果,并传递给fcitx5,从而让linux用户也用上高质量的语音输入。
简而言之,MeloASR 是面向 Linux/Fcitx5 的桌面语音输入工具。应用使用受支持网页平台自身的录音、流式识别、纠错、断句和标点能力,并把网页编辑器中的当前完整文本实时同步到活动的 Fcitx5 InputContext。
当前后端:
当前支持的可作为输入源的后端:
- 千问
- 元宝
### 本项目暂不考虑迁移到Tauri。我们充分理解这种需要后台常驻的系统性应用,在Tauri下开发似乎更加合适,事实上我们也确实进行了两次Tauri迁移。但元宝和千问web版对于webkit2gtk的表现实在是过于糟糕了(至少是在我的目标设备上),即使使用默认的gnome浏览器进行语音输入都会卡死,更不用说后续自动化的抓取了。还请各位理解,该项目是一个偏个人向的项目(毕竟使用的这种方式,大规模公开那就是商业侵权),而我的目标开发设备是一台非常老旧的设备,我没法说因为为了在他人的设备上获得更出色的性能而放弃对自己设备的适配。也许未来我们会在这些web端的兼容性得到改善,或者该目标设备被淘汰以后,才会考虑向Tauri迁移。
### 项目暂不支持豆包后端。豆包的ASR输入是直接发送请求,而非像元宝和千问一样输出到输入框,这就需要拦截或自行重新实现请求,技术难度高且有风控风险。
### 同时该项目是中文输入主导的,也不考虑适配chatgpt等非中文优先的平台。
### 出于个人原因(多次尝试后效果不符合预期,个人设备不兼容等),本项目暂不考虑迁移到Tauri或QtWebEngine。
## 架构
@@ -31,7 +23,7 @@ api调用——调用各大ai厂商的语音转文字api。显然,该方案最
网页后端适配器
→ Electron 后端窗口与登录会话
→ 会话状态机
→ $XDG_RUNTIME_DIR/meloasr/fcitx5.sock
→ $XDG_RUNTIME_DIR/tama-asr/fcitx5.sock
→ Fcitx5 常驻 addon
→ 当前应用的 preedit / commit
```
@@ -52,12 +44,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 中。
@@ -78,9 +70,9 @@ 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`。
@@ -92,8 +84,8 @@ Arch/pacman 配置位于 `packaging/arch/`,Nix 入口为 `flake.nix`。详细
## 验证
```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
+5 -5
View File
@@ -2,15 +2,15 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1787498568,
"narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=",
"owner": "NixOS",
"lastModified": 1788283238,
"narHash": "sha256-iFgPO6h9A+vvC8auAY2DBJv1t01vRRS6EWbUUqmEh0c=",
"owner": "Melorise",
"repo": "nixpkgs",
"rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188",
"rev": "800023974a51c7b559d9ae8c489f3de3abdb14f0",
"type": "github"
},
"original": {
"owner": "NixOS",
"owner": "Melorise",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
+5 -5
View File
@@ -1,7 +1,7 @@
{
description = "MeloASR — web-backed voice input for Fcitx5";
description = "TamaASR — web-backed voice input for Fcitx5";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.nixpkgs.url = "github:Melorise/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
@@ -12,13 +12,13 @@
let pkgs = import nixpkgs { inherit system; };
in {
default = pkgs.callPackage ./packaging/nix/package.nix { };
meloasr = self.packages.${system}.default;
tama-asr = self.packages.${system}.default;
});
apps = forAllSystems (system: {
default = {
type = "app";
program = "${self.packages.${system}.default}/bin/meloasr";
program = "${self.packages.${system}.default}/bin/tama-asr";
};
});
@@ -28,6 +28,7 @@
default = pkgs.mkShell {
packages = with pkgs; [
nodejs
pnpm_11
cmake
pkg-config
fcitx5
@@ -37,4 +38,3 @@
});
};
}
+10 -10
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.16)
project(meloasr-fcitx5 VERSION 0.1.15 LANGUAGES CXX)
project(tama-asr-fcitx5 VERSION 1.0.2 LANGUAGES CXX)
include(GNUInstallDirs)
include(CTest)
@@ -8,16 +8,16 @@ find_package(Fcitx5Core 5.0 REQUIRED)
find_package(Fcitx5Utils 5.0 REQUIRED)
find_package(nlohmann_json 3.2 REQUIRED)
add_library(meloasr MODULE voiceinput.cpp)
target_compile_features(meloasr PRIVATE cxx_std_20)
target_compile_options(meloasr PRIVATE -Wall -Wextra -Wpedantic)
target_link_libraries(meloasr PRIVATE Fcitx5::Core Fcitx5::Utils nlohmann_json::nlohmann_json)
set_target_properties(meloasr PROPERTIES PREFIX "lib")
add_library(tama-asr MODULE voiceinput.cpp)
target_compile_features(tama-asr PRIVATE cxx_std_20)
target_compile_options(tama-asr PRIVATE -Wall -Wextra -Wpedantic)
target_link_libraries(tama-asr PRIVATE Fcitx5::Core Fcitx5::Utils nlohmann_json::nlohmann_json)
set_target_properties(tama-asr PROPERTIES PREFIX "lib")
set(MELOASR_LIBRARY_PATH "${FCITX_INSTALL_ADDONDIR}/libmeloasr")
configure_file(meloasr.conf.in meloasr.conf @ONLY)
install(TARGETS meloasr LIBRARY DESTINATION "${FCITX_INSTALL_ADDONDIR}")
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/meloasr.conf" DESTINATION "${FCITX_INSTALL_PKGDATADIR}/addon")
set(TAMA_ASR_LIBRARY_PATH "${FCITX_INSTALL_ADDONDIR}/libtama-asr")
configure_file(tama-asr.conf.in tama-asr.conf @ONLY)
install(TARGETS tama-asr LIBRARY DESTINATION "${FCITX_INSTALL_ADDONDIR}")
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/tama-asr.conf" DESTINATION "${FCITX_INSTALL_PKGDATADIR}/addon")
if(BUILD_TESTING)
add_executable(protocolsession_test protocolsession_test.cpp)
+4 -4
View File
@@ -1,4 +1,4 @@
# MeloASR Fcitx5 addon
# TamaASR Fcitx5 addon
该 addon 作为 Fcitx5 常驻 Module 工作,不需要切换到独立输入法。
@@ -18,8 +18,8 @@ fcitx5 -r
安装文件:
```text
~/.local/lib/fcitx5/libmeloasr.so
~/.local/share/fcitx5/addon/meloasr.conf
~/.local/lib/fcitx5/libtama-asr.so
~/.local/share/fcitx5/addon/tama-asr.conf
```
本地协议使用 `$XDG_RUNTIME_DIR/meloasr/fcitx5.sock`,目录权限 `0700`,Socket 权限 `0600`。协议版本为 2,支持 `configure` 动态快捷键和 `activate` 悬浮球启动请求。
本地协议使用 `$XDG_RUNTIME_DIR/tama-asr/fcitx5.sock`,目录权限 `0700`,Socket 权限 `0600`。协议版本为 2,支持 `configure` 动态快捷键和 `activate` 悬浮球启动请求。
+1 -1
View File
@@ -17,4 +17,4 @@ rm -f -- \
"${HOME}/.local/lib/fcitx5/libvoiceinput.so" \
"${HOME}/.local/share/fcitx5/addon/voiceinput.conf"
echo "已安装 MeloASR Fcitx5 插件,请重启 Fcitx5。"
echo "已安装 TamaASR Fcitx5 插件,请重启 Fcitx5。"
-14
View File
@@ -1,14 +0,0 @@
[Addon]
Name=MeloASR
Name[zh_CN]=MeloASR
Comment=Connect MeloASR web backends to the active input context
Comment[zh_CN]=将 MeloASR 网页后端连接到当前输入上下文
Type=SharedLibrary
Library=@MELOASR_LIBRARY_PATH@
Category=Module
Version=0.1.15
OnDemand=False
Configurable=False
[Addon/Dependencies]
0=core:5.0.0
+14
View File
@@ -0,0 +1,14 @@
[Addon]
Name=TamaASR
Name[zh_CN]=TamaASR
Comment=Connect TamaASR web backends to the active input context
Comment[zh_CN]=将 TamaASR 网页后端连接到当前输入上下文
Type=SharedLibrary
Library=@TAMA_ASR_LIBRARY_PATH@
Category=Module
Version=1.0.2
OnDemand=False
Configurable=False
[Addon/Dependencies]
0=core:5.0.0
+17 -17
View File
@@ -35,7 +35,7 @@ namespace {
constexpr int kProtocolVersion = 2;
constexpr std::size_t kMaxBufferBytes = 1024 * 1024;
#define MELOASR_DIAGNOSTIC_INFO if (!diagnosticLogging_) {} else FCITX_INFO()
#define TAMA_ASR_DIAGNOSTIC_INFO if (!diagnosticLogging_) {} else FCITX_INFO()
class VoiceInputAddon final : public fcitx::AddonInstance {
public:
@@ -53,7 +53,7 @@ public:
fcitx::EventType::InputContextFocusOut,
fcitx::EventWatcherPhase::PostInputMethod,
[this](fcitx::Event &event) {
MELOASR_DIAGNOSTIC_INFO << "MeloASR:InputContext focusOut,取消当前会话 active="
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:InputContext focusOut,取消当前会话 active="
<< session_.active() << " awaiting=" << awaitingStart_;
cancelForContext(static_cast<fcitx::InputContextEvent &>(event).inputContext());
});
@@ -68,7 +68,7 @@ public:
fcitx::EventType::InputContextReset,
fcitx::EventWatcherPhase::PostInputMethod,
[this](fcitx::Event &event) {
MELOASR_DIAGNOSTIC_INFO << "MeloASR:InputContext reset,取消当前会话 active="
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:InputContext reset,取消当前会话 active="
<< session_.active() << " awaiting=" << awaitingStart_;
cancelForContext(static_cast<fcitx::InputContextEvent &>(event).inputContext());
});
@@ -79,7 +79,7 @@ public:
auto *context = static_cast<fcitx::InputContextEvent &>(event).inputContext();
if (context == focusedInputContext_) focusedInputContext_ = nullptr;
if (context == inputContext_) {
MELOASR_DIAGNOSTIC_INFO << "MeloASR:InputContext destroyed,取消当前会话";
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:InputContext destroyed,取消当前会话";
sendControl("request-cancel");
resetLocalState(false);
}
@@ -118,7 +118,7 @@ private:
if (action == VoiceKeyAction::Start) {
event.filterAndAccept();
MELOASR_DIAGNOSTIC_INFO << "网页语音输入:快捷键按下 held=" << held_
TAMA_ASR_DIAGNOSTIC_INFO << "网页语音输入:快捷键按下 held=" << held_
<< " awaiting=" << awaitingStart_
<< " active=" << session_.active();
if (!ensureConnected()) {
@@ -132,7 +132,7 @@ private:
if (action == VoiceKeyAction::Stop) {
event.filterAndAccept();
MELOASR_DIAGNOSTIC_INFO << "网页语音输入:快捷键松开 awaiting=" << awaitingStart_
TAMA_ASR_DIAGNOSTIC_INFO << "网页语音输入:快捷键松开 awaiting=" << awaitingStart_
<< " active=" << session_.active();
held_ = false;
if (awaitingStart_ || session_.active()) sendControl("request-stop");
@@ -143,7 +143,7 @@ private:
if (event.key().check(FcitxKey_Escape)) {
event.filterAndAccept();
MELOASR_DIAGNOSTIC_INFO << "MeloASR:录音中收到 Escape,取消当前会话";
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:录音中收到 Escape,取消当前会话";
sendControl("request-cancel");
clearPreedit();
resetLocalState(false);
@@ -151,7 +151,7 @@ private:
}
// 用户开始键盘输入时,立即取消语音会话,并让该按键继续交给原输入法。
MELOASR_DIAGNOSTIC_INFO << "MeloASR:录音中收到其它按键,取消当前会话 key="
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:录音中收到其它按键,取消当前会话 key="
<< event.key().toString() << " rawKey=" << event.rawKey().toString()
<< " repeat=" << event.rawKey().states().test(fcitx::KeyState::Repeat);
sendControl("request-cancel");
@@ -163,7 +163,7 @@ private:
if (!context || !context->hasFocus()) return false;
if (!context->inputPanel().clientPreedit().empty() ||
!context->inputPanel().preedit().empty()) {
FCITX_WARN() << "MeloASR:当前输入法存在未提交文本,本轮未启动";
FCITX_WARN() << "TamaASR:当前输入法存在未提交文本,本轮未启动";
return false;
}
inputContext_ = context;
@@ -179,12 +179,12 @@ private:
if (probe > 0 || (probe < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))) {
return true;
}
MELOASR_DIAGNOSTIC_INFO << "网页语音输入:检测到失效的 Electron Socket,立即重连";
TAMA_ASR_DIAGNOSTIC_INFO << "网页语音输入:检测到失效的 Electron Socket,立即重连";
handleDisconnect();
}
const char *runtimeDir = std::getenv("XDG_RUNTIME_DIR");
if (!runtimeDir || !*runtimeDir) return false;
const std::string socketPath = std::string(runtimeDir) + "/meloasr/fcitx5.sock";
const std::string socketPath = std::string(runtimeDir) + "/tama-asr/fcitx5.sock";
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0) return false;
sockaddr_un address{};
@@ -267,11 +267,11 @@ private:
awaitingStart_ || session_.active()) return;
fcitx::Key candidate(message["shortcut"].get<std::string>());
if (!candidate.isValid() || !candidate.hasModifier()) {
FCITX_WARN() << "MeloASR:拒绝无效快捷键";
FCITX_WARN() << "TamaASR:拒绝无效快捷键";
return;
}
trigger_ = std::move(candidate);
MELOASR_DIAGNOSTIC_INFO << "MeloASR:快捷键已更新为 " << trigger_.toString();
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:快捷键已更新为 " << trigger_.toString();
return;
}
if (type == "activate") {
@@ -285,7 +285,7 @@ private:
if (!message.contains("sessionId") || !message["sessionId"].is_string()) return;
session_.start(message["sessionId"].get<std::string>());
awaitingStart_ = false;
MELOASR_DIAGNOSTIC_INFO << "网页语音输入:会话开始,clientPreedit="
TAMA_ASR_DIAGNOSTIC_INFO << "网页语音输入:会话开始,clientPreedit="
<< inputContext_->capabilityFlags().test(fcitx::CapabilityFlag::Preedit);
// 新会话尚无文本;重复发布空 preedit 会使部分 frontend 立刻 reset。
return;
@@ -293,7 +293,7 @@ private:
if (type == "cancel") {
const std::string incoming = message.value("sessionId", std::string());
if (!incoming.empty() && !session_.matches(incoming)) return;
MELOASR_DIAGNOSTIC_INFO << "MeloASR:收到 Electron cancel,sessionIdEmpty="
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:收到 Electron cancel,sessionIdEmpty="
<< incoming.empty() << " message="
<< message.value("message", std::string());
clearPreedit();
@@ -306,7 +306,7 @@ private:
const std::int64_t incomingRevision = message["revision"].get<std::int64_t>();
if (!session_.accept(message.value("sessionId", std::string()), incomingRevision)) return;
currentText_ = message["text"].get<std::string>();
MELOASR_DIAGNOSTIC_INFO << "网页语音输入:收到 " << type << " revision="
TAMA_ASR_DIAGNOSTIC_INFO << "网页语音输入:收到 " << type << " revision="
<< incomingRevision << " bytes=" << currentText_.size();
setPreedit(currentText_);
if (type == "finish") {
@@ -374,7 +374,7 @@ private:
}
void handleDisconnect() {
MELOASR_DIAGNOSTIC_INFO << "MeloASR:Electron Socket 断开,取消当前会话 active="
TAMA_ASR_DIAGNOSTIC_INFO << "TamaASR:Electron Socket 断开,取消当前会话 active="
<< session_.active() << " awaiting=" << awaitingStart_;
clearPreedit();
resetLocalState(false);
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

-4158
View File
File diff suppressed because it is too large Load Diff
+11 -10
View File
@@ -1,21 +1,22 @@
{
"name": "meloasr",
"productName": "MeloASR",
"version": "0.1.15",
"name": "tama-asr",
"productName": "TamaASR",
"version": "1.0.2",
"private": true,
"author": "MeloASR contributors",
"packageManager": "pnpm@11.21.0",
"author": "TamaASR 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",
+14 -12
View File
@@ -1,27 +1,29 @@
# Linux 分发打包
MeloASR 0.1.15 的系统包由两部分组成:
TamaASR 1.0.2 的系统包由两部分组成:
- Electron 应用安装到 `/opt/meloasr`,命令入口为 `/usr/bin/meloasr`;
- Electron 应用安装到 `/opt/tama-asr`,命令入口为 `/usr/bin/tama-asr`;
- Fcitx5 常驻 addon 由目标发行版中的 CMake/Fcitx5 开发包编译,并安装到该环境报告的 addon 目录。
安装包同时提供桌面入口、项目图标和 `/etc/xdg/autostart/meloasr.desktop`,因此安装后的默认行为是随桌面会话启动。设置页关闭自启动时,应用需要在用户级 autostart 目录写入覆盖项,不能删除系统包所属文件。
安装包同时提供桌面入口、项目图标和 `/etc/xdg/autostart/tama-asr.desktop`,因此安装后的默认行为是随桌面会话启动。设置页关闭自启动时,应用需要在用户级 autostart 目录写入覆盖项,不能删除系统包所属文件。
`tama-asr` 1.0.2 的 deb、RPM 和 Arch 元数据均声明替换旧 `meloasr` 包;通过对应发行版的包管理器安装新包时,旧应用、Fcitx5 addon 和桌面文件会一并卸载。
## 统一构建契约
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 文件。
该命令应在 `release/linux-unpacked/` 生成可直接运行的 Electron 目录,并且主程序名必须是 `tama-asr`。仓库提供的 `electron-builder.yml` 只生成这一中间目录;deb/rpm/Arch 的系统文件仍由各自脚本管理,避免用安装钩子复制未被包管理器追踪的 Fcitx5 文件。
主项目可将脚本定义为:
```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 +38,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.15_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。
产物位于 `packaging/out/tama-asr_1.0.2_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。
## rpm
在目标 RPM 发行版中准备相同的编译依赖和 `rpmbuild`,然后执行:
```bash
npm run build:linux:dir
pnpm run build:linux:dir
packaging/rpm/build-rpm.sh
```
@@ -55,7 +57,7 @@ packaging/rpm/build-rpm.sh
## Arch/pacman
参见 [`arch/README.md`](arch/README.md)。PKGBUILD 在干净 chroot 中从源码构建 Electron 应用和 Fcitx5 addon。
参见 [`arch/README.md`](arch/README.md)。默认 `PKGBUILD` 解包同版本官方 deb 并重打包为 pacman 包;`PKGBUILD-from-source` 保留在干净 chroot 中构建 Electron 应用和 Fcitx5 addon 的原有流程。
## Nix flake
@@ -64,12 +66,12 @@ 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`。
## 发布前必须完成
- 发布前确认仓库地址和许可证元数据;
- 明确项目许可证,并替换临时的 `LicenseRef-Proprietary`/Nix license;
- 生成 Arch 源码归档真实 SHA-256;
- 生成 Arch bin 版 deb 和源码版源码归档的真实 SHA-256;
- 在干净的 Debian、RPM、Arch 和 Nix 构建环境逐一构建、安装、卸载;
- 验证 Fcitx5 能发现 addon,桌面菜单/托盘图标、自启动开关和包升级均正常。
+25 -39
View File
@@ -1,47 +1,33 @@
pkgname=meloasr
pkgver=0.1.15
pkgname=tama-asr
pkgver=1.0.2
pkgrel=1
pkgdesc='Web-backed voice input for Fcitx5'
arch=('x86_64')
url='https://github.com/Melorise/MeloASR'
arch=('x86_64' 'aarch64')
url='https://github.com/Melorise/TamaASR'
license=('MPL2')
depends=('fcitx5' 'gtk3' 'nss' 'libxss' 'mesa' 'alsa-lib')
makedepends=('nodejs' 'npm' '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}/${_source_dir}"
npm ci
npm run build:linux:dir
cmake -S linux/fcitx5 -B build/fcitx5 \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr
cmake --build build/fcitx5 --parallel
}
check() {
cd "${srcdir}/${_source_dir}"
npm test
ctest --test-dir build/fcitx5 --output-on-failure
}
conflicts=('meloasr')
provides=('meloasr')
replaces=('meloasr')
options=('!strip')
source_x86_64=("${pkgname}-${pkgver}-x86_64.deb::${url}/releases/download/v${pkgver}/${pkgname}_${pkgver}_amd64.deb")
source_aarch64=("${pkgname}-${pkgver}-aarch64.deb::${url}/releases/download/v${pkgver}/${pkgname}_${pkgver}_arm64.deb")
sha256sums_x86_64=('SKIP')
sha256sums_aarch64=('SKIP')
noextract=("${pkgname}-${pkgver}-x86_64.deb" "${pkgname}-${pkgver}-aarch64.deb")
package() {
cd "${srcdir}/${_source_dir}"
local deb_file="${srcdir}/${pkgname}-${pkgver}-${CARCH}.deb"
local deb_root="${srcdir}/deb-root"
local data_archives
install -d "${pkgdir}/opt/meloasr"
cp -a release/linux-unpacked/. "${pkgdir}/opt/meloasr/"
install -Dm755 packaging/assets/meloasr-wrapper "${pkgdir}/usr/bin/meloasr"
install -Dm644 packaging/assets/meloasr.desktop \
"${pkgdir}/usr/share/applications/meloasr.desktop"
install -Dm644 packaging/assets/meloasr-autostart.desktop \
"${pkgdir}/etc/xdg/autostart/meloasr.desktop"
install -Dm644 packaging/assets/meloasr.metainfo.xml \
"${pkgdir}/usr/share/metainfo/meloasr.metainfo.xml"
install -Dm644 src/assets/logo.png \
"${pkgdir}/usr/share/pixmaps/meloasr.png"
DESTDIR="${pkgdir}" cmake --install build/fcitx5
rm -rf -- "${deb_root}"
mkdir -p "${deb_root}"
bsdtar -xf "${deb_file}" -C "${deb_root}"
data_archives=("${deb_root}"/data.tar.*)
if (( ${#data_archives[@]} != 1 )) || [[ ! -f "${data_archives[0]}" ]]; then
error '无法从 deb 包中找到唯一的 data.tar.* 归档'
return 1
fi
bsdtar -xf "${data_archives[0]}" -C "${pkgdir}"
}
+50
View File
@@ -0,0 +1,50 @@
pkgname=tama-asr
pkgver=1.0.2
pkgrel=1
pkgdesc='Web-backed voice input for Fcitx5'
arch=('x86_64')
url='https://github.com/Melorise/TamaASR'
license=('MPL2')
depends=('fcitx5' 'gtk3' 'nss' 'libxss' 'mesa' 'alsa-lib')
conflicts=('meloasr')
provides=('meloasr')
replaces=('meloasr')
makedepends=('nodejs' 'pnpm' 'cmake' 'gcc' 'nlohmann-json')
source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz")
sha256sums=('SKIP')
_source_dir="TamaASR-${pkgver}"
build() {
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
cmake --build build/fcitx5 --parallel
}
check() {
cd "${srcdir}/${_source_dir}"
pnpm test
ctest --test-dir build/fcitx5 --output-on-failure
}
package() {
cd "${srcdir}/${_source_dir}"
install -d "${pkgdir}/opt/tama-asr"
cp -a release/linux-unpacked/. "${pkgdir}/opt/tama-asr/"
install -Dm755 packaging/assets/tama-asr-wrapper "${pkgdir}/usr/bin/tama-asr"
install -Dm644 packaging/assets/tama-asr.desktop \
"${pkgdir}/usr/share/applications/tama-asr.desktop"
install -Dm644 packaging/assets/tama-asr-autostart.desktop \
"${pkgdir}/etc/xdg/autostart/tama-asr.desktop"
install -Dm644 packaging/assets/tama-asr.metainfo.xml \
"${pkgdir}/usr/share/metainfo/tama-asr.metainfo.xml"
install -Dm644 src/assets/logo.png \
"${pkgdir}/usr/share/pixmaps/tama-asr.png"
DESTDIR="${pkgdir}" cmake --install build/fcitx5
}
+20 -4
View File
@@ -1,11 +1,27 @@
# Arch Linux 构建
# Arch Linux 打包
远程 Release 会附带当前版本的 `PKGBUILD`。下载后直接运行:
## 默认:从官方 deb 重打包
远程 Release 附带的 `PKGBUILD` 是 bin 版。它根据当前 Arch 架构下载同一 Release 中的 `amd64.deb` 或 `arm64.deb`,解出已经构建好的 Electron 应用、桌面文件和 Fcitx5 addon,再交给 `makepkg` 生成 pacman 包,不会在本地重复编译源码。
下载后直接运行:
```bash
curl --fail --location --remote-name \
https://github.com/Melorise/MeloASR/releases/download/v0.1.15/PKGBUILD
https://github.com/Melorise/TamaASR/releases/download/v1.0.2/PKGBUILD
makepkg --syncdeps --cleanbuild
```
`PKGBUILD` 会根据 `pkgver` 直接下载 GitHub Release 标签对应的源码归档,无需手动下载、重命名或放置源码包。源码地址固定为 `https://github.com/Melorise/MeloASR/archive/refs/tags/v${pkgver}.tar.gz`,因此同一份 `PKGBUILD` 可以重复构建该版本。
`x86_64` 使用 `tama-asr_<version>_amd64.deb`,`aarch64` 使用 `tama-asr_<version>_arm64.deb`。这两个 deb 与 Arch 包来自同一版本的官方 Release。
## 可选:从源码构建
需要完整源码构建时,使用 Release 同时附带的 `PKGBUILD-from-source`:
```bash
curl --fail --location --remote-name \
https://github.com/Melorise/TamaASR/releases/download/v1.0.2/PKGBUILD-from-source
makepkg --syncdeps --cleanbuild -p PKGBUILD-from-source
```
源码版保留原有流程:下载对应 Git 标签的源码归档,安装锁文件依赖,构建 Electron 应用和 Fcitx5 addon,运行测试后再生成 pacman 包。
@@ -1,12 +0,0 @@
[Desktop Entry]
Type=Application
Name=MeloASR
Comment=Start MeloASR when signing in
Comment[zh_CN]=登录桌面后启动 MeloASR
Exec=meloasr --autostart
Icon=meloasr
Terminal=false
X-GNOME-Autostart-enabled=true
X-KDE-autostart-after=panel
NoDisplay=true
-2
View File
@@ -1,2 +0,0 @@
#!/bin/sh
exec /opt/meloasr/meloasr --ozone-platform=x11 "$@"
@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=TamaASR
Comment=Start TamaASR when signing in
Comment[zh_CN]=登录桌面后启动 TamaASR
Exec=tama-asr --autostart
Icon=tama-asr
Terminal=false
X-GNOME-Autostart-enabled=true
X-KDE-autostart-after=panel
NoDisplay=true
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
exec /opt/tama-asr/tama-asr --ozone-platform=x11 "$@"
@@ -1,10 +1,10 @@
[Desktop Entry]
Type=Application
Name=MeloASR
Name=TamaASR
Comment=Web-backed voice input for Fcitx5
Comment[zh_CN]=基于网页后端的 Fcitx5 语音输入工具
Exec=meloasr
Icon=meloasr
Exec=tama-asr
Icon=tama-asr
Terminal=false
Categories=Utility;Accessibility;
StartupNotify=false
@@ -1,21 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.github.meloasr.MeloASR</id>
<name>MeloASR</name>
<id>io.github.melorise.TamaASR</id>
<name>TamaASR</name>
<summary>Web-backed voice input for Fcitx5</summary>
<summary xml:lang="zh-CN">基于网页后端的 Fcitx5 语音输入工具</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MPL-2.0</project_license>
<description>
<p>MeloASR connects supported web speech-recognition backends to the active Fcitx5 input context.</p>
<p xml:lang="zh-CN">MeloASR 将支持的网页语音识别后端连接到当前 Fcitx5 输入上下文。</p>
<p>TamaASR connects supported web speech-recognition backends to the active Fcitx5 input context.</p>
<p xml:lang="zh-CN">TamaASR 将支持的网页语音识别后端连接到当前 Fcitx5 输入上下文。</p>
</description>
<url type="homepage">https://github.com/Melorise/MeloASR</url>
<launchable type="desktop-id">meloasr.desktop</launchable>
<url type="homepage">https://github.com/Melorise/TamaASR</url>
<launchable type="desktop-id">tama-asr.desktop</launchable>
<provides>
<binary>meloasr</binary>
<binary>tama-asr</binary>
</provides>
<releases>
<release version="1.0.2" date="2026-09-04"/>
<release version="1.0.1" date="2026-09-01"/>
<release version="1.0.0" date="2026-09-01"/>
<release version="0.1.17" date="2026-09-01"/>
<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"/>
+4 -4
View File
@@ -2,9 +2,9 @@
set -euo pipefail
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
version="${MELOASR_VERSION:-0.1.15}"
arch="${MELOASR_DEB_ARCH:-$(dpkg --print-architecture)}"
app_dir="${1:-${MELOASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
version="${TAMA_ASR_VERSION:-1.0.2}"
arch="${TAMA_ASR_DEB_ARCH:-$(dpkg --print-architecture)}"
app_dir="${1:-${TAMA_ASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
work_dir="${project_dir}/packaging/out/deb-root"
output_dir="${project_dir}/packaging/out"
@@ -21,4 +21,4 @@ sed \
mkdir -p "${output_dir}"
dpkg-deb --root-owner-group --build "${work_dir}" \
"${output_dir}/meloasr_${version}_${arch}.deb"
"${output_dir}/tama-asr_${version}_${arch}.deb"
+5 -3
View File
@@ -1,11 +1,13 @@
Package: meloasr
Package: tama-asr
Version: @VERSION@
Section: sound
Priority: optional
Architecture: @ARCH@
Maintainer: MeloASR contributors
Maintainer: TamaASR contributors
Installed-Size: @INSTALLED_SIZE@
Depends: fcitx5, libc6, libstdc++6, libgtk-3-0, libnss3, libxss1, libgbm1, libasound2 | libasound2t64
Conflicts: meloasr
Replaces: meloasr
Description: Web-backed voice input for Fcitx5
MeloASR hosts supported web speech-recognition backends and sends their
TamaASR hosts supported web speech-recognition backends and sends their
live corrected text to the active Fcitx5 input context.
+3 -3
View File
@@ -1,5 +1,5 @@
appId: io.github.meloasr.MeloASR
productName: MeloASR
appId: io.github.melorise.TamaASR
productName: TamaASR
asar: true
directories:
@@ -16,6 +16,6 @@ extraResources:
linux:
target:
- dir
executableName: meloasr
executableName: tama-asr
icon: src/assets/logo.png
category: Utility
+28 -24
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";
pname = "tama-asr";
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
'';
@@ -58,29 +67,24 @@ stdenv.mkDerivation (finalAttrs: {
installPhase = ''
runHook preInstall
appDir="$out/share/meloasr/app"
appDir="$out/share/tama-asr/app"
mkdir -p "$appDir" "$out/bin" "$out/share/applications" \
"$out/share/pixmaps" "$out/share/metainfo" \
"$out/etc/xdg/autostart"
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 \
"$out/etc/xdg/autostart/meloasr.desktop"
install -Dm644 packaging/assets/meloasr.metainfo.xml \
"$out/share/metainfo/meloasr.metainfo.xml"
install -Dm644 packaging/assets/tama-asr.desktop \
"$out/share/applications/tama-asr.desktop"
install -Dm644 packaging/assets/tama-asr-autostart.desktop \
"$out/etc/xdg/autostart/tama-asr.desktop"
install -Dm644 packaging/assets/tama-asr.metainfo.xml \
"$out/share/metainfo/tama-asr.metainfo.xml"
install -Dm644 src/assets/logo.png \
"$out/share/pixmaps/meloasr.png"
"$out/share/pixmaps/tama-asr.png"
cmake --install build/fcitx5
makeWrapper ${electron}/bin/electron "$out/bin/meloasr" \
makeWrapper ${electron}/bin/electron "$out/bin/tama-asr" \
--add-flags "$appDir"
runHook postInstall
@@ -88,9 +92,9 @@ stdenv.mkDerivation (finalAttrs: {
meta = {
description = "Web-backed voice input for Fcitx5";
homepage = "https://github.com/Melorise/MeloASR";
homepage = "https://github.com/Melorise/TamaASR";
license = lib.licenses.mpl20;
mainProgram = "meloasr";
mainProgram = "tama-asr";
platforms = lib.platforms.linux;
};
})
+7 -7
View File
@@ -2,8 +2,8 @@
set -euo pipefail
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
version="${MELOASR_VERSION:-0.1.15}"
app_dir="${1:-${MELOASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
version="${TAMA_ASR_VERSION:-1.0.2}"
app_dir="${1:-${TAMA_ASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
stage_dir="${project_dir}/packaging/out/rpm-root"
top_dir="${project_dir}/packaging/out/rpmbuild"
@@ -14,19 +14,19 @@ command -v rpmbuild >/dev/null || {
rm -rf -- "${stage_dir}" "${top_dir}"
"${project_dir}/packaging/scripts/stage-linux.sh" "${app_dir}" "${stage_dir}"
addon_file="$(find "${stage_dir}/usr" -type f -path '*/fcitx5/libmeloasr.so' \
addon_file="$(find "${stage_dir}/usr" -type f -path '*/fcitx5/libtama-asr.so' \
-printf '/usr/%P\n' -quit)"
if [[ -z "${addon_file}" ]]; then
echo "未在安装根目录中找到 Fcitx5 addon。" >&2
exit 1
fi
mkdir -p "${top_dir}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
tar -C "${stage_dir}" -czf "${top_dir}/SOURCES/meloasr-root.tar.gz" .
tar -C "${stage_dir}" -czf "${top_dir}/SOURCES/tama-asr-root.tar.gz" .
rpmbuild -bb \
--define "_topdir ${top_dir}" \
--define "meloasr_version ${version}" \
--define "meloasr_addon_file ${addon_file}" \
"${project_dir}/packaging/rpm/meloasr-binary.spec"
--define "tama_asr_version ${version}" \
--define "tama_asr_addon_file ${addon_file}" \
"${project_dir}/packaging/rpm/tama-asr-binary.spec"
find "${top_dir}/RPMS" -type f -name '*.rpm' -exec cp -f {} "${project_dir}/packaging/out/" \;
-66
View File
@@ -1,66 +0,0 @@
Name: meloasr
Version: %{meloasr_version}
Release: 1%{?dist}
Summary: Web-backed voice input for Fcitx5
License: MPL-2.0
URL: https://github.com/Melorise/MeloASR
Source0: meloasr-root.tar.gz
Requires: fcitx5
Requires: gtk3
Requires: nss
Requires: libXScrnSaver
Requires: mesa-libgbm
%description
MeloASR hosts supported web speech-recognition backends and sends their live
corrected text to the active Fcitx5 input context.
%prep
%build
%install
mkdir -p %{buildroot}
tar -C %{buildroot} -xzf %{SOURCE0}
%files
%defattr(-,root,root,-)
/opt/meloasr
/usr/bin/meloasr
%{meloasr_addon_file}
/usr/share/applications/meloasr.desktop
/usr/share/pixmaps/meloasr.png
/usr/share/metainfo/meloasr.metainfo.xml
/usr/share/fcitx5/addon/meloasr.conf
/etc/xdg/autostart/meloasr.desktop
%changelog
* 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.
* Sun Aug 23 2026 MeloASR contributors - 0.1.9-1
- Fix multi-architecture package staging and RPM addon file ownership.
- Fix Nix derivation configuration phase.
* Sun Aug 23 2026 MeloASR contributors - 0.1.8-1
- Fix Fcitx5 addon compatibility with Ubuntu 24.04.
* Sun Aug 23 2026 MeloASR contributors - 0.1.7-1
- Initial package
+83
View File
@@ -0,0 +1,83 @@
Name: tama-asr
Version: %{tama_asr_version}
Release: 1%{?dist}
Summary: Web-backed voice input for Fcitx5
License: MPL-2.0
URL: https://github.com/Melorise/TamaASR
Source0: tama-asr-root.tar.gz
Requires: fcitx5
Requires: gtk3
Requires: nss
Requires: libXScrnSaver
Requires: mesa-libgbm
Obsoletes: meloasr < 1.0.0
Provides: meloasr = %{version}-%{release}
%description
TamaASR hosts supported web speech-recognition backends and sends their live
corrected text to the active Fcitx5 input context.
%prep
%build
%install
mkdir -p %{buildroot}
tar -C %{buildroot} -xzf %{SOURCE0}
%files
%defattr(-,root,root,-)
/opt/tama-asr
/usr/bin/tama-asr
%{tama_asr_addon_file}
/usr/share/applications/tama-asr.desktop
/usr/share/pixmaps/tama-asr.png
/usr/share/metainfo/tama-asr.metainfo.xml
/usr/share/fcitx5/addon/tama-asr.conf
/etc/xdg/autostart/tama-asr.desktop
%changelog
* Fri Sep 04 2026 TamaASR contributors - 1.0.2-1
- 修复快速停止可能使网页录音状态与外部会话不同步的问题。
* Tue Sep 01 2026 TamaASR contributors - 1.0.1-1
- Redesign the settings and overlay-position interfaces.
* Tue Sep 01 2026 TamaASR contributors - 1.0.0-1
- Rename the package and replace the legacy meloasr package on upgrade.
* Tue Sep 01 2026 TamaASR contributors - 0.1.17-1
- Replace the application and tray icons; use one icon for ready and one for all not-ready states.
* Wed Aug 26 2026 TamaASR contributors - 0.1.16-1
- Migrate dependency installation and build commands from npm to pnpm.
* Wed Aug 26 2026 TamaASR contributors - 0.1.15-1
- Download the Arch package source directly from the matching GitHub release tag.
* Wed Aug 26 2026 TamaASR contributors - 0.1.14-1
- Cache the active web editor for the duration of each speech session.
* Mon Aug 24 2026 TamaASR contributors - 0.1.13-1
- Create the settings renderer only when it is opened and destroy it on close.
* Sun Aug 23 2026 TamaASR contributors - 0.1.12-1
- Disable Electron GPU acceleration for background web renderers.
* Sun Aug 23 2026 TamaASR 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 TamaASR contributors - 0.1.10-1
- Restore the Nix npm configuration hook before building the application.
* Sun Aug 23 2026 TamaASR contributors - 0.1.9-1
- Fix multi-architecture package staging and RPM addon file ownership.
- Fix Nix derivation configuration phase.
* Sun Aug 23 2026 TamaASR contributors - 0.1.8-1
- Fix Fcitx5 addon compatibility with Ubuntu 24.04.
* Sun Aug 23 2026 TamaASR contributors - 0.1.7-1
- Initial package
+15 -15
View File
@@ -2,9 +2,9 @@
set -euo pipefail
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
app_dir="${1:-${MELOASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
app_dir="${1:-${TAMA_ASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
stage_dir="${2:-${project_dir}/packaging/out/root}"
addon_build_dir="${MELOASR_ADDON_BUILD_DIR:-${project_dir}/packaging/out/fcitx5-build}"
addon_build_dir="${TAMA_ASR_ADDON_BUILD_DIR:-${project_dir}/packaging/out/fcitx5-build}"
if [[ "${app_dir}" != /* ]]; then
app_dir="${project_dir}/${app_dir}"
@@ -16,30 +16,30 @@ if [[ ! -d "${app_dir}" ]]; then
exit 1
fi
if [[ ! -x "${app_dir}/meloasr" ]]; then
echo "Electron 解包目录中没有可执行文件 meloasr:${app_dir}" >&2
if [[ ! -x "${app_dir}/tama-asr" ]]; then
echo "Electron 解包目录中没有可执行文件 tama-asr:${app_dir}" >&2
exit 1
fi
install -d \
"${stage_dir}/opt/meloasr" \
"${stage_dir}/opt/tama-asr" \
"${stage_dir}/usr/bin" \
"${stage_dir}/usr/share/applications" \
"${stage_dir}/usr/share/pixmaps" \
"${stage_dir}/usr/share/metainfo" \
"${stage_dir}/etc/xdg/autostart"
cp -a "${app_dir}/." "${stage_dir}/opt/meloasr/"
install -Dm755 "${project_dir}/packaging/assets/meloasr-wrapper" \
"${stage_dir}/usr/bin/meloasr"
install -Dm644 "${project_dir}/packaging/assets/meloasr.desktop" \
"${stage_dir}/usr/share/applications/meloasr.desktop"
install -Dm644 "${project_dir}/packaging/assets/meloasr-autostart.desktop" \
"${stage_dir}/etc/xdg/autostart/meloasr.desktop"
install -Dm644 "${project_dir}/packaging/assets/meloasr.metainfo.xml" \
"${stage_dir}/usr/share/metainfo/meloasr.metainfo.xml"
cp -a "${app_dir}/." "${stage_dir}/opt/tama-asr/"
install -Dm755 "${project_dir}/packaging/assets/tama-asr-wrapper" \
"${stage_dir}/usr/bin/tama-asr"
install -Dm644 "${project_dir}/packaging/assets/tama-asr.desktop" \
"${stage_dir}/usr/share/applications/tama-asr.desktop"
install -Dm644 "${project_dir}/packaging/assets/tama-asr-autostart.desktop" \
"${stage_dir}/etc/xdg/autostart/tama-asr.desktop"
install -Dm644 "${project_dir}/packaging/assets/tama-asr.metainfo.xml" \
"${stage_dir}/usr/share/metainfo/tama-asr.metainfo.xml"
install -Dm644 "${project_dir}/src/assets/logo.png" \
"${stage_dir}/usr/share/pixmaps/meloasr.png"
"${stage_dir}/usr/share/pixmaps/tama-asr.png"
cmake -S "${project_dir}/linux/fcitx5" -B "${addon_build_dir}" \
-DCMAKE_BUILD_TYPE=Release \
+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
+1 -3
View File
@@ -8,10 +8,8 @@ const copies = [
['src/renderer/overlay.html', 'dist/renderer/overlay.html'],
['src/renderer/overlay.css', 'dist/renderer/overlay.css'],
['src/assets/logo.png', 'dist/assets/logo.png'],
['src/assets/tray-loading.png', 'dist/assets/tray-loading.png'],
['src/assets/tray-notready.png', 'dist/assets/tray-notready.png'],
['src/assets/tray-ready.png', 'dist/assets/tray-ready.png'],
['src/assets/tray-login-required.png', 'dist/assets/tray-login-required.png'],
['src/assets/tray-error.png', 'dist/assets/tray-error.png']
];
for (const [source, target] of copies) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect x="2" y="2" width="60" height="60" rx="18" fill="#22b573"/>
<rect x="24" y="10" width="16" height="27" rx="8" fill="#fff"/>
<path d="M17 30v4c0 8.3 6.7 15 15 15s15-6.7 15-15v-4M32 49v7M24 56h16"
fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 398 B

+2 -2
View File
@@ -5,7 +5,7 @@ export const qianwenDefinition: BackendDefinition = Object.freeze({
label: '千问',
url: 'https://www.qianwen.com/',
origins: Object.freeze(['https://www.qianwen.com']),
partition: 'persist:meloasr-qianwen',
partition: 'persist:tama-asr-qianwen',
editorSelector: '[data-slate-editor="true"][contenteditable="true"], [role="textbox"][contenteditable="true"]'
});
@@ -14,7 +14,7 @@ export const yuanbaoDefinition: BackendDefinition = Object.freeze({
label: '元宝',
url: 'https://yuanbao.tencent.com/',
origins: Object.freeze(['https://yuanbao.tencent.com']),
partition: 'persist:meloasr-yuanbao',
partition: 'persist:tama-asr-yuanbao',
editorSelector: '.ql-editor[contenteditable="true"], [contenteditable="true"][role="textbox"]'
});
+2 -2
View File
@@ -5,7 +5,7 @@ import path from 'node:path';
import { resolveAutoStartAction } from './auto-start-policy';
export class AutoStartService {
private readonly filePath = path.join(os.homedir(), '.config', 'autostart', 'meloasr.desktop');
private readonly filePath = path.join(os.homedir(), '.config', 'autostart', 'tama-asr.desktop');
apply(enabled: boolean): void {
const action = resolveAutoStartAction(app.isPackaged, enabled);
@@ -15,7 +15,7 @@ export class AutoStartService {
fs.writeFileSync(this.filePath, [
'[Desktop Entry]',
'Type=Application',
'Name=MeloASR',
'Name=TamaASR',
'Hidden=true',
''
].join('\n'), { mode: 0o644 });
+2 -2
View File
@@ -54,13 +54,13 @@ export class BackendManager extends EventEmitter {
skipTaskbar: true,
width: 1280,
height: 840,
title: `${definition.label} · MeloASR`,
title: `${definition.label} · TamaASR`,
backgroundColor: '#ffffff',
icon: path.join(app.getAppPath(), 'dist', 'assets', 'logo.png'),
webPreferences: {
partition: definition.partition,
preload: path.join(__dirname, '..', 'preload', 'backend.js'),
additionalArguments: [`--meloasr-backend=${id}`],
additionalArguments: [`--tama-asr-backend=${id}`],
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
+1 -1
View File
@@ -8,7 +8,7 @@ const MAX_LINE_BYTES = 1024 * 1024;
export function defaultSocketPath(env: NodeJS.ProcessEnv = process.env): string {
if (!env.XDG_RUNTIME_DIR) throw new Error('缺少 XDG_RUNTIME_DIR,无法创建 Fcitx5 本地 Socket');
return path.join(env.XDG_RUNTIME_DIR, 'meloasr', 'fcitx5.sock');
return path.join(env.XDG_RUNTIME_DIR, 'tama-asr', 'fcitx5.sock');
}
export class JsonLineDecoder {
+5 -5
View File
@@ -9,7 +9,7 @@ import { SettingsWindow } from './settings-window';
import { TrayController } from './tray-controller';
import type { BackendStatusPayload, Point } from '../shared/contracts';
app.setName('MeloASR');
app.setName('TamaASR');
app.disableHardwareAcceleration();
app.commandLine.appendSwitch('disable-gpu');
const singleInstance = app.requestSingleInstanceLock();
@@ -18,7 +18,7 @@ if (!singleInstance) {
app.quit();
} else {
void app.whenReady().then(bootstrap).catch((error) => {
console.error('[MeloASR 启动失败]', error);
console.error('[TamaASR 启动失败]', error);
app.quit();
});
}
@@ -36,8 +36,8 @@ async function bootstrap(): Promise<void> {
const overlay = new OverlayController(settings);
await overlay.create();
const sessions = new SessionController(backends, bridge, overlay);
const repositoryUrl = process.env.MELOASR_REPOSITORY_URL?.trim() ||
'https://github.com/Melorise/MeloASR';
const repositoryUrl = process.env.TAMA_ASR_REPOSITORY_URL?.trim() ||
'https://github.com/Melorise/TamaASR';
const settingsWindow = new SettingsWindow(settings, backends, overlay, sessions, repositoryUrl);
registerIpc({ settings, autoStart, bridge, backends, overlay, sessions, settingsWindow });
const tray = new TrayController(
@@ -164,7 +164,7 @@ function registerIpc(services: Services): void {
if (typeof payload?.backend !== 'string' || !backends.senderIsActive(event, payload.backend)) return;
if (!settings.get().diagnosticLogging) return;
const { backend, stage, ...details } = payload;
console.info(`[MeloASR 网页诊断] backend=${backend} stage=${stage || 'unknown'} ${JSON.stringify(details)}`);
console.info(`[TamaASR 网页诊断] backend=${backend} stage=${stage || 'unknown'} ${JSON.stringify(details)}`);
});
}
+3 -3
View File
@@ -72,7 +72,7 @@ export class SessionController extends EventEmitter {
cancel(message = '语音输入已取消'): void {
if (!['starting', 'recording', 'stopping'].includes(this.state)) return;
console.warn(`[MeloASR 会话取消] state=${this.state} reason=${message}`);
console.warn(`[TamaASR 会话取消] state=${this.state} reason=${message}`);
this.generation += 1;
this.stopRequestedDuringStart = false;
this.backends.activePage()?.webContents.send('backend-control', {
@@ -104,7 +104,7 @@ export class SessionController extends EventEmitter {
}
backendError(message: string): void {
console.error(`[MeloASR 后端错误] state=${this.state} reason=${message}`);
console.error(`[TamaASR 后端错误] state=${this.state} reason=${message}`);
if (this.bridge.activeSession) this.bridge.cancel(); else this.bridge.rejectStart(message);
this.state = 'idle';
this.mirroredText = '';
@@ -127,7 +127,7 @@ export class SessionController extends EventEmitter {
}
private notify(message: string): void {
if (Notification.isSupported()) new Notification({ title: 'MeloASR', body: message }).show();
if (Notification.isSupported()) new Notification({ title: 'TamaASR', body: message }).show();
}
private emitState(): void {
+2 -2
View File
@@ -37,7 +37,7 @@ export class SettingsWindow {
minWidth: 560,
minHeight: 560,
show: false,
title: 'MeloASR 设置',
title: 'TamaASR 设置',
autoHideMenuBar: true,
icon: path.join(app.getAppPath(), 'dist', 'assets', 'logo.png'),
webPreferences: {
@@ -98,7 +98,7 @@ export class SettingsWindow {
overlayPosition: position,
displays: screen.getAllDisplays().map((display, index) => ({
id: String(display.id),
label: `屏幕 ${index + 1}(${display.workArea.width} × ${display.workArea.height})`,
label: `屏幕 ${index + 1}`,
workArea: display.workArea
})),
version: app.getVersion(),
+5 -5
View File
@@ -6,10 +6,10 @@ import type { SettingsWindow } from './settings-window';
type TrayState = 'loading' | 'ready' | 'login-required' | 'error';
const ICONS: Record<TrayState, string> = {
loading: 'tray-loading.png',
loading: 'tray-notready.png',
ready: 'tray-ready.png',
'login-required': 'tray-login-required.png',
error: 'tray-error.png'
'login-required': 'tray-notready.png',
error: 'tray-notready.png'
};
function trayImage(state: TrayState): Electron.NativeImage {
@@ -30,7 +30,7 @@ export class TrayController {
create(): void {
this.tray = new Tray(trayImage('loading'));
this.tray.setToolTip('MeloASR · 正在加载');
this.tray.setToolTip('TamaASR · 正在加载');
this.tray.on('click', () => void this.settingsWindow.show());
this.refresh();
}
@@ -42,7 +42,7 @@ export class TrayController {
: status.login === 'logged-out' ? 'login-required'
: status.detail.startsWith('加载失败') ? 'error' : 'loading';
this.tray.setImage(trayImage(state));
this.tray.setToolTip(`MeloASR · ${status.detail}`);
this.tray.setToolTip(`TamaASR · ${status.detail}`);
this.tray.setContextMenu(Menu.buildFromTemplate([
{ label: '设置', click: () => void this.settingsWindow.show() },
{ label: '打开登录/调试页面', click: () => void this.settingsWindow.openDebug() },
+18 -9
View File
@@ -1,12 +1,12 @@
import { ipcRenderer } from 'electron';
import type { BackendControlCommand, BackendPageStatus, BackendTranscriptPayload } from '../backends/contracts';
import { getBackendWebAdapter } from '../backends/web-registry';
import { shouldClickMicrophoneOnCancel } from './speech-session-state';
import { shouldClickMicrophoneOnCancel, shouldClickMicrophoneOnStop } from './speech-session-state';
import { shouldFinishAfterStop } from './stop-completion';
const backendId = process.argv
.find((argument) => argument.startsWith('--meloasr-backend='))
?.slice('--meloasr-backend='.length) ?? '';
.find((argument) => argument.startsWith('--tama-asr-backend='))
?.slice('--tama-asr-backend='.length) ?? '';
const adapter = getBackendWebAdapter(backendId);
let editorObserver: MutationObserver | undefined;
@@ -180,9 +180,7 @@ function stopSpeech(): void {
if (!active || stopping || !adapter) return;
stopping = true;
const stoppedAt = Date.now();
const wasRecordingAtStop = adapter.isRecording(document);
adapter.findMicrophone(document)?.click();
diagnose('stop-clicked', { microphone: controlSnapshot(adapter.findMicrophone(document)) });
let stopClicked = false;
const finish = (): void => {
emitCurrentEditorText();
resetSession();
@@ -192,11 +190,22 @@ function stopSpeech(): void {
const checkCompletion = (): void => {
if (!active || !stopping || !adapter) return;
const now = Date.now();
const isRecording = adapter.isRecording(document);
if (shouldClickMicrophoneOnStop(isRecording, stopClicked)) {
const microphone = adapter.findMicrophone(document);
if (microphone) {
microphone.click();
stopClicked = true;
diagnose('stop-clicked', { microphone: controlSnapshot(adapter.findMicrophone(document)) });
}
stopTimer = setTimeout(checkCompletion, 75);
return;
}
if (shouldFinishAfterStop({
elapsedMs: now - stoppedAt,
quietMs: now - lastTranscriptAt,
wasRecordingAtStop,
isRecording: adapter.isRecording(document),
stopClicked,
isRecording,
...adapter.stopCompletion
})) {
diagnose('stop-complete', { elapsedMs: now - stoppedAt });
@@ -206,7 +215,7 @@ function stopSpeech(): void {
stopTimer = setTimeout(checkCompletion, 75);
};
clearTimeout(stopTimer);
stopTimer = setTimeout(checkCompletion, 75);
checkCompletion();
}
function cancelSpeech(): void {
+4
View File
@@ -1,3 +1,7 @@
export function shouldClickMicrophoneOnCancel(active: boolean, stopping: boolean): boolean {
return active && !stopping;
}
export function shouldClickMicrophoneOnStop(isRecording: boolean, stopClicked: boolean): boolean {
return isRecording && !stopClicked;
}
+2 -2
View File
@@ -1,7 +1,7 @@
export interface StopCompletionInput {
readonly elapsedMs: number;
readonly quietMs: number;
readonly wasRecordingAtStop: boolean;
readonly stopClicked: boolean;
readonly isRecording: boolean;
readonly minimumWaitMs: number;
readonly quietWindowMs: number;
@@ -9,7 +9,7 @@ export interface StopCompletionInput {
}
export function shouldFinishAfterStop(input: StopCompletionInput): boolean {
if (!input.stopClicked || input.isRecording) return false;
if (input.elapsedMs >= input.timeoutMs) return true;
if (input.wasRecordingAtStop && input.isRecording) return false;
return input.elapsedMs >= input.minimumWaitMs && input.quietMs >= input.quietWindowMs;
}
+247 -49
View File
@@ -1,56 +1,254 @@
:root { color-scheme: light; font: 14px/1.5 system-ui, "Microsoft YaHei", sans-serif; color: #182033; }
:root {
color-scheme: light;
font: 14px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
color: #202421;
background: #f6f7f5;
--brand: #63a978;
--brand-strong: #4f9564;
--brand-soft: #edf6ef;
--text: #202421;
--text-secondary: #707672;
--text-tertiary: #929792;
--border: #e2e6e1;
--border-strong: #cfd5cf;
--surface: #fff;
--surface-muted: #f8f9f7;
--warning: #a66b20;
--warning-soft: #fff7e9;
--error: #b34d43;
--error-soft: #fff0ee;
--ease-out: cubic-bezier(.23, 1, .32, 1);
--ease-in-out: cubic-bezier(.77, 0, .175, 1);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; background: #f4f6fa; }
header { display: flex; align-items: center; gap: 12px; padding: 22px 28px 12px; }
header img { width: 44px; height: 44px; border-radius: 11px; }
header h1 { margin: 0; font-size: 22px; }
header span { color: #788398; }
main { display: grid; gap: 14px; padding: 0 28px 30px; }
section { padding: 18px; background: white; border: 1px solid #e0e5ee; border-radius: 13px; box-shadow: 0 5px 20px rgba(33,48,77,.05); }
h2 { margin: 0 0 14px; font-size: 16px; }
.row { display: flex; align-items: center; gap: 12px; min-height: 40px; }
.row > label, .row > span:first-child { min-width: 80px; color: #5f6b80; }
select, input, button { height: 38px; border-radius: 8px; font: inherit; }
select, input { padding: 0 10px; border: 1px solid #cad3e1; background: white; }
select { flex: 1; }
button { padding: 0 14px; border: 0; background: #e9eef7; color: #26334a; cursor: pointer; font-weight: 600; }
button.primary { background: #286df0; color: white; }
button:disabled { opacity: .55; cursor: default; }
.status { padding: 4px 9px; border-radius: 99px; font-size: 12px; background: #edf0f5; }
.status[data-state="ready"] { color: #087b4d; background: #ddf6eb; }
.status[data-state="warning"] { color: #a84332; background: #fde8e4; }
.warning { margin: 8px 0 12px; padding: 9px 11px; border-radius: 8px; color: #a84332; background: #fff0ed; }
.switch { display: flex; align-items: center; gap: 10px; min-height: 40px; }
.switch input { width: 17px; height: 17px; }
.position-panel { max-width: 760px; }
.page-title { display: flex; align-items: center; gap: 12px; }
.page-title h2 { margin: 0; }
.page-title span { color: #788398; }
.position-screen { position: relative; height: 300px; margin: 16px 0; border: 2px solid #b9c4d6; border-radius: 10px; background: #f7f9fc; overflow: hidden; }
.position-ball { position: absolute; width: 28px; height: 28px; transform: translate(-50%, -50%); border: 3px solid #3476ed; border-radius: 50%; background: #76a4f8; box-shadow: 0 3px 10px rgba(52,118,237,.3); }
.position-controls { display: grid; grid-template-columns: 1fr 164px; align-items: center; gap: 24px; }
.direction-pad { display: grid; grid-template-columns: repeat(3, 48px); grid-template-rows: repeat(3, 38px); gap: 4px; justify-content: center; }
.direction-pad button { padding: 0; }
html, body { min-width: 100%; min-height: 100%; }
body { margin: 0; overflow-x: hidden; background: #f6f7f5; }
button, select, input { font: inherit; }
button { color: inherit; }
button, select { -webkit-tap-highlight-color: transparent; }
button:focus-visible, select:focus-visible, input:focus-visible {
outline: 2px solid rgba(99, 169, 120, .42);
outline-offset: 2px;
}
svg { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.view { width: 100%; padding: 20px 24px; }
.settings-view { display: grid; gap: 12px; max-width: 720px; margin: 0 auto; }
.card {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
box-shadow: 0 1px 2px rgba(33, 46, 36, .025), 0 7px 24px rgba(33, 46, 36, .025);
}
.app-header { display: flex; align-items: center; justify-content: space-between; min-height: 48px; }
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.brand img { width: 42px; height: 42px; border-radius: 11px; object-fit: cover; box-shadow: 0 2px 8px rgba(67, 112, 78, .12); }
.brand h1, .position-header h1 { margin: 0; font-size: 20px; line-height: 1.2; letter-spacing: -.018em; }
.brand p, .position-header p, .section-heading p { margin: 3px 0 0; color: var(--text-secondary); }
.brand p { font-size: 13px; }
.app-status {
display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px;
border: 1px solid #d4e6d8; border-radius: 9px; color: #417d53; background: #f3f9f4;
white-space: nowrap;
}
.app-status i, .backend-status i { display: block; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.app-status[data-state="loading"] { border-color: var(--border); color: var(--text-secondary); background: var(--surface-muted); }
.app-status[data-state="warning"] { border-color: #eeddbf; color: var(--warning); background: var(--warning-soft); }
.service-card { padding: 17px 18px 16px; }
.section-heading h2, .preferences-card h2, .position-tools h2 { margin: 0; font-size: 16px; line-height: 1.25; letter-spacing: -.01em; }
.section-heading p { font-size: 13px; }
.service-row { display: grid; grid-template-columns: 92px minmax(150px, 1fr) auto; align-items: center; gap: 12px; margin-top: 15px; }
.service-row > label { font-weight: 600; }
.select-shell { position: relative; min-width: 0; }
.select-shell::after {
content: ""; position: absolute; right: 13px; top: 50%; width: 7px; height: 7px;
border-right: 1.5px solid #5f665f; border-bottom: 1.5px solid #5f665f;
transform: translateY(-70%) rotate(45deg); pointer-events: none;
}
select {
width: 100%; height: 38px; padding: 0 34px 0 12px; appearance: none;
border: 1px solid var(--border-strong); border-radius: 8px; color: var(--text); background: var(--surface);
cursor: pointer; transition: border-color 140ms ease, box-shadow 140ms ease;
}
select:focus { border-color: #8abb98; }
.backend-status { display: inline-flex; align-items: center; gap: 7px; min-width: 74px; color: var(--text-secondary); white-space: nowrap; }
.backend-status[data-state="ready"] { color: #478b5b; }
.backend-status[data-state="warning"] { color: var(--warning); }
.backend-status[data-state="loading"] i { background: #a5aaa6; }
.warning {
display: flex; align-items: flex-start; gap: 8px; margin-top: 12px; padding: 9px 11px;
border: 1px solid #f0dfbf; border-radius: 8px; color: #83571e; background: var(--warning-soft); font-size: 12px;
}
.warning svg { flex: 0 0 auto; width: 17px; height: 17px; }
.service-actions { display: flex; justify-content: flex-end; margin-top: 13px; }
.primary-button, .secondary-button, .icon-button, .direction-pad button {
border: 0; border-radius: 8px; cursor: pointer;
transition: transform 140ms var(--ease-out), background-color 140ms ease, border-color 140ms ease;
}
.primary-button { height: 38px; padding: 0 15px; color: #fff; background: var(--brand); font-weight: 600; box-shadow: 0 2px 7px rgba(79, 149, 100, .16); }
.primary-button:hover { background: var(--brand-strong); }
.primary-button:disabled, .secondary-button:disabled { opacity: .5; cursor: default; }
.preferences-card { padding: 16px 18px 2px; }
.preferences-card > h2 { margin-bottom: 6px; }
.preference-row { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-height: 60px; border-top: 1px solid #ecefeb; }
.preferences-card > h2 + .preference-row { border-top: 0; }
.preference-copy { display: grid; gap: 2px; min-width: 0; }
.preference-copy strong { font-size: 14px; font-weight: 600; }
.preference-copy span { color: var(--text-secondary); font-size: 12px; }
.shortcut {
flex: 0 0 auto; min-width: 148px; height: 38px; padding: 0 13px; border: 1px solid var(--border-strong);
border-radius: 8px; color: #353a36; background: linear-gradient(#fff, #fafbfa); cursor: pointer;
box-shadow: inset 0 -1px 0 rgba(30, 40, 32, .035); font-variant-numeric: tabular-nums;
}
.shortcut[data-capturing="true"] { border-color: #88b995; color: #39794c; background: var(--brand-soft); }
.toggle-row { cursor: pointer; user-select: none; }
.toggle { position: relative; flex: 0 0 auto; width: 45px; height: 26px; }
.toggle input { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
.toggle > span {
display: block; width: 45px; height: 26px; border-radius: 999px; background: #cdd1cd;
transition: background-color 160ms ease;
}
.toggle > span::after {
content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%;
background: #fff; box-shadow: 0 1px 4px rgba(25, 36, 28, .18);
transform: translateX(0); transition: transform 180ms var(--ease-out);
}
.toggle input:checked + span { background: var(--brand); }
.toggle input:checked + span::after { transform: translateX(19px); }
.toggle input:focus-visible + span { outline: 2px solid rgba(99, 169, 120, .42); outline-offset: 2px; }
.position-link {
display: flex; align-items: center; width: 100%; min-height: 72px; padding: 11px 16px;
color: var(--text); text-align: left; cursor: pointer;
transition: transform 140ms var(--ease-out), border-color 140ms ease, background-color 140ms ease;
}
.position-icon { display: grid; place-items: center; width: 44px; height: 44px; margin-right: 13px; border: 1px solid #b8dbbf; border-radius: 50%; background: #f6fbf7; }
.position-icon img { width: 31px; height: 31px; border-radius: 50%; object-fit: cover; }
.position-link-copy { display: grid; gap: 3px; flex: 1; }
.position-link-copy strong { font-size: 14px; }
.position-link-copy span { color: var(--text-secondary); font-size: 12px; }
.chevron { width: 21px; height: 21px; color: #6e756f; }
.trust-notice {
padding: 13px 15px; border: 1px solid #dce7de; border-radius: 10px;
color: #646b66; background: #f8faf8; font-size: 11px; line-height: 1.6;
}
.trust-notice p { margin: 0; }
.trust-notice p + p { margin-top: 6px; }
.trust-notice .license-note { color: #4b805a; font-weight: 600; }
.app-footer { display: flex; align-items: center; justify-content: space-between; min-height: 26px; padding: 0 7px; color: var(--text-secondary); font-size: 12px; }
.repository-link { display: inline-flex; align-items: center; gap: 3px; padding: 4px 0; border: 0; color: #4a8c5e; background: transparent; cursor: pointer; }
.repository-link svg { width: 15px; height: 15px; }
.repository-link:disabled { color: var(--text-tertiary); cursor: default; }
.position-view { max-width: 780px; margin: 0 auto; }
.position-header { display: flex; align-items: center; gap: 13px; min-height: 49px; margin-bottom: 12px; }
.position-header p { font-size: 13px; }
.icon-button { display: grid; place-items: center; width: 40px; height: 40px; border: 1px solid var(--border); background: var(--surface); }
.icon-button svg { width: 20px; height: 20px; }
.position-panel { padding: 15px 16px 14px; }
.display-row { display: grid; grid-template-columns: 90px minmax(230px, 1fr); align-items: center; gap: 16px; }
.display-row label { font-size: 15px; font-weight: 600; }
.display-row .select-shell { justify-self: end; width: min(100%, 315px); }
.position-screen {
position: relative; height: 250px; margin-top: 14px; overflow: hidden;
border: 1px solid #cfd7d0; border-radius: 9px;
background: linear-gradient(160deg, #f2f7f3 0%, #e9f1eb 45%, #dce9df 100%);
}
.position-screen::before, .position-screen::after {
content: ""; position: absolute; left: -8%; width: 116%; height: 62%; border-radius: 50%; pointer-events: none;
}
.position-screen::before { bottom: -35%; background: rgba(150, 183, 159, .23); transform: rotate(2deg); }
.position-screen::after { bottom: -47%; background: rgba(126, 165, 137, .18); transform: rotate(-4deg); }
.desktop-bar { position: relative; z-index: 2; display: flex; align-items: center; justify-content: space-between; height: 22px; padding: 0 9px; border-bottom: 1px solid #d3dad4; color: #69706a; background: rgba(251, 252, 251, .88); font-size: 9px; }
.desktop-status { letter-spacing: 1px; }
.desktop-window {
position: absolute; z-index: 1; width: 38%; height: 53%; border: 1px solid rgba(148, 158, 150, .58);
border-radius: 5px; background: rgba(255, 255, 255, .86); box-shadow: 0 8px 22px rgba(61, 76, 64, .055);
}
.desktop-window::before { content: ""; display: block; height: 13px; border-bottom: 1px solid #e0e4e0; }
.desktop-window i { position: absolute; top: 4px; width: 4px; height: 4px; border-radius: 50%; background: #c4cac5; }
.desktop-window i:nth-child(1) { right: 17px; }.desktop-window i:nth-child(2) { right: 10px; }.desktop-window i:nth-child(3) { right: 3px; }
.desktop-window span { display: block; width: 52%; height: 7px; margin: 11px 12px -4px; border-radius: 3px; background: #ecefec; }
.desktop-window span:nth-of-type(2) { width: 38%; }.desktop-window span:nth-of-type(3) { width: 68%; }
.window-back { top: 28%; left: 17%; }.window-front { top: 46%; left: 38%; width: 35%; height: 47%; }
.position-ball {
position: absolute; z-index: 4; width: 50px; height: 50px; padding: 5px; border: 2px solid var(--brand);
border-radius: 50%; background: rgba(247, 251, 248, .78); box-shadow: 0 6px 18px rgba(66, 113, 78, .2);
transform: translate(-50%, -50%);
}
.position-ball img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.position-tools { display: grid; grid-template-columns: minmax(145px, .82fr) minmax(265px, 1.55fr); gap: 18px; margin-top: 14px; }
.position-tools h2 { margin-bottom: 9px; font-size: 13px; }
.preset-section { padding-right: 18px; border-right: 1px solid var(--border); }
.preset-grid { display: grid; grid-template-columns: repeat(3, 1fr); place-items: center; gap: 12px 18px; min-height: 118px; padding: 14px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface-muted); }
.preset-grid button {
position: relative; width: 18px; height: 18px; padding: 0; border: 1.5px solid #aeb5af; border-radius: 50%; background: #fff; cursor: pointer;
}
.preset-grid button[aria-pressed="true"] { border-color: var(--brand); background: var(--brand); box-shadow: 0 0 0 6px #e7f3ea; }
.precise-controls { display: grid; grid-template-columns: minmax(125px, 1fr) 132px; align-items: center; gap: 16px; }
.coordinates { display: grid; gap: 10px; }
.coordinates label { display: grid; grid-template-columns: 35px 1fr; height: 38px; border: 1px solid var(--border-strong); border-radius: 8px; background: #fff; overflow: hidden; }
.coordinates label > span { display: grid; place-items: center; border-right: 1px solid var(--border); color: var(--text-secondary); }
.coordinates input { width: 100%; min-width: 0; height: 100%; padding: 0 10px; border: 0; color: var(--text); background: transparent; font-variant-numeric: tabular-nums; }
.coordinates input:focus-visible { outline-offset: -2px; }
.direction-pad { display: grid; grid-template-columns: repeat(3, 38px); grid-template-rows: repeat(3, 34px); gap: 4px; justify-content: center; }
.direction-pad button { display: grid; place-items: center; padding: 0; border: 1px solid var(--border-strong); background: #fbfcfb; }
.direction-pad button svg { width: 18px; height: 18px; }
.direction-pad button[data-direction="up"] { grid-column: 2; }
.direction-pad button[data-direction="left"] { grid-column: 1; grid-row: 2; }
.direction-pad button[data-direction="center"] { grid-column: 2; grid-row: 2; }
.direction-pad button[data-direction="right"] { grid-column: 3; grid-row: 2; }
.direction-pad button[data-direction="down"] { grid-column: 2; grid-row: 3; }
.preset-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 18px 0 12px; padding: 12px; border: 1px solid #dbe2ee; border-radius: 10px; background: #f7f9fc; }
.preset-grid button { align-self: center; justify-self: center; width: 24px; height: 24px; padding: 0; border: 3px solid #3476ed; border-radius: 50%; background: #76a4f8; }
.screen button { align-self: center; justify-self: center; width: 24px; height: 24px; padding: 0; border: 3px solid #3476ed; border-radius: 50%; background: #76a4f8; }
.coordinates { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.coordinates label { display: grid; grid-template-columns: 18px 1fr; align-items: center; color: #5f6b80; }
.coordinates input { width: 100%; }
.buttons { display: flex; gap: 10px; margin-top: 12px; }
.hint { margin-top: 7px; color: #7b8698; font-size: 12px; }
.shortcut { margin-left: auto; min-width: 180px; }
.link { margin-left: auto; color: #286df0; background: transparent; }
#message { min-height: 21px; color: #55708f; }
dialog { width: min(420px, calc(100vw - 48px)); padding: 0; border: 0; border-radius: 14px; color: inherit; box-shadow: 0 18px 55px rgba(20,32,55,.25); }
dialog::backdrop { background: rgba(18,27,43,.42); backdrop-filter: blur(2px); }
.modal-content { padding: 22px; }
.modal-content h2 { margin-bottom: 8px; }
.modal-content p { margin: 0; color: #5f6b80; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
.hint { margin: 8px 0 0; color: var(--text-secondary); font-size: 11px; white-space: nowrap; }
.message {
position: fixed; z-index: 20; right: 18px; bottom: 16px; display: flex; align-items: center; gap: 8px;
max-width: calc(100vw - 36px); min-height: 38px; padding: 0 13px; border: 1px solid #cfe3d4; border-radius: 9px;
color: #39784c; background: rgba(245, 251, 246, .97); box-shadow: 0 9px 30px rgba(42, 61, 47, .12);
opacity: 0; pointer-events: none; transform: translateY(8px);
transition: opacity 160ms ease, transform 180ms var(--ease-out);
}
.message::before { content: "✓"; display: grid; place-items: center; width: 18px; height: 18px; border: 1px solid currentColor; border-radius: 50%; font-size: 11px; }
.message[data-visible="true"] { opacity: 1; transform: translateY(0); }
.message[data-kind="error"] { border-color: #efcdc8; color: var(--error); background: rgba(255, 246, 245, .98); }
.message[data-kind="error"]::before { content: "!"; font-weight: 700; }
dialog { width: min(410px, calc(100vw - 40px)); padding: 0; border: 1px solid var(--border); border-radius: 14px; color: inherit; background: var(--surface); box-shadow: 0 22px 70px rgba(26, 38, 29, .22); }
dialog::backdrop { background: rgba(28, 34, 29, .38); backdrop-filter: blur(2px); }
.modal-content { padding: 23px; }
.modal-icon { display: grid; place-items: center; width: 42px; height: 42px; margin-bottom: 15px; border-radius: 11px; background: var(--brand-soft); }
.modal-icon img { width: 32px; height: 32px; border-radius: 8px; }
.modal-content h2 { margin: 0 0 7px; font-size: 18px; }
.modal-content p { margin: 0; color: var(--text-secondary); }
.modal-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 22px; }
.secondary-button { height: 38px; padding: 0 15px; border: 1px solid var(--border-strong); background: var(--surface); font-weight: 600; }
@media (hover: hover) and (pointer: fine) {
.position-link:hover { border-color: #cfd9d1; background: #fcfdfc; }
.repository-link:hover { color: var(--brand-strong); }
.icon-button:hover, .secondary-button:hover, .direction-pad button:hover { background: #f2f5f2; }
.preset-grid button:hover { border-color: var(--brand); }
button:not(:disabled):active { transform: scale(.97); }
.direction-pad button:active, .preset-grid button:active, .shortcut:active { transform: none; }
}
@media (max-width: 590px) {
.view { padding-right: 18px; padding-left: 18px; }
.service-row { grid-template-columns: 82px 1fr; }
.backend-status { grid-column: 2; }
.position-tools { grid-template-columns: 1fr; }
.preset-section { padding-right: 0; padding-bottom: 14px; border-right: 0; border-bottom: 1px solid var(--border); }
.preset-grid { grid-template-columns: repeat(3, 45px); justify-content: center; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; }
button, select, .toggle > span, .toggle > span::after, .position-ball, .message { transition-duration: 0ms !important; }
}
+115 -43
View File
@@ -3,66 +3,138 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>MeloASR 设置</title>
<title>TamaASR 设置</title>
<link rel="stylesheet" href="settings.css">
</head>
<body>
<header><img src="../assets/logo.png" alt=""><div><h1>MeloASR</h1><span>设置</span></div></header>
<main>
<section>
<h2>语音后端</h2>
<div class="row"><label for="backend">后端</label><select id="backend"></select><span id="backend-status" class="status"></span></div>
<div id="login-warning" class="warning" hidden>当前后端尚未登录。</div>
<button id="open-debug">打开登录/调试页面</button>
<main id="settings-view" class="view settings-view">
<header class="app-header">
<div class="brand">
<img src="../assets/logo.png" alt="TamaASR">
<div><h1>TamaASR</h1><p>Linux 语音输入</p></div>
</div>
<span id="app-status" class="app-status"><i></i><span>正在连接</span></span>
</header>
<section class="card service-card" aria-labelledby="service-title">
<div class="section-heading">
<h2 id="service-title">语音服务</h2>
<p>选择用于语音识别的网页服务</p>
</div>
<div class="service-row">
<label for="backend">当前服务</label>
<div class="select-shell service-select"><select id="backend"></select></div>
<span id="backend-status" class="backend-status"><i></i><span></span></span>
</div>
<div id="login-warning" class="warning" hidden>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 2.5a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15Zm0 4v4m0 3h.01"/></svg>
<span>当前服务尚未登录,完成登录后才能开始语音输入。</span>
</div>
<div class="service-actions"><button id="open-debug" class="primary-button" type="button">打开登录与调试</button></div>
</section>
<section>
<h2>悬浮球</h2>
<button id="open-position">调整悬浮球位置</button>
<section class="card preferences-card" aria-labelledby="preferences-title">
<h2 id="preferences-title">输入与启动</h2>
<div class="preference-row shortcut-row">
<div class="preference-copy"><strong>按住说话</strong><span>按住快捷键开始识别,松开后提交文本</span></div>
<button id="shortcut" class="shortcut" type="button"></button>
</div>
<label class="preference-row toggle-row" for="auto-start">
<span class="preference-copy"><strong>开机自动启动</strong><span>登录桌面后自动运行 TamaASR</span></span>
<span class="toggle"><input id="auto-start" type="checkbox"><span aria-hidden="true"></span></span>
</label>
<label class="preference-row toggle-row" for="diagnostic-logging">
<span class="preference-copy"><strong>诊断日志</strong><span>仅在排查问题时记录运行信息</span></span>
<span class="toggle"><input id="diagnostic-logging" type="checkbox"><span aria-hidden="true"></span></span>
</label>
</section>
<section>
<h2>常规</h2>
<div class="row"><span>按住说话快捷键</span><button id="shortcut" class="shortcut"></button></div>
<label class="switch"><input id="auto-start" type="checkbox"><span>开机自动启动</span></label>
<label class="switch"><input id="diagnostic-logging" type="checkbox"><span>记录诊断日志</span></label>
</section>
<button id="open-position" class="card position-link" type="button">
<span class="position-icon"><img src="../assets/logo.png" alt=""></span>
<span class="position-link-copy"><strong>悬浮球位置</strong><span id="position-summary">正在读取位置…</span></span>
<svg class="chevron" viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4.5 5 5.5-5 5.5"/></svg>
</button>
<section>
<h2>关于</h2>
<div class="row"><span>版本</span><span id="version"></span></div>
<div class="row"><span>项目仓库</span><button id="repository" class="link"></button></div>
</section>
<div id="message" role="status"></div>
<aside class="trust-notice" aria-label="来源与隐私说明">
<p>从官方渠道(GitHub Releases、星火商店等)获取到的本软件,不会获取您的任何个人信息、AI 平台账户信息、对话记录或语音输入记录。</p>
<p>如果您是从其他渠道获取,且无法确认来源可信,请立即卸载并更换为官方来源的版本,以防止个人信息泄露。</p>
<p class="license-note">本项目遵循 MPL-2.0 许可证开源。</p>
</aside>
<footer class="app-footer">
<span>版本 <span id="version"></span></span>
<button id="repository" class="repository-link" type="button"><span></span><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7 5h8v8m0-8L5 15"/></svg></button>
</footer>
</main>
<main id="position-view" style="display: none">
<div class="page-title"><button id="back-settings" title="返回设置">返回</button><div><h2>悬浮球位置</h2><span>位置调整</span></div></div>
<section class="position-panel">
<div class="row"><label for="display">屏幕</label><select id="display"></select></div>
<div id="position-screen" class="position-screen" aria-label="屏幕位置预览"><div id="position-ball" class="position-ball"></div></div>
<div class="position-controls">
<div class="coordinates"><label for="position-x">X <input id="position-x" type="number" step="1"></label><label for="position-y">Y <input id="position-y" type="number" step="1"></label></div>
<div class="direction-pad" aria-label="方向键">
<button data-direction="up" title="上移">上</button>
<button data-direction="left" title="左移">左</button><button data-direction="center" title="回到屏幕中央">●</button><button data-direction="right" title="右移">右</button>
<button data-direction="down" title="下移">下</button>
<main id="position-view" class="view position-view" hidden>
<header class="position-header">
<button id="back-settings" class="icon-button" type="button" title="返回设置" aria-label="返回设置">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m12.5 4.5-5 5.5 5 5.5"/></svg>
</button>
<div><h1>悬浮球位置</h1><p>选择它在桌面上的默认位置</p></div>
</header>
<section class="card position-panel">
<div class="display-row">
<label for="display">显示器</label>
<div class="select-shell"><select id="display"></select></div>
</div>
<div id="position-screen" class="position-screen" aria-label="当前显示器的位置预览">
<div class="desktop-bar"><span>活动</span><span>TamaASR 桌面</span><span class="desktop-status">●&nbsp; ●&nbsp; ●</span></div>
<div class="desktop-window window-back"><i></i><i></i><i></i><span></span><span></span><span></span></div>
<div class="desktop-window window-front"><i></i><i></i><i></i><span></span><span></span><span></span></div>
<div id="position-ball" class="position-ball"><img src="../assets/logo.png" alt="悬浮球"></div>
</div>
<div class="position-tools">
<div class="preset-section">
<h2>快速位置</h2>
<div class="preset-grid" aria-label="九宫格位置预设">
<button data-preset="top-left" type="button" title="左上" aria-label="左上"></button>
<button data-preset="top-center" type="button" title="顶部居中" aria-label="顶部居中"></button>
<button data-preset="top-right" type="button" title="右上" aria-label="右上"></button>
<button data-preset="middle-left" type="button" title="左侧居中" aria-label="左侧居中"></button>
<button data-preset="middle-center" type="button" title="屏幕中央" aria-label="屏幕中央"></button>
<button data-preset="middle-right" type="button" title="右侧居中" aria-label="右侧居中"></button>
<button data-preset="bottom-left" type="button" title="左下" aria-label="左下"></button>
<button data-preset="bottom-center" type="button" title="底部居中" aria-label="底部居中"></button>
<button data-preset="bottom-right" type="button" title="右下" aria-label="右下"></button>
</div>
</div>
<div class="precise-section">
<h2>精确位置</h2>
<div class="precise-controls">
<div class="coordinates">
<label for="position-x"><span>X</span><input id="position-x" type="number" step="1"></label>
<label for="position-y"><span>Y</span><input id="position-y" type="number" step="1"></label>
</div>
<div class="direction-pad" aria-label="方向微调">
<button data-direction="up" type="button" title="上移" aria-label="上移"><svg viewBox="0 0 20 20"><path d="m5 12.5 5-5 5 5"/></svg></button>
<button data-direction="left" type="button" title="左移" aria-label="左移"><svg viewBox="0 0 20 20"><path d="m12.5 5-5 5 5 5"/></svg></button>
<button data-direction="center" type="button" title="回到屏幕中央" aria-label="回到屏幕中央"><svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="5.5"/><circle cx="10" cy="10" r="1.5"/><path d="M10 2v2.5M10 15.5V18M2 10h2.5M15.5 10H18"/></svg></button>
<button data-direction="right" type="button" title="右移" aria-label="右移"><svg viewBox="0 0 20 20"><path d="m7.5 5 5 5-5 5"/></svg></button>
<button data-direction="down" type="button" title="下移" aria-label="下移"><svg viewBox="0 0 20 20"><path d="m5 7.5 5 5 5-5"/></svg></button>
</div>
</div>
<p class="hint">方向键移动 1 像素,按住 Shift 移动 10 像素</p>
</div>
</div>
<div class="preset-grid" aria-label="九宫格位置预设">
<button data-preset="top-left" title="左上"></button><button data-preset="top-center" title="顶部居中"></button><button data-preset="top-right" title="右上"></button>
<button data-preset="middle-left" title="左侧居中"></button><button data-preset="middle-center" title="屏幕中央"></button><button data-preset="middle-right" title="右侧居中"></button>
<button data-preset="bottom-left" title="左下"></button><button data-preset="bottom-center" title="底部居中"></button><button data-preset="bottom-right" title="右下"></button>
</div>
<div class="hint">方向键 1 像素,Shift + 方向键 10 像素</div>
</section>
</main>
<div id="message" class="message" role="status" aria-live="polite"></div>
<dialog id="login-notice">
<div class="modal-content">
<h2 id="login-notice-title">登录后端</h2>
<div class="modal-icon" aria-hidden="true"><img src="../assets/logo.png" alt=""></div>
<h2 id="login-notice-title">登录语音服务</h2>
<p>请先登录对应的网页平台。登录完成后即可关闭调试窗口。</p>
<div class="modal-actions">
<button id="login-notice-cancel" type="button">取消</button>
<button id="login-notice-continue" type="button" class="primary">打开登录页面</button>
<button id="login-notice-cancel" type="button" class="secondary-button">取消</button>
<button id="login-notice-continue" type="button" class="primary-button">打开登录页面</button>
</div>
</div>
</dialog>
+148 -37
View File
@@ -17,8 +17,23 @@ interface SettingsViewState {
repositoryUrl: string | null;
}
const OVERLAY_SIZE = 88;
const PRESET_MARGIN = 32;
const PRESET_LABELS: Record<string, string> = {
'top-left': '左上角',
'top-center': '顶部居中',
'top-right': '右上角',
'middle-left': '左侧居中',
'middle-center': '屏幕中央',
'middle-right': '右侧居中',
'bottom-left': '左下角',
'bottom-center': '底部居中',
'bottom-right': '右下角'
};
const backend = document.querySelector<HTMLSelectElement>('#backend')!;
const backendStatus = document.querySelector<HTMLElement>('#backend-status')!;
const appStatus = document.querySelector<HTMLElement>('#app-status')!;
const loginWarning = document.querySelector<HTMLElement>('#login-warning')!;
const display = document.querySelector<HTMLSelectElement>('#display')!;
const x = document.querySelector<HTMLInputElement>('#position-x')!;
@@ -28,21 +43,44 @@ const autoStart = document.querySelector<HTMLInputElement>('#auto-start')!;
const diagnosticLogging = document.querySelector<HTMLInputElement>('#diagnostic-logging')!;
const version = document.querySelector<HTMLElement>('#version')!;
const repository = document.querySelector<HTMLButtonElement>('#repository')!;
const repositoryLabel = repository.querySelector<HTMLElement>('span')!;
const message = document.querySelector<HTMLElement>('#message')!;
const settingsView = document.querySelector<HTMLElement>('body > main')!;
const settingsView = document.querySelector<HTMLElement>('#settings-view')!;
const positionView = document.querySelector<HTMLElement>('#position-view')!;
const positionScreen = document.querySelector<HTMLElement>('#position-screen')!;
const positionBall = document.querySelector<HTMLElement>('#position-ball')!;
const positionSummary = document.querySelector<HTMLElement>('#position-summary')!;
const loginNotice = document.querySelector<HTMLDialogElement>('#login-notice')!;
const loginNoticeTitle = document.querySelector<HTMLElement>('#login-notice-title')!;
const presetButtons = [...document.querySelectorAll<HTMLButtonElement>('[data-preset]')];
let state: SettingsViewState;
let capturingShortcut = false;
let positionMode = false;
let messageTimer: ReturnType<typeof setTimeout> | null = null;
function statusText(status: BackendRuntimeStatus): string {
if (status.ready) return '已就绪';
if (status.ready || status.login === 'logged-in') return '已登录';
if (status.login === 'logged-out') return '需要登录';
return status.detail || '正在加载';
return '加载中';
}
function renderServiceStatus(status: BackendRuntimeStatus): void {
const backendStatusText = backendStatus.querySelector<HTMLElement>('span')!;
backendStatusText.textContent = statusText(status);
backendStatus.dataset.state = status.ready ? 'ready'
: status.login === 'logged-out' ? 'warning' : 'loading';
const appStatusText = appStatus.querySelector<HTMLElement>('span')!;
if (status.ready) {
appStatusText.textContent = '服务已就绪';
appStatus.dataset.state = 'ready';
} else if (status.login === 'logged-out') {
appStatusText.textContent = '需要登录';
appStatus.dataset.state = 'warning';
} else {
appStatusText.textContent = '正在连接';
appStatus.dataset.state = 'loading';
}
}
function render(next: SettingsViewState): void {
@@ -50,42 +88,95 @@ function render(next: SettingsViewState): void {
backend.replaceChildren(...next.backends.map((item) => new Option(item.label, item.id)));
backend.value = next.backendId;
const active = next.backends.find((item) => item.id === next.backendId)!;
backendStatus.textContent = statusText(active.status);
backendStatus.dataset.state = active.status.ready ? 'ready'
: active.status.login === 'logged-out' ? 'warning' : 'loading';
renderServiceStatus(active.status);
loginWarning.hidden = active.status.login !== 'logged-out';
const previousDisplay = display.value;
display.replaceChildren(...next.displays.map((item) => new Option(item.label, item.id)));
const containing = next.displays.find((item) => {
const area = item.workArea;
return next.overlayPosition.x >= area.x && next.overlayPosition.x < area.x + area.width &&
next.overlayPosition.y >= area.y && next.overlayPosition.y < area.y + area.height;
});
const containing = next.displays.find((item) => containsPosition(item.workArea, next.overlayPosition));
display.value = next.displays.some((item) => item.id === previousDisplay)
? previousDisplay : (containing?.id ?? next.displays[0]?.id ?? '');
x.value = String(next.overlayPosition.x);
y.value = String(next.overlayPosition.y);
renderPositionPreview(next);
renderPositionSummary(next, containing);
shortcut.textContent = capturingShortcut ? '请按下新快捷键…' : displayShortcut(next.shortcut);
shortcut.dataset.capturing = String(capturingShortcut);
shortcut.setAttribute('aria-pressed', String(capturingShortcut));
autoStart.checked = next.autoStart;
diagnosticLogging.checked = next.diagnosticLogging;
version.textContent = next.version;
repository.textContent = next.repositoryUrl ?? '尚未配置';
repositoryLabel.textContent = next.repositoryUrl ? '项目仓库' : '仓库未配置';
repository.disabled = !next.repositoryUrl;
}
function containsPosition(area: SettingsViewState['displays'][number]['workArea'], point: { x: number; y: number }): boolean {
return point.x >= area.x && point.x < area.x + area.width && point.y >= area.y && point.y < area.y + area.height;
}
function selectedDisplay(next: SettingsViewState): SettingsViewState['displays'][number] | undefined {
return next.displays.find((item) => item.id === display.value) ?? next.displays[0];
}
function presetPoint(area: SettingsViewState['displays'][number]['workArea'], preset: string): { x: number; y: number } {
const [vertical, horizontal] = preset.split('-');
const pointX = horizontal === 'left' ? area.x + PRESET_MARGIN
: horizontal === 'right' ? area.x + area.width - OVERLAY_SIZE - PRESET_MARGIN
: area.x + (area.width - OVERLAY_SIZE) / 2;
const pointY = vertical === 'top' ? area.y + PRESET_MARGIN
: vertical === 'bottom' ? area.y + area.height - OVERLAY_SIZE - PRESET_MARGIN
: area.y + (area.height - OVERLAY_SIZE) / 2;
return { x: Math.round(pointX), y: Math.round(pointY) };
}
function matchingPreset(next: SettingsViewState, displayInfo = selectedDisplay(next)): string | null {
if (!displayInfo) return null;
for (const button of presetButtons) {
const preset = button.dataset.preset!;
const point = presetPoint(displayInfo.workArea, preset);
if (point.x === next.overlayPosition.x && point.y === next.overlayPosition.y) return preset;
}
return null;
}
function renderPositionPreview(next: SettingsViewState): void {
const area = next.displays.find((item) => item.id === display.value)?.workArea ?? next.displays[0]?.workArea;
if (!area) return;
const px = Math.max(0, Math.min(area.width, next.overlayPosition.x - area.x));
const py = Math.max(0, Math.min(area.height, next.overlayPosition.y - area.y));
positionBall.style.left = `${(px / area.width) * 100}%`;
positionBall.style.top = `${(py / area.height) * 100}%`;
const displayInfo = selectedDisplay(next);
if (!displayInfo) return;
const area = displayInfo.workArea;
const centerX = next.overlayPosition.x - area.x + OVERLAY_SIZE / 2;
const centerY = next.overlayPosition.y - area.y + OVERLAY_SIZE / 2;
const px = Math.max(OVERLAY_SIZE / 2, Math.min(area.width - OVERLAY_SIZE / 2, centerX));
const py = Math.max(OVERLAY_SIZE / 2, Math.min(area.height - OVERLAY_SIZE / 2, centerY));
positionBall.style.left = `clamp(25px, ${(px / area.width) * 100}%, calc(100% - 25px))`;
positionBall.style.top = `clamp(25px, ${(py / area.height) * 100}%, calc(100% - 25px))`;
const preset = matchingPreset(next, displayInfo);
presetButtons.forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.preset === preset)));
}
function renderPositionSummary(
next: SettingsViewState,
containing = next.displays.find((item) => containsPosition(item.workArea, next.overlayPosition))
): void {
const displayInfo = containing ?? selectedDisplay(next);
if (!displayInfo) {
positionSummary.textContent = '未检测到显示器';
return;
}
const preset = matchingPreset(next, displayInfo);
const screenName = displayInfo.label.split(' · ')[0].split('(')[0];
positionSummary.textContent = preset
? `${screenName} · ${PRESET_LABELS[preset]}`
: `${screenName} · X ${next.overlayPosition.x},Y ${next.overlayPosition.y}`;
}
function displayShortcut(value: string): string {
return value.replaceAll('Control', 'Ctrl').replaceAll('Super', 'Meta').replace(/\+space$/i, '+Space');
return value
.replaceAll('Control', 'Ctrl')
.replaceAll('Super', 'Meta')
.replace(/\+space$/i, '+Space')
.replaceAll('+', ' + ');
}
function formatShortcut(event: KeyboardEvent): string | null {
@@ -103,32 +194,47 @@ function formatShortcut(event: KeyboardEvent): string | null {
return [...modifiers, key].join('+');
}
async function apply(action: () => Promise<SettingsViewState>, confirmation = '已保存'): Promise<void> {
function showMessage(text: string, kind: 'success' | 'error' = 'success'): void {
if (messageTimer) clearTimeout(messageTimer);
message.textContent = text;
message.dataset.kind = kind;
message.dataset.visible = 'true';
messageTimer = setTimeout(() => {
message.dataset.visible = 'false';
messageTimer = null;
}, kind === 'error' ? 4200 : 2200);
}
async function apply(action: () => Promise<SettingsViewState>, confirmation = '设置已保存'): Promise<void> {
try {
render(await action());
message.textContent = confirmation;
showMessage(confirmation);
} catch (error) {
message.textContent = (error as Error).message;
showMessage((error as Error).message, 'error');
}
}
async function applyPosition(): Promise<void> {
await apply(() => window.meloSettings.setPosition({ x: Number(x.value), y: Number(y.value) }, display.value));
await apply(() => window.meloSettings.setPosition({ x: Number(x.value), y: Number(y.value) }, display.value), '位置已保存');
}
backend.addEventListener('change', () => void apply(() => window.meloSettings.setBackend(backend.value)));
backend.addEventListener('change', () => void apply(() => window.meloSettings.setBackend(backend.value), '语音服务已切换'));
display.addEventListener('change', () => {
renderPositionPreview(state);
renderPositionSummary(state);
});
document.querySelector('#open-debug')!.addEventListener('click', () => void window.meloSettings.openDebug());
document.querySelector('#open-position')!.addEventListener('click', () => {
positionMode = true;
settingsView.style.display = 'none';
positionView.style.display = 'grid';
settingsView.hidden = true;
positionView.hidden = false;
void window.meloSettings.beginPositioning();
renderPositionPreview(state);
});
document.querySelector('#back-settings')!.addEventListener('click', () => {
positionMode = false;
positionView.style.display = 'none';
settingsView.style.display = 'grid';
positionView.hidden = true;
settingsView.hidden = false;
void window.meloSettings.endPositioning();
});
document.querySelector('#login-notice-cancel')!.addEventListener('click', () => loginNotice.close());
@@ -136,7 +242,7 @@ document.querySelector('#login-notice-continue')!.addEventListener('click', () =
loginNotice.close();
void window.meloSettings.openDebug(true);
});
document.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => {
presetButtons.forEach((button) => {
button.addEventListener('click', () => void apply(
() => window.meloSettings.setPreset(button.dataset.preset!, display.value), '位置已保存'
));
@@ -147,12 +253,11 @@ async function nudge(direction: string): Promise<void> {
await apply(() => window.meloSettings.setPreset('middle-center', display.value), '位置已保存');
return;
}
const step = 1;
const next = { x: Number(x.value), y: Number(y.value) };
if (direction === 'left') next.x -= step;
if (direction === 'right') next.x += step;
if (direction === 'up') next.y -= step;
if (direction === 'down') next.y += step;
if (direction === 'left') next.x -= 1;
if (direction === 'right') next.x += 1;
if (direction === 'up') next.y -= 1;
if (direction === 'down') next.y += 1;
x.value = String(next.x);
y.value = String(next.y);
await applyPosition();
@@ -191,11 +296,17 @@ document.addEventListener('keydown', (event) => {
shortcut.addEventListener('click', () => {
capturingShortcut = true;
shortcut.textContent = '请按下新快捷键…';
shortcut.dataset.capturing = 'true';
shortcut.setAttribute('aria-pressed', 'true');
shortcut.focus();
});
autoStart.addEventListener('change', () => void apply(() => window.meloSettings.setAutoStart(autoStart.checked)));
autoStart.addEventListener('change', () => void apply(
() => window.meloSettings.setAutoStart(autoStart.checked),
autoStart.checked ? '已开启开机自动启动' : '已关闭开机自动启动'
));
diagnosticLogging.addEventListener('change', () => void apply(
() => window.meloSettings.setDiagnosticLogging(diagnosticLogging.checked)
() => window.meloSettings.setDiagnosticLogging(diagnosticLogging.checked),
diagnosticLogging.checked ? '已开启诊断日志' : '已关闭诊断日志'
));
repository.addEventListener('click', () => void window.meloSettings.openRepository());
window.meloSettings.onState(render);
@@ -206,4 +317,4 @@ window.meloSettings.onLoginNotice((backendLabel) => {
window.addEventListener('beforeunload', () => {
if (positionMode) void window.meloSettings.endPositioning();
});
void window.meloSettings.getState().then(render).catch((error: Error) => { message.textContent = error.message; });
void window.meloSettings.getState().then(render).catch((error: Error) => showMessage(error.message, 'error'));
+1 -1
View File
@@ -3,7 +3,7 @@ import test from 'node:test';
import { JsonLineDecoder, defaultSocketPath } from '../src/main/fcitx-bridge';
test('Socket 路径只使用当前用户运行时目录', () => {
assert.equal(defaultSocketPath({ XDG_RUNTIME_DIR: '/run/user/1000' }), '/run/user/1000/meloasr/fcitx5.sock');
assert.equal(defaultSocketPath({ XDG_RUNTIME_DIR: '/run/user/1000' }), '/run/user/1000/tama-asr/fcitx5.sock');
assert.throws(() => defaultSocketPath({}), /XDG_RUNTIME_DIR/);
});
+10 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { shouldClickMicrophoneOnCancel } from '../src/preload/speech-session-state';
import { shouldClickMicrophoneOnCancel, shouldClickMicrophoneOnStop } from '../src/preload/speech-session-state';
test('停止命令已经点击过麦克风后,取消不得再次点击并重新开启录音', () => {
assert.equal(shouldClickMicrophoneOnCancel(true, true), false);
@@ -13,3 +13,12 @@ test('录音期间直接取消时仍应点击麦克风停止网页录音', () =>
test('本地会话未激活时取消不得点击麦克风', () => {
assert.equal(shouldClickMicrophoneOnCancel(false, false), false);
});
test('停止到达但网页尚未开始录音时不得再次点击开始按钮', () => {
assert.equal(shouldClickMicrophoneOnStop(false, false), false);
});
test('网页异步进入录音后只点击一次停止按钮', () => {
assert.equal(shouldClickMicrophoneOnStop(true, false), true);
assert.equal(shouldClickMicrophoneOnStop(true, true), false);
});
+11 -3
View File
@@ -5,7 +5,7 @@ import { shouldFinishAfterStop } from '../src/preload/stop-completion';
const base = {
elapsedMs: 700,
quietMs: 500,
wasRecordingAtStop: true,
stopClicked: true,
isRecording: false,
minimumWaitMs: 250,
quietWindowMs: 450,
@@ -16,6 +16,10 @@ test('网页仍在录音时不得提前提交', () => {
assert.equal(shouldFinishAfterStop({ ...base, isRecording: true }), false);
});
test('尚未点击停止时不得提交', () => {
assert.equal(shouldFinishAfterStop({ ...base, stopClicked: false }), false);
});
test('网页结束且最终文本静默窗口满足时立即提交', () => {
assert.equal(shouldFinishAfterStop(base), true);
});
@@ -24,6 +28,10 @@ test('网页结束后仍有文本修正时继续等待', () => {
assert.equal(shouldFinishAfterStop({ ...base, quietMs: 120 }), false);
});
test('网页状态未回切时由超时兜底结束', () => {
assert.equal(shouldFinishAfterStop({ ...base, elapsedMs: 4_500, isRecording: true }), true);
test('网页仍在录音时超时也不得提交', () => {
assert.equal(shouldFinishAfterStop({ ...base, elapsedMs: 4_500, isRecording: true }), false);
});
test('网页已停止时超时可以结束静默等待', () => {
assert.equal(shouldFinishAfterStop({ ...base, elapsedMs: 4_500, quietMs: 120 }), true);
});