mirror of
https://github.com/Melorise/TamaASR.git
synced 2026-11-07 14:39:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbe34e91ef | ||
|
|
8f8244af05 | ||
|
|
6ea57c6375 | ||
|
|
adfa4df236 | ||
|
|
cd3fc9a7d7 | ||
|
|
6c3013b0ae | ||
|
|
7687350ca4 | ||
|
|
e3759efa48 | ||
|
|
4e58ca6d1d | ||
|
|
599cb2a120 |
@@ -2,7 +2,7 @@
|
||||
|
||||
## 项目范围
|
||||
|
||||
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.10`。
|
||||
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.13`。
|
||||
|
||||
不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。
|
||||
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 的格式。
|
||||
|
||||
## [0.1.13] - 2026-08-24
|
||||
|
||||
### 变更
|
||||
|
||||
- 设置页改为按需创建,关闭窗口后立即销毁其 renderer 以降低常驻内存。
|
||||
- 缩小设置页默认与最小窗口尺寸,使其匹配当前功能密度。
|
||||
|
||||
## [0.1.12] - 2026-08-23
|
||||
|
||||
### 变更
|
||||
|
||||
- 全平台启动时强制禁用 Electron GPU 硬件加速,降低后台网页 renderer 的无效显存与内存占用。
|
||||
|
||||
## [0.1.11] - 2026-08-23
|
||||
|
||||
### 变更
|
||||
|
||||
- 切换后端时销毁非当前后端的网页窗口,保留登录会话但不再保活其 renderer。
|
||||
- 移除悬浮球点击录音和常驻显示功能,悬浮球仅用于显示语音会话状态。
|
||||
- 缩短正常完成后的绿色状态显示时间。
|
||||
|
||||
## [0.1.10] - 2026-08-23
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 项目范围
|
||||
|
||||
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.10`。
|
||||
MeloASR 是仅面向 Linux 的 Fcitx5 桌面语音输入工具。项目名为 `MeloASR`,分发包名为 `meloasr`,当前版本为 `0.1.13`。
|
||||
|
||||
不得恢复 Windows、PowerShell 或 Tauri 输出路径。发行版差异只能存在于依赖说明和打包配置,不能进入语音输入核心逻辑。
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# MeloASR
|
||||
|
||||
大模型的进步推动了自然语言处理的进化,语音输入正在成为主流。在其它桌面平台下,微信输入法,豆包输入法,千问输入法等支持语音输入甚至以此为卖点的新兴中文输入法正在成为主流。
|
||||
大模型的进步推动了自然语言处理的进化,语音输入正在成为主流。在其它桌面平台下,微信输入法,豆包输入法,千问输入法等支持语音输入甚至以此为卖点的新兴中文输入法层出不穷。
|
||||
而在linux下,相关领域却长期空白,现有实现主要聚焦于以下几种类型:
|
||||
|
||||
|
||||
api调用——调用各大ai厂商的语音转文字api。显然,该方案最大的问题在于——要钱。
|
||||
api调用——调用各大ai厂商的语音转文字api。显然,该方案最大的问题在于——要钱。
|
||||
本地模型——识别准确率低,技术落后,和其他平台的能实时校正的ai语音输入根本不是一个量级的东西,尤其是针对中文环境。
|
||||
|
||||
我们注意到,很多大模型的网页对话窗口,都支持语音输入,而且完全免费——因为语音输入最后只是输入到文本框,并没有发送实际的对话请求,而大模型就算是付费也往往是根据对话请求来计费的。
|
||||
@@ -20,8 +20,6 @@ api调用——调用各大ai厂商的语音转文字api。显然,该方案最
|
||||
|
||||
### 本项目暂不考虑迁移到Tauri。我们充分理解这种需要后台常驻的系统性应用,在Tauri下开发似乎更加合适,事实上我们也确实进行了两次Tauri迁移。但元宝和千问web版对于webkit2gtk的表现实在是过于糟糕了(至少是在我的目标设备上),即使使用默认的gnome浏览器进行语音输入都会卡死,更不用说后续自动化的抓取了。还请各位理解,该项目是一个偏个人向的项目(毕竟使用的这种方式,大规模公开那就是商业侵权),而我的目标开发设备是一台非常老旧的设备,我没法说因为为了在他人的设备上获得更出色的性能而放弃对自己设备的适配。也许未来我们会在这些web端的兼容性得到改善,或者该目标设备被淘汰以后,才会考虑向Tauri迁移。
|
||||
|
||||
### 当前electron版的常驻占用内存在200mb左右。
|
||||
|
||||
### 项目暂不支持豆包后端。豆包的ASR输入是直接发送请求,而非像元宝和千问一样输出到输入框,这就需要拦截或自行重新实现请求,技术难度高且有风控风险。
|
||||
|
||||
### 同时该项目是中文输入主导的,也不考虑适配chatgpt等非中文优先的平台。
|
||||
@@ -87,6 +85,10 @@ npm run package:rpm
|
||||
|
||||
Arch/pacman 配置位于 `packaging/arch/`,Nix 入口为 `flake.nix`。详细依赖、验证状态和发布前替换项见 `packaging/README.md`。
|
||||
|
||||
## Nix Cachix缓存源
|
||||
缓存源:https://meloasr.cachix.org
|
||||
公钥:meloasr.cachix.org-1:oMmpfj7D1pQTtJx6cHYupvPDJPFlkBB0JIpJYvkR5hc=
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
project(meloasr-fcitx5 VERSION 0.1.10 LANGUAGES CXX)
|
||||
project(meloasr-fcitx5 VERSION 0.1.13 LANGUAGES CXX)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
include(CTest)
|
||||
|
||||
@@ -6,7 +6,7 @@ Comment[zh_CN]=将 MeloASR 网页后端连接到当前输入上下文
|
||||
Type=SharedLibrary
|
||||
Library=@MELOASR_LIBRARY_PATH@
|
||||
Category=Module
|
||||
Version=0.1.10
|
||||
Version=0.1.13
|
||||
OnDemand=False
|
||||
Configurable=False
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meloasr",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meloasr",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"electron": "42.3.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meloasr",
|
||||
"productName": "MeloASR",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"private": true,
|
||||
"author": "MeloASR contributors",
|
||||
"description": "基于网页语音识别后端和 Fcitx5 的 Linux 实时语音输入工具",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Linux 分发打包
|
||||
|
||||
MeloASR 0.1.10 的系统包由两部分组成:
|
||||
MeloASR 0.1.13 的系统包由两部分组成:
|
||||
|
||||
- Electron 应用安装到 `/opt/meloasr`,命令入口为 `/usr/bin/meloasr`;
|
||||
- Fcitx5 常驻 addon 由目标发行版中的 CMake/Fcitx5 开发包编译,并安装到该环境报告的 addon 目录。
|
||||
@@ -40,7 +40,7 @@ npm run build:linux:dir
|
||||
packaging/deb/build-deb.sh
|
||||
```
|
||||
|
||||
产物位于 `packaging/out/meloasr_0.1.10_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。
|
||||
产物位于 `packaging/out/meloasr_0.1.13_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。
|
||||
|
||||
## rpm
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=meloasr
|
||||
pkgver=0.1.10
|
||||
pkgver=0.1.13
|
||||
pkgrel=1
|
||||
pkgdesc='Web-backed voice input for Fcitx5'
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Arch Linux 构建
|
||||
|
||||
发布时把项目源码归档为 `meloasr-0.1.10.tar.gz`,与 `PKGBUILD` 放在同一目录,随后运行:
|
||||
发布时把项目源码归档为 `meloasr-0.1.13.tar.gz`,与 `PKGBUILD` 放在同一目录,随后运行:
|
||||
|
||||
```bash
|
||||
makepkg --syncdeps --cleanbuild
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
<binary>meloasr</binary>
|
||||
</provides>
|
||||
<releases>
|
||||
<release version="0.1.13" date="2026-08-24"/>
|
||||
<release version="0.1.12" date="2026-08-23"/>
|
||||
<release version="0.1.11" date="2026-08-23"/>
|
||||
<release version="0.1.10" date="2026-08-23"/>
|
||||
<release version="0.1.9" date="2026-08-23"/>
|
||||
<release version="0.1.8" date="2026-08-23"/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
version="${MELOASR_VERSION:-0.1.10}"
|
||||
version="${MELOASR_VERSION:-0.1.13}"
|
||||
arch="${MELOASR_DEB_ARCH:-$(dpkg --print-architecture)}"
|
||||
app_dir="${1:-${MELOASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
|
||||
work_dir="${project_dir}/packaging/out/deb-root"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
version="${MELOASR_VERSION:-0.1.10}"
|
||||
version="${MELOASR_VERSION:-0.1.13}"
|
||||
app_dir="${1:-${MELOASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
|
||||
stage_dir="${project_dir}/packaging/out/rpm-root"
|
||||
top_dir="${project_dir}/packaging/out/rpmbuild"
|
||||
|
||||
@@ -36,6 +36,16 @@ tar -C %{buildroot} -xzf %{SOURCE0}
|
||||
/etc/xdg/autostart/meloasr.desktop
|
||||
|
||||
%changelog
|
||||
* 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.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ interface BackendRecord {
|
||||
page: BrowserWindow;
|
||||
windows: Set<BrowserWindow>;
|
||||
exposed: boolean;
|
||||
disposed: boolean;
|
||||
status: BackendRuntimeStatus;
|
||||
}
|
||||
|
||||
@@ -72,6 +73,7 @@ export class BackendManager extends EventEmitter {
|
||||
page: root,
|
||||
windows: new Set([root]),
|
||||
exposed: false,
|
||||
disposed: false,
|
||||
status: loadingStatus(definition)
|
||||
};
|
||||
this.records.set(id, record);
|
||||
@@ -89,7 +91,12 @@ export class BackendManager extends EventEmitter {
|
||||
|
||||
async select(id: string): Promise<void> {
|
||||
requireBackend(id);
|
||||
this.hideRecord(this.records.get(this.settings.get().backendId));
|
||||
const previousId = this.settings.get().backendId;
|
||||
if (previousId === id) {
|
||||
await this.ensure(id);
|
||||
return;
|
||||
}
|
||||
this.disposeRecord(previousId);
|
||||
this.settings.update({ backendId: id });
|
||||
await this.ensure(id);
|
||||
this.emit('active-changed', id);
|
||||
@@ -200,7 +207,7 @@ export class BackendManager extends EventEmitter {
|
||||
|
||||
private registerWindow(window: BrowserWindow, record: BackendRecord): void {
|
||||
window.on('close', (event) => {
|
||||
if ((app as typeof app & { isQuitting?: boolean }).isQuitting) return;
|
||||
if (record.disposed || (app as typeof app & { isQuitting?: boolean }).isQuitting) return;
|
||||
event.preventDefault();
|
||||
this.hideRecord(record);
|
||||
});
|
||||
@@ -209,6 +216,10 @@ export class BackendManager extends EventEmitter {
|
||||
overrideBrowserWindowOptions: { show: record.exposed, skipTaskbar: !record.exposed, focusable: true }
|
||||
}));
|
||||
window.webContents.on('did-create-window', (child) => {
|
||||
if (record.disposed) {
|
||||
child.destroy();
|
||||
return;
|
||||
}
|
||||
record.windows.add(child);
|
||||
child.on('closed', () => record.windows.delete(child));
|
||||
this.registerWindow(child, record);
|
||||
@@ -241,6 +252,17 @@ export class BackendManager extends EventEmitter {
|
||||
for (const window of record.windows) this.parkWindow(window);
|
||||
}
|
||||
|
||||
private disposeRecord(id: string): void {
|
||||
const record = this.records.get(id);
|
||||
if (!record) return;
|
||||
record.disposed = true;
|
||||
this.records.delete(id);
|
||||
for (const window of [...record.windows]) {
|
||||
if (!window.isDestroyed()) window.destroy();
|
||||
}
|
||||
record.windows.clear();
|
||||
}
|
||||
|
||||
private parkWindow(window: BrowserWindow): void {
|
||||
if (window.isDestroyed()) return;
|
||||
window.hide();
|
||||
|
||||
+3
-19
@@ -10,6 +10,8 @@ import { TrayController } from './tray-controller';
|
||||
import type { BackendStatusPayload, Point } from '../shared/contracts';
|
||||
|
||||
app.setName('MeloASR');
|
||||
app.disableHardwareAcceleration();
|
||||
app.commandLine.appendSwitch('disable-gpu');
|
||||
const singleInstance = app.requestSingleInstanceLock();
|
||||
|
||||
if (!singleInstance) {
|
||||
@@ -37,9 +39,7 @@ async function bootstrap(): Promise<void> {
|
||||
const repositoryUrl = process.env.MELOASR_REPOSITORY_URL?.trim() ||
|
||||
'https://github.com/Melorise/MeloASR';
|
||||
const settingsWindow = new SettingsWindow(settings, backends, overlay, sessions, repositoryUrl);
|
||||
const settingsWindowReady = settingsWindow.create();
|
||||
registerIpc({ settings, autoStart, bridge, backends, overlay, sessions, settingsWindow });
|
||||
await settingsWindowReady;
|
||||
const tray = new TrayController(
|
||||
backends,
|
||||
settingsWindow,
|
||||
@@ -66,7 +66,7 @@ async function bootstrap(): Promise<void> {
|
||||
await backends.ensure(settings.get().backendId);
|
||||
tray.refresh();
|
||||
|
||||
app.on('second-instance', () => settingsWindow.show());
|
||||
app.on('second-instance', () => void settingsWindow.show());
|
||||
app.on('before-quit', () => { (app as typeof app & { isQuitting?: boolean }).isQuitting = true; });
|
||||
app.on('will-quit', () => {
|
||||
tray.destroy();
|
||||
@@ -88,9 +88,6 @@ function registerIpc(services: Services): void {
|
||||
const { settings, autoStart, bridge, backends, overlay, sessions, settingsWindow } = services;
|
||||
const settingsSender = (senderId: number): boolean =>
|
||||
Boolean(settingsWindow.window && !settingsWindow.window.isDestroyed() && settingsWindow.window.webContents.id === senderId);
|
||||
const overlaySender = (senderId: number): boolean =>
|
||||
Boolean(overlay.window && !overlay.window.isDestroyed() && overlay.window.webContents.id === senderId);
|
||||
|
||||
ipcMain.handle('settings:get-state', (event) => {
|
||||
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的设置请求');
|
||||
return settingsWindow.state();
|
||||
@@ -115,10 +112,6 @@ function registerIpc(services: Services): void {
|
||||
overlay.applyPreset(preset, displayId);
|
||||
return settingsWindow.state();
|
||||
});
|
||||
ipcMain.handle('settings:preview-overlay', (event) => {
|
||||
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的预览请求');
|
||||
overlay.preview();
|
||||
});
|
||||
ipcMain.handle('settings:begin-positioning', (event) => {
|
||||
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的位置调整请求');
|
||||
overlay.beginPositioning();
|
||||
@@ -141,12 +134,6 @@ function registerIpc(services: Services): void {
|
||||
settings.update({ autoStart: enabled });
|
||||
return settingsWindow.state();
|
||||
});
|
||||
ipcMain.handle('settings:set-overlay-persistent', (event, enabled: unknown) => {
|
||||
if (!settingsSender(event.sender.id) || typeof enabled !== 'boolean') throw new Error('无效的悬浮球设置');
|
||||
settings.update({ overlayPersistent: enabled });
|
||||
overlay.reconcilePersistent();
|
||||
return settingsWindow.state();
|
||||
});
|
||||
ipcMain.handle('settings:set-diagnostic-logging', (event, enabled: unknown) => {
|
||||
if (!settingsSender(event.sender.id) || typeof enabled !== 'boolean') throw new Error('无效的日志设置');
|
||||
settings.update({ diagnosticLogging: enabled });
|
||||
@@ -157,9 +144,6 @@ function registerIpc(services: Services): void {
|
||||
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的外部链接请求');
|
||||
await settingsWindow.openRepository();
|
||||
});
|
||||
ipcMain.on('overlay:toggle-recording', (event) => {
|
||||
if (overlaySender(event.sender.id)) sessions.requestStartFromOverlay();
|
||||
});
|
||||
ipcMain.on('backend-status', (event, payload: BackendStatusPayload) => backends.handleStatus(event, payload));
|
||||
ipcMain.on('backend-transcript', (event, payload: { backend?: string; text?: string }) => {
|
||||
if (typeof payload?.backend === 'string' && typeof payload.text === 'string' && backends.senderIsActive(event, payload.backend)) {
|
||||
|
||||
@@ -35,9 +35,9 @@ export class OverlayController {
|
||||
}
|
||||
});
|
||||
this.window.setAlwaysOnTop(true, 'floating');
|
||||
this.window.setIgnoreMouseEvents(true);
|
||||
await this.window.loadFile(path.join(__dirname, '..', 'renderer', 'overlay.html'));
|
||||
this.position();
|
||||
this.reconcilePersistent();
|
||||
}
|
||||
|
||||
position(point = this.settings.get().overlayPosition): Point {
|
||||
@@ -68,7 +68,6 @@ export class OverlayController {
|
||||
if (!this.window || this.window.isDestroyed()) return;
|
||||
this.positioning = true;
|
||||
this.clearHideTimer();
|
||||
this.window.setIgnoreMouseEvents(true);
|
||||
this.window.webContents.send('overlay-state', { state: 'idle' });
|
||||
this.window.showInactive();
|
||||
}
|
||||
@@ -77,8 +76,7 @@ export class OverlayController {
|
||||
if (!this.positioning) return;
|
||||
this.positioning = false;
|
||||
if (!this.window || this.window.isDestroyed()) return;
|
||||
this.window.setIgnoreMouseEvents(false);
|
||||
this.reconcilePersistent();
|
||||
this.window.hide();
|
||||
}
|
||||
|
||||
showState(state: SessionState | 'done' | 'error'): void {
|
||||
@@ -90,26 +88,9 @@ export class OverlayController {
|
||||
|
||||
hideLater(delay = 900): void {
|
||||
this.clearHideTimer();
|
||||
if (this.settings.get().overlayPersistent) return;
|
||||
this.hideTimer = setTimeout(() => this.window?.hide(), delay);
|
||||
}
|
||||
|
||||
preview(): void {
|
||||
this.position();
|
||||
this.showState('recording');
|
||||
this.hideLater(1800);
|
||||
}
|
||||
|
||||
reconcilePersistent(): void {
|
||||
if (!this.window || this.window.isDestroyed()) return;
|
||||
if (this.positioning) return;
|
||||
if (this.settings.get().overlayPersistent) {
|
||||
this.showState('idle');
|
||||
} else {
|
||||
this.window.hide();
|
||||
}
|
||||
}
|
||||
|
||||
private clearHideTimer(): void {
|
||||
if (this.hideTimer) clearTimeout(this.hideTimer);
|
||||
this.hideTimer = null;
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { BackendManager } from './backend-manager';
|
||||
import type { FcitxBridge } from './fcitx-bridge';
|
||||
import type { OverlayController } from './overlay-controller';
|
||||
|
||||
const COMPLETION_INDICATOR_DURATION_MS = 450;
|
||||
|
||||
export class SessionController extends EventEmitter {
|
||||
state: SessionState = 'idle';
|
||||
private mirroredText = '';
|
||||
@@ -54,14 +56,6 @@ export class SessionController extends EventEmitter {
|
||||
if (this.stopRequestedDuringStart) this.stop();
|
||||
}
|
||||
|
||||
requestStartFromOverlay(): void {
|
||||
if (this.state === 'recording' || this.state === 'starting') {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
if (!this.bridge.ready || !this.bridge.requestInputStart()) this.reject('Fcitx5 插件尚未连接');
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.state === 'starting') {
|
||||
this.stopRequestedDuringStart = true;
|
||||
@@ -105,7 +99,7 @@ export class SessionController extends EventEmitter {
|
||||
this.state = 'idle';
|
||||
this.mirroredText = '';
|
||||
this.overlay.showState('done');
|
||||
this.overlay.hideLater();
|
||||
this.overlay.hideLater(COMPLETION_INDICATOR_DURATION_MS);
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
backendId: 'qianwen',
|
||||
shortcut: 'Control+Shift+space',
|
||||
autoStart: true,
|
||||
overlayPersistent: false,
|
||||
diagnosticLogging: false,
|
||||
overlayPosition: null,
|
||||
loginNoticeShown: {}
|
||||
@@ -22,10 +21,12 @@ export class SettingsStore extends EventEmitter {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) as Partial<AppSettings> & {
|
||||
backend?: string;
|
||||
overlayPersistent?: unknown;
|
||||
};
|
||||
const { overlayPersistent: _obsoleteOverlayPersistent, ...saved } = parsed;
|
||||
this.value = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...parsed,
|
||||
...saved,
|
||||
backendId: parsed.backendId ?? parsed.backend ?? DEFAULT_SETTINGS.backendId,
|
||||
overlayPosition: this.isPoint(parsed.overlayPosition) ? {
|
||||
x: Math.round(parsed.overlayPosition.x), y: Math.round(parsed.overlayPosition.y)
|
||||
|
||||
+29
-17
@@ -9,6 +9,7 @@ import type { SettingsStore } from './settings-store';
|
||||
|
||||
export class SettingsWindow {
|
||||
window: BrowserWindow | null = null;
|
||||
private creating: Promise<BrowserWindow> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly settings: SettingsStore,
|
||||
@@ -18,12 +19,23 @@ export class SettingsWindow {
|
||||
private readonly repositoryUrl: string | null
|
||||
) {}
|
||||
|
||||
async create(): Promise<void> {
|
||||
this.window = new BrowserWindow({
|
||||
width: 720,
|
||||
height: 820,
|
||||
minWidth: 640,
|
||||
minHeight: 680,
|
||||
private async ensureWindow(): Promise<BrowserWindow> {
|
||||
if (this.window && !this.window.isDestroyed()) return this.window;
|
||||
if (this.creating) return this.creating;
|
||||
this.creating = this.createWindow();
|
||||
try {
|
||||
return await this.creating;
|
||||
} finally {
|
||||
this.creating = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async createWindow(): Promise<BrowserWindow> {
|
||||
const window = new BrowserWindow({
|
||||
width: 600,
|
||||
height: 650,
|
||||
minWidth: 560,
|
||||
minHeight: 560,
|
||||
show: false,
|
||||
title: 'MeloASR 设置',
|
||||
autoHideMenuBar: true,
|
||||
@@ -35,18 +47,19 @@ export class SettingsWindow {
|
||||
sandbox: true
|
||||
}
|
||||
});
|
||||
this.window.on('close', (event) => {
|
||||
if ((app as typeof app & { isQuitting?: boolean }).isQuitting) return;
|
||||
event.preventDefault();
|
||||
this.window?.hide();
|
||||
this.window = window;
|
||||
window.on('closed', () => {
|
||||
this.overlay.endPositioning();
|
||||
if (this.window === window) this.window = null;
|
||||
});
|
||||
this.window.on('hide', () => this.overlay.endPositioning());
|
||||
await this.window.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html'));
|
||||
await window.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html'));
|
||||
return window;
|
||||
}
|
||||
|
||||
show(): void {
|
||||
this.window?.show();
|
||||
this.window?.focus();
|
||||
async show(): Promise<void> {
|
||||
const window = await this.ensureWindow();
|
||||
window.show();
|
||||
window.focus();
|
||||
this.publish();
|
||||
}
|
||||
|
||||
@@ -60,7 +73,7 @@ export class SettingsWindow {
|
||||
const definition = this.backends.activeDefinition();
|
||||
const decision = resolveDebugLoginAction(Boolean(current.loginNoticeShown[definition.id]), confirmed);
|
||||
if (decision.action === 'prompt') {
|
||||
this.show();
|
||||
await this.show();
|
||||
this.window?.webContents.send('settings:login-notice', { backendLabel: definition.label });
|
||||
return;
|
||||
}
|
||||
@@ -81,7 +94,6 @@ export class SettingsWindow {
|
||||
sessionState: this.sessions.state,
|
||||
shortcut: current.shortcut,
|
||||
autoStart: current.autoStart,
|
||||
overlayPersistent: current.overlayPersistent,
|
||||
diagnosticLogging: current.diagnosticLogging,
|
||||
overlayPosition: position,
|
||||
displays: screen.getAllDisplays().map((display, index) => ({
|
||||
|
||||
@@ -31,7 +31,7 @@ export class TrayController {
|
||||
create(): void {
|
||||
this.tray = new Tray(trayImage('loading'));
|
||||
this.tray.setToolTip('MeloASR · 正在加载');
|
||||
this.tray.on('click', () => this.settingsWindow.show());
|
||||
this.tray.on('click', () => void this.settingsWindow.show());
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export class TrayController {
|
||||
this.tray.setImage(trayImage(state));
|
||||
this.tray.setToolTip(`MeloASR · ${status.detail}`);
|
||||
this.tray.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: '设置', click: () => this.settingsWindow.show() },
|
||||
{ label: '设置', click: () => void this.settingsWindow.show() },
|
||||
{ label: '打开登录/调试页面', click: () => void this.settingsWindow.openDebug() },
|
||||
{ type: 'separator' },
|
||||
{ label: '重启', click: this.onRestart },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
contextBridge.exposeInMainWorld('meloOverlay', {
|
||||
toggleRecording: (): void => ipcRenderer.send('overlay:toggle-recording'),
|
||||
onState: (listener: (state: string) => void): void => {
|
||||
ipcRenderer.on('overlay-state', (_event, payload: { state: string }) => listener(payload.state));
|
||||
}
|
||||
|
||||
@@ -9,13 +9,10 @@ contextBridge.exposeInMainWorld('meloSettings', {
|
||||
ipcRenderer.invoke('settings:set-position', position, displayId),
|
||||
setPreset: (preset: string, displayId: string): Promise<SettingsViewState> =>
|
||||
ipcRenderer.invoke('settings:set-preset', preset, displayId),
|
||||
previewOverlay: (): Promise<void> => ipcRenderer.invoke('settings:preview-overlay'),
|
||||
beginPositioning: (): Promise<void> => ipcRenderer.invoke('settings:begin-positioning'),
|
||||
endPositioning: (): Promise<void> => ipcRenderer.invoke('settings:end-positioning'),
|
||||
setShortcut: (shortcut: string): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-shortcut', shortcut),
|
||||
setAutoStart: (enabled: boolean): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-auto-start', enabled),
|
||||
setOverlayPersistent: (enabled: boolean): Promise<SettingsViewState> =>
|
||||
ipcRenderer.invoke('settings:set-overlay-persistent', enabled),
|
||||
setDiagnosticLogging: (enabled: boolean): Promise<SettingsViewState> =>
|
||||
ipcRenderer.invoke('settings:set-diagnostic-logging', enabled),
|
||||
openRepository: (): Promise<void> => ipcRenderer.invoke('settings:open-repository'),
|
||||
|
||||
Vendored
-3
@@ -8,19 +8,16 @@ declare global {
|
||||
openDebug(confirmed?: boolean): Promise<void>;
|
||||
setPosition(position: Point, displayId: string): Promise<SettingsViewState>;
|
||||
setPreset(preset: string, displayId: string): Promise<SettingsViewState>;
|
||||
previewOverlay(): Promise<void>;
|
||||
beginPositioning(): Promise<void>;
|
||||
endPositioning(): Promise<void>;
|
||||
setShortcut(shortcut: string): Promise<SettingsViewState>;
|
||||
setAutoStart(enabled: boolean): Promise<SettingsViewState>;
|
||||
setOverlayPersistent(enabled: boolean): Promise<SettingsViewState>;
|
||||
setDiagnosticLogging(enabled: boolean): Promise<SettingsViewState>;
|
||||
openRepository(): Promise<void>;
|
||||
onState(listener: (state: SettingsViewState) => void): void;
|
||||
onLoginNotice(listener: (backendLabel: string) => void): void;
|
||||
};
|
||||
meloOverlay: {
|
||||
toggleRecording(): void;
|
||||
onState(listener: (state: string) => void): void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; }
|
||||
body { display: grid; place-items: center; }
|
||||
.voice-overlay { position: relative; display: grid; place-items: center; width: 68px; height: 68px; padding: 0; border: 0; border-radius: 50%; background: radial-gradient(circle at 35% 28%, #71aaff, #2568de 70%); box-shadow: 0 8px 24px rgba(23,79,177,.35); cursor: pointer; }
|
||||
.voice-overlay { position: relative; display: grid; place-items: center; width: 68px; height: 68px; border-radius: 50%; background: radial-gradient(circle at 35% 28%, #71aaff, #2568de 70%); box-shadow: 0 8px 24px rgba(23,79,177,.35); }
|
||||
.ring { position: absolute; inset: 0; border: 2px solid rgba(69,136,245,.45); border-radius: 50%; opacity: 0; }
|
||||
.voice-overlay[data-state="recording"] .ring { animation: ripple 1.7s ease-out infinite; }
|
||||
.voice-overlay[data-state="recording"] .ring.two { animation-delay: .55s; }
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
<link rel="stylesheet" href="overlay.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="voice-overlay" data-state="idle" aria-label="开始或停止语音输入">
|
||||
<div class="voice-overlay" data-state="idle" aria-hidden="true">
|
||||
<span class="ring one"></span><span class="ring two"></span>
|
||||
<span class="bars"><i></i><i></i><i></i><i></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<script src="overlay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
const root = document.querySelector<HTMLElement>('.voice-overlay')!;
|
||||
|
||||
root.addEventListener('click', () => window.meloOverlay.toggleRecording());
|
||||
root.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') window.meloOverlay.toggleRecording();
|
||||
});
|
||||
window.meloOverlay.onState((state) => { root.dataset.state = state; });
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
<section>
|
||||
<h2>悬浮球</h2>
|
||||
<label class="switch"><input id="overlay-persistent" type="checkbox"><span>始终显示悬浮球</span></label>
|
||||
<button id="open-position">调整悬浮球位置</button>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ interface SettingsViewState {
|
||||
sessionState: 'idle' | 'starting' | 'recording' | 'stopping';
|
||||
shortcut: string;
|
||||
autoStart: boolean;
|
||||
overlayPersistent: boolean;
|
||||
diagnosticLogging: boolean;
|
||||
overlayPosition: { x: number; y: number };
|
||||
displays: Array<{ id: string; label: string; workArea: { x: number; y: number; width: number; height: number } }>;
|
||||
@@ -26,7 +25,6 @@ const x = document.querySelector<HTMLInputElement>('#position-x')!;
|
||||
const y = document.querySelector<HTMLInputElement>('#position-y')!;
|
||||
const shortcut = document.querySelector<HTMLButtonElement>('#shortcut')!;
|
||||
const autoStart = document.querySelector<HTMLInputElement>('#auto-start')!;
|
||||
const overlayPersistent = document.querySelector<HTMLInputElement>('#overlay-persistent')!;
|
||||
const diagnosticLogging = document.querySelector<HTMLInputElement>('#diagnostic-logging')!;
|
||||
const version = document.querySelector<HTMLElement>('#version')!;
|
||||
const repository = document.querySelector<HTMLButtonElement>('#repository')!;
|
||||
@@ -71,7 +69,6 @@ function render(next: SettingsViewState): void {
|
||||
renderPositionPreview(next);
|
||||
shortcut.textContent = capturingShortcut ? '请按下新快捷键…' : displayShortcut(next.shortcut);
|
||||
autoStart.checked = next.autoStart;
|
||||
overlayPersistent.checked = next.overlayPersistent;
|
||||
diagnosticLogging.checked = next.diagnosticLogging;
|
||||
version.textContent = next.version;
|
||||
repository.textContent = next.repositoryUrl ?? '尚未配置';
|
||||
@@ -197,9 +194,6 @@ shortcut.addEventListener('click', () => {
|
||||
shortcut.focus();
|
||||
});
|
||||
autoStart.addEventListener('change', () => void apply(() => window.meloSettings.setAutoStart(autoStart.checked)));
|
||||
overlayPersistent.addEventListener('change', () => void apply(
|
||||
() => window.meloSettings.setOverlayPersistent(overlayPersistent.checked)
|
||||
));
|
||||
diagnosticLogging.addEventListener('change', () => void apply(
|
||||
() => window.meloSettings.setDiagnosticLogging(diagnosticLogging.checked)
|
||||
));
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface AppSettings {
|
||||
backendId: string;
|
||||
shortcut: string;
|
||||
autoStart: boolean;
|
||||
overlayPersistent: boolean;
|
||||
diagnosticLogging: boolean;
|
||||
overlayPosition: Point | null;
|
||||
loginNoticeShown: Record<string, boolean>;
|
||||
@@ -43,7 +42,6 @@ export interface SettingsViewState {
|
||||
sessionState: SessionState;
|
||||
shortcut: string;
|
||||
autoStart: boolean;
|
||||
overlayPersistent: boolean;
|
||||
diagnosticLogging: boolean;
|
||||
overlayPosition: Point;
|
||||
displays: DisplayInfo[];
|
||||
|
||||
Reference in New Issue
Block a user