arona-agent 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +16 -9
  2. package/assets/blue-archive/arona/voice_sovits.mp3 +0 -0
  3. package/assets/blue-archive/arona/voice_text.txt +1 -0
  4. package/assets/blue-archive/hoshino/voice_sovits.mp3 +0 -0
  5. package/assets/blue-archive/hoshino/voice_text.txt +1 -0
  6. package/assets/blue-archive/plana/voice_sovits.mp3 +0 -0
  7. package/assets/blue-archive/plana/voice_text.txt +1 -0
  8. package/assets/blue-archive/shiroko/voice_sovits.mp3 +0 -0
  9. package/assets/blue-archive/shiroko/voice_text.txt +1 -0
  10. package/bin/arona.mjs +3 -1
  11. package/bin/postinstall-fix.mjs +46 -0
  12. package/package.json +3 -2
  13. package/pet/renderer/gallery.js +2 -2
  14. package/pet/renderer/renderer.js +1 -1
  15. package/pet/renderer/style.css +16 -2
  16. package/pet/tools/gen_sway.cjs +1 -1
  17. package/pet/tools/visual_test.cjs +2 -2
  18. package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
  19. package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
  20. package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
  21. package/python/__pycache__/oss_upload.cpython-314.pyc +0 -0
  22. package/python/__pycache__/stt.cpython-314.pyc +0 -0
  23. package/python/__pycache__/tts_say.cpython-314.pyc +0 -0
  24. package/python/hotkey.py +7 -1
  25. package/python/oss_upload.py +70 -0
  26. package/python/tts_say.py +182 -19
  27. package/src/agent.ts +155 -147
  28. package/src/commands.ts +17 -23
  29. package/src/config.ts +45 -2
  30. package/src/gesture_context.ts +48 -0
  31. package/src/gpt_sovits_local.ts +706 -0
  32. package/src/index.ts +13 -10
  33. package/src/memory.ts +3 -10
  34. package/src/oss_upload.ts +116 -0
  35. package/src/renderer.ts +17 -6
  36. package/src/repl.ts +70 -59
  37. package/src/setup.ts +349 -15
  38. package/src/skills.ts +32 -2
  39. package/src/slash_menu.ts +1 -1
  40. package/src/slash_registry.ts +6 -5
  41. package/src/tools/read_docs_tool.ts +84 -0
  42. package/src/tools/tavily_tools.ts +24 -10
  43. package/src/tools/voice_tools.ts +1 -1
  44. package/src/tts_provider.ts +501 -0
  45. package/src/tts_stream.ts +321 -31
  46. package/src/tui_select.ts +31 -2
  47. package/src/undo.ts +82 -18
  48. package/src/utils/python.ts +22 -0
  49. package/src/voice.ts +17 -7
  50. package/src/voice_cli.ts +117 -9
  51. package/src/voices.ts +197 -28
package/README.md CHANGED
@@ -8,14 +8,14 @@
8
8
 
9
9
  ![示意图](./intro.png)
10
10
 
11
- 基于 [Pi SDK](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) 构建的终端对话式 AI Agent,集成 Computer Use、语音(TTS/STT)、桌面宠物、持久记忆与会话管理。
11
+ 基于 [Pi SDK](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) 构建的终端对话式 AI Agent,集成 Computer Use、语音(TTS/STT)、桌面宠物与持久记忆。
12
12
 
13
13
  ---
14
14
 
15
15
  ## 安装
16
16
 
17
17
  ```bash
18
- npm install -g git+https://github.com/SRC114514/arona-agent.git
18
+ npm i -g arona-agent
19
19
 
20
20
  # 初始化配置文件
21
21
  arona setup
@@ -23,6 +23,9 @@ arona setup
23
23
  # 初始化后可直接启动
24
24
  arona
25
25
 
26
+ # 更新
27
+ npm u -g arona-agent
28
+
26
29
  # 禁用TTS+STT并启动
27
30
  arona --no-voice
28
31
 
@@ -56,8 +59,8 @@ arona voice add [<角色名>] # 不带角色名则进入 TUI 选择未补全
56
59
  | 字段 | 说明 | 默认值 |
57
60
  |---|---|---|
58
61
  | `apiKey` | API Key | — |
59
- | `apiBaseUrl` | API地址(留空则按模型名自动匹配) | — |
60
- | `model` | 模型名(`provider/model-id`,裸名自动补前缀) | `openai/gpt-4o` |
62
+ | `apiBaseUrl` | API地址 | — |
63
+ | `model` | 模型名 | `openai/gpt-4o` |
61
64
  | `thinkingLevel` | 思考等级 | `medium` |
62
65
  | `contextWindow` | 上下文窗口 | `1000000` |
63
66
  | `language` | 界面语言(`auto`/`zh`/`en`) | `auto` |
@@ -66,8 +69,10 @@ arona voice add [<角色名>] # 不带角色名则进入 TUI 选择未补全
66
69
  | `ttsEnabled` | 启用 TTS | `true` |
67
70
  | `sttEnabled` | 启用 STT | `true` |
68
71
  | `workspaceId` | 阿里云百炼业务空间 ID | — |
69
- | `ttsApiKey` | 百炼 API Key | — |
72
+ | `ttsApiKey` | 百炼 API Key(GPT-SoVITS 时仅用于 STT) | — |
70
73
  | `ttsModel` | TTS 模型 | `qwen-audio-3.0-tts-plus` |
74
+ | `ttsProvider` | TTS 后端(`aliyun`/`gpt-sovits`) | `aliyun` |
75
+ | `ttsConfig` | 各 Provider 配置 | `{}` |
71
76
  | `sttApiKey` | 百炼 API Key | — |
72
77
  | `sttModel` | STT 模型 | `qwen-audio-3.0-asr-flash-streaming` |
73
78
  | `sttFormat` | STT 音频格式 | `pcm` |
@@ -86,7 +91,7 @@ arona voice add [<角色名>] # 不带角色名则进入 TUI 选择未补全
86
91
  | 路径 | 说明 |
87
92
  |---|---|
88
93
  | `settings.json` | 本地总配置文件,字段见 [配置](#配置) |
89
- | `voices.json` | 角色音色克隆 voice_id |
94
+ | `voices.json` | TTS音色配置文件 |
90
95
  | `MEMORY.md` | 持久记忆 |
91
96
  | `sessions/` | 已保存会话 |
92
97
  | `skills/` | 自定义 Skill |
@@ -98,8 +103,10 @@ arona voice add [<角色名>] # 不带角色名则进入 TUI 选择未补全
98
103
  ## 桌宠与 STT 热键
99
104
 
100
105
  - **桌宠**:透明无边框置顶窗口;拖动窗口可移动,在头部区域左右摇晃可触发"摸头"彩蛋;点击/拖拽还会触发特效
101
- - **STT 热键**:右 Cmd 键,长按 ≥ 2 秒触发一次性录音。
102
- - **macOS 权限**:全局键盘监听需「系统设置 → 隐私与安全性 → 辅助功能」为Python授权;Computer Use 截图需授予终端「屏幕录制」权限。
106
+ - **STT 热键**:macOS 为右 Cmd 键,Windows/Linux 为右 Ctrl 键,长按 ≥ 2 秒触发一次性录音。
107
+ > MacOS下,全局键盘监听需「系统设置 → 隐私与安全性 → 辅助功能」为Python授权;
108
+
109
+ > Computer Use 截图需授予终端「屏幕录制」权限。
103
110
 
104
111
  ---
105
112
 
@@ -146,4 +153,4 @@ arona voice add [<角色名>] # 不带角色名则进入 TUI 选择未补全
146
153
  - 本项目不得用于影响版权方权益之分发,否则自行承担相关侵权责任。
147
154
 
148
155
  此类内容的知识产权归Nexon Games Co., Ltd.及YOSTAR LIMITED所有,其使用、复制及分发规则应严格遵循上述权利方的官方条款及适用法律法规。
149
- 若相关权利人认为本声明或项目使用方式存在不当,可与我联系,将在核实后第一时间调整或移除相关内容。
156
+ 若相关权利人认为本声明或项目使用方式存在不当,可与我联系,将在核实后第一时间调整或移除相关内容。
@@ -0,0 +1 @@
1
+ 老……老师?!居然能进到这个空间,难,难道……您就是……老师?
@@ -0,0 +1 @@
1
+ 就我一个人,这么开心是不是不太好?哎呀~新春新气象,那大叔我啊……
@@ -0,0 +1 @@
1
+ 老师。识别目标,确认为,什亭之箱,将响应等级提升到最高。确认战况,变更应对方式。
@@ -0,0 +1 @@
1
+ 祝你生日快乐,老师。为了纪念这个时刻,我们一起去慢跑怎么样?嗯,不愿意吗?原来……
package/bin/arona.mjs CHANGED
@@ -34,8 +34,10 @@ if (!existsSync(tsxBin)) {
34
34
  }
35
35
 
36
36
  const child = spawn(tsxBin, [join(root, target), ...passArgs], {
37
+ // 保持用户调用 arona 时所在的目录作为工作目录(Workspace = CWD)。
38
+ // 之前固定 cwd: root 会让全局安装的 arona 把包目录当工作区,文件工具/项目文档/undo 全部错位。
39
+ cwd: process.cwd(),
37
40
  stdio: 'inherit',
38
- cwd: root,
39
41
  // Windows: tsxBin 是 .cmd 批处理文件。Node >=20.12.2/18.20.2(CVE-2024-27980 修复,2024-04-10)
40
42
  // 起,spawn .bat/.cmd 且不设 shell 会直接抛 EINVAL,导致 `arona setup` 在 Windows 上无法启动。
41
43
  shell: process.platform === 'win32',
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ // ARONA postinstall autofix.
3
+ //
4
+ // npm 11+ blocks a global package's *dependency* install-scripts and refuses to
5
+ // read the installed package's own `.npmrc`, so `npm install -g arona-agent`
6
+ // always warns about blocked scripts for @google/genai / protobufjs / esbuild /
7
+ // fsevents. The blocking is functionally harmless, but noisy.
8
+ //
9
+ // When this package is installed Globally, its OWN postinstall lifecycle script
10
+ // runs (script-blocking only targets the package's dependencies), so this is the
11
+ // only reliable repo-side lever: merge the allow-scripts entries into the
12
+ // END-USER's user-level npm config (~/.npmrc). Subsequent global installs/upgrades
13
+ // no longer warn. It only acts for GLOBAL installs (`npm_config_global=true`);
14
+ // a local `npm install` inside the repo never touches the developer's global config.
15
+ import { spawnSync } from 'node:child_process';
16
+
17
+ // The exact packages named in the blocked-scripts warning.
18
+ const TARGETS = ['@google/genai', 'protobufjs', 'esbuild', 'fsevents'];
19
+
20
+ // Only a global install may write to the user-level npm config.
21
+ if (process.env.npm_config_global !== 'true') process.exit(0);
22
+
23
+ function npm(args) {
24
+ try {
25
+ const r = spawnSync('npm', args, {
26
+ encoding: 'utf8',
27
+ stdio: ['ignore', 'pipe', 'ignore'],
28
+ shell: process.platform === 'win32',
29
+ });
30
+ return r.status === 0 ? (r.stdout || '').trim() : null;
31
+ } catch {
32
+ return null; // npm missing/broken → silently skip, never fail the install
33
+ }
34
+ }
35
+
36
+ const SET_KEYS = ['config', 'set', 'allow-scripts'];
37
+ const GET_KEYS = ['config', 'get', 'allow-scripts', '--location=user'];
38
+
39
+ const current = (npm(GET_KEYS) ?? '').split(',')
40
+ .map((s) => s.trim())
41
+ .filter(Boolean);
42
+ const merged = [...new Set([...current, ...TARGETS])];
43
+
44
+ if (merged.join(',') === [...current].join(',')) process.exit(0); // already satisfied
45
+
46
+ npm([...SET_KEYS, merged.join(','), '--location=user']); // fully silent, never print
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arona-agent",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Terminal AI Agent with desktop pet Arona — eye-tracking pupils, voice cloning, Computer Use, TTS/STT, MCP.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,7 +17,8 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "start": "tsx src/index.ts",
20
- "dev": "tsx watch src/index.ts"
20
+ "dev": "tsx watch src/index.ts",
21
+ "postinstall": "node bin/postinstall-fix.mjs"
21
22
  },
22
23
  "dependencies": {
23
24
  "@earendil-works/pi-ai": "^0.84.1",
@@ -1,8 +1,8 @@
1
1
  // 表情目视图库(gallery.js):加载当前 Agent 骨架,逐帧切换数字情绪预设,供人工目视选号。
2
2
  // 由 pet/tools/gallery_capture.cjs 驱动截图。
3
3
  // 与 spinetest 不同:这里不调度眨眼/注视/拖动,保证截图干净。
4
- // 注意:截图内不带任何水印(旧 #label 水印已删——DOM 文本与 WebGL 画布是两条合成路径,
5
- // 曾出现水印滞后于骨架的过期纹理帧,编号与脸错位误导选号;编号一律以 gallery.html 文字为准)。
4
+ // 注意:截图内不带任何水印——DOM 文本与 WebGL 画布是两条合成路径,
5
+ // 曾出现水印滞后于骨架的过期纹理帧,编号与脸错位误导选号;编号一律以 gallery.html 文字为准。
6
6
  const canvas = document.getElementById("spine");
7
7
 
8
8
  async function init() {
@@ -107,7 +107,7 @@ const DIZZY_RECOVER_MS = 2000; // 松手后保持晕脸的时长,之后自动
107
107
  // 头部区域:x 比例基于左侧 320px 渲染区(spineCanvas.clientWidth),y 基于窗口高。
108
108
  // ⚠️ x 不能按整窗 innerWidth(580 = 320 渲染 + 260 气泡)算:0.26×580≈151px 比头部实际
109
109
  // 左缘(渲染区内 ~110px)偏右,会把头部左边缘切掉——左侧长按拖动永不触发摸头(2026-08-20 实测)。
110
- // Spine 姿势实测头部在 CSS y 119~180(旧视频为 y 6%-29%,保留旧值兼容)。
110
+ // Spine 姿势实测头部在 CSS y 119~180
111
111
  const HEAD_BOX = { xMin: 0.26, xMax: 0.78, yMin: 0.06, yMax: 0.29 };
112
112
  // 摸头期间"离开头部"判定缓冲(px):用户反馈 16px 太严苛,先放宽 50px、再要求更宽 → 90px;
113
113
  // 摸头时手在头部附近大幅游移不打断;离开很远(躯干以下/窗口外)才结束 enjoy
@@ -34,11 +34,12 @@ body:active {
34
34
 
35
35
 
36
36
  /* 桌宠文字气泡:定位到角色头部右侧,贴近角色(不遮脸、不高高飘在右上);
37
- top 取头部高度(头 CSS y 40~195),left 紧邻渲染区右缘(320 + 10px 间距);
37
+ top 取头部高度(头 CSS y 40~195),left 270px 贴近头部右缘(≈250)留 ~20px 缝隙,
38
+ 由 ::after 小尾巴向左伸进缝隙指向角色(视觉上"连到"角色,不显远);
38
39
  高于 Spine、鼠标穿透;只显示 mid/final 短消息 */
39
40
  #pet-bubble {
40
41
  position: absolute;
41
- left: 310px;
42
+ left: 270px;
42
43
  top: 90px;
43
44
  transform: none;
44
45
  max-width: 220px;
@@ -58,6 +59,19 @@ body:active {
58
59
  transition: opacity 0.25s ease;
59
60
  }
60
61
 
62
+ /* 左指小三角尾巴:从气泡左缘向左伸进气泡与角色头部的缝隙里,指向角色。
63
+ 随父级 hidden / opacity 过渡一起隐藏。top 取气泡中部即可,位置可按需微调 */
64
+ #pet-bubble::after {
65
+ content: "";
66
+ position: absolute;
67
+ left: -12px;
68
+ top: 50%;
69
+ transform: translateY(-50%);
70
+ border-top: 8px solid transparent;
71
+ border-bottom: 8px solid transparent;
72
+ border-right: 12px solid rgba(20, 22, 34, 0.82);
73
+ }
74
+
61
75
  #pet-bubble.hidden {
62
76
  opacity: 0;
63
77
  }
@@ -2,7 +2,7 @@
2
2
  // 用法:node pet/tools/gen_sway.cjs shiroko [--amp 20] [--keys 12]
3
3
  // 模式(SWAY_TARGETS[].main.mode):
4
4
  // band = y 区间带内摆动(Shiroko 百褶裙:腿/手臂钉死,只动裙摆带;带内越低越自由)
5
- // bottom= mesh 底部向上渐变(旧逻辑,仅适用于底缘就是自由端的 mesh)
5
+ // bottom= mesh 底部向上渐变(仅适用于底缘就是自由端的 mesh)
6
6
  // 原理(对未加权 mesh,附件顶点在骨本地空间):
7
7
  // - dx = amp·w·sin(2πt/T + φ(x)),φ 随 x 错开制造布料波浪
8
8
  // - dy = amp·dyRatio·w·sin(4πt/T)(微弱垂直起伏)
@@ -22,7 +22,7 @@ let win;
22
22
  // 指令时间线:t 秒时向渲染层发送情绪/重置指令;offs 覆盖默认截屏偏移
23
23
  // expect:落定后(+400ms)执行的断言(track4/slot attachment 期望值)
24
24
  const TIMELINE = [
25
- // —— 情绪进/出 + 瞬时切换(原溶解/中断语义已退役)——
25
+ // —— 情绪进/出 + 瞬时切换 ——
26
26
  { t: 2.0, type: "emotion", name: "enjoy", tag: "enjoy",
27
27
  expect: { track4: "13", coverL: "L_Eye_Cover_01", halo: "halo_normal_00", mouse: "Mouse_01", gaze: false } },
28
28
  { t: 3.0, type: "emotion", name: "smile", tag: "smile",
@@ -38,7 +38,7 @@ const TIMELINE = [
38
38
  expect: { track4: "05", halo: "halo_angry", mouse: "Mouse_06", gaze: false } },
39
39
  { t: 6.4, type: "reset", tag: "angryBack",
40
40
  expect: { track4: null, halo: "halo_normal_00", gaze: true } },
41
- // —— 说话/疑惑(旧介导断言改为 track4 预设 + 特征 slot)——
41
+ // —— 说话/疑惑(track4 预设 + 特征 slot)——
42
42
  { t: 7.0, type: "emotion", name: "saying", tag: "saying", offs: [100, 300, 700],
43
43
  expect: { track4: "20", mouse: "Mouse_02", halo: "halo_normal_00", gaze: true } },
44
44
  { t: 8.2, type: "reset", tag: "sayingBack", offs: [100, 300, 700],
package/python/hotkey.py CHANGED
@@ -9,7 +9,10 @@ ARONA 全局热键监听 - pynput 实现
9
9
  退出时输出 {"event":"exit"}。
10
10
 
11
11
  环境变量:
12
- ARONA_HOTKEY_KEY - 热键名称(默认 cmd_r,即右 Cmd
12
+ ARONA_HOTKEY_KEY - 热键名称(默认 cmd_r,即右 Cmd)。
13
+ 实际按键由 Node 侧(src/repl.ts)按平台注入:macOS 传 cmd_r(右 Cmd),
14
+ Windows/Linux 传 ctrl_r(右 Ctrl)——pynput 在 Windows 无 Key.cmd_r,
15
+ 默认 cmd_r 在该平台会静默失效。用户仍可用本环境变量自定义覆盖。
13
16
  ARONA_HOTKEY_HOLD_MS - 长按阈值(默认 2000 毫秒)
14
17
  """
15
18
 
@@ -157,6 +160,9 @@ def main():
157
160
  return
158
161
  if target_is_right and is_right is not True:
159
162
  return
163
+ if not target_is_right and is_right is True and target_code in ("cmd", "ctrl", "alt", "shift"):
164
+ # 左键模式:排除右键释放,避免右修饰键误清左键状态
165
+ return
160
166
 
161
167
  with state["lock"]:
162
168
  state["pressed_at"] = None
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ARONA OSS Upload — 把本地文件上传到阿里云 DashScope 托管 OSS,返回可长期引用的 URL。
4
+
5
+ 与 voice_clone.py 同一上传通道(dashscope Files.upload / Files.get),但【不删除】已上传文件,
6
+ 因为返回的 OSS URL 需要长期被云端 GPT-SoVITS 作为 ref_audio_path 引用(删除后 URL 会 404)。
7
+
8
+ stdin(单行 JSON):
9
+ {"path": "<本地文件绝对路径>", "apiKey": "<DashScope API Key>"}
10
+
11
+ stdout:
12
+ {"url": "<OSS URL>"} 成功
13
+ {"error": "..."} 失败
14
+
15
+ 日志走 stderr(保持 stdout 干净 JSON)。
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+
22
+
23
+ def fail(msg):
24
+ print(json.dumps({"error": msg}))
25
+ # 同步写 stderr:Node 侧 runPython 在非零退出码时只保留 stderr,不写的话真实原因到不了调用方。
26
+ print(f"oss_upload error: {msg}", file=sys.stderr)
27
+ sys.exit(1)
28
+
29
+
30
+ def main():
31
+ line = sys.stdin.readline()
32
+ if not line:
33
+ fail("no stdin input")
34
+ try:
35
+ cmd = json.loads(line)
36
+ except Exception:
37
+ fail("invalid stdin JSON")
38
+ path = (cmd.get("path") or "").strip()
39
+ api_key = (cmd.get("apiKey") or "").strip()
40
+ if not path or not os.path.isfile(path):
41
+ fail(f"File not found: {path}")
42
+ if not api_key:
43
+ fail("DashScope apiKey not set")
44
+
45
+ try:
46
+ import dashscope
47
+ from dashscope import Files
48
+ except ImportError:
49
+ fail("dashscope package not installed. Run: pip install dashscope")
50
+
51
+ dashscope.api_key = api_key
52
+
53
+ # 1. Upload to DashScope hosted OSS
54
+ try:
55
+ resp = Files.upload(file_path=path, purpose="voice_clone")
56
+ file_id = resp.output["uploaded_files"][0]["file_id"]
57
+ except Exception as e:
58
+ fail(f"Upload failed: {e}")
59
+
60
+ # 2. Get the OSS URL (长期有效,可被云端服务端引用)
61
+ try:
62
+ oss_url = Files.get(file_id).output["url"]
63
+ except Exception as e:
64
+ fail(f"Failed to get OSS URL: {e}")
65
+
66
+ print(json.dumps({"url": oss_url}))
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
package/python/tts_say.py CHANGED
@@ -3,18 +3,22 @@
3
3
  ARONA 非流式 TTS:一句话合成并播放(一次性进程,非流式 HTTP)。
4
4
 
5
5
  与 Node 侧(src/tts_stream.ts)通过 stdin/stdout JSON 行通信(每句一个进程):
6
- stdin (单行): {"text": "...", "voice": "..."}
6
+ stdin (单行): {"provider":"aliyun"|"gpt-sovits", "text": "...", ...}
7
7
  stdout 事件(协议通道,禁止混入日志):
8
8
  {"event":"ready"} 进程就绪
9
9
  {"event":"play_start"} 音频就绪、开始播放
10
10
  {"event":"play_end"} 播放完毕
11
+ {"event":"weights_loaded","gpt":"...","sovits":"..."} GPT-SoVITS 权重切换完成
11
12
  {"event":"error","message":"..."}
12
13
 
13
- 流程:HTTP POST 非流式 TTS 端点 → 解析 output.audio.url → 下载音频 → 播放。
14
+ Provider:
15
+ - aliyun 百炼非流式 SpeechSynthesizer → 下载 OSS 音频 → 播放。
16
+ - gpt-sovits GPT-SoVITS api_v2(云端/本地):可选 apiKey 作 Bearer 鉴权、
17
+ set_gpt_weights/set_sovits_weights 切换权重,POST /tts 拿 wav 字节 → 播放。
14
18
  整句一次合成,语气上下文完整,避免流式逐段截断(如"好好吃饭哦"被拆读)。
15
19
  播放:pyaudio 播 wav;pyaudio 不可用降级系统播放器(afplay / powershell / aplay / ffplay / paplay)。
16
20
 
17
- 环境变量:
21
+ 百炼环境变量:
18
22
  QWEN_TTS_API_KEY - 百炼 API Key
19
23
  QWEN_TTS_MODEL - 模型名(默认 qwen-audio-3.0-tts-plus)
20
24
  QWEN_WORKSPACE_ID - 百炼业务空间 ID(可选;留空走全局域名 dashscope.aliyuncs.com)
@@ -27,6 +31,9 @@ import shutil
27
31
  import subprocess
28
32
  import sys
29
33
  import tempfile
34
+ import time
35
+ import urllib.error
36
+ import urllib.parse
30
37
  import urllib.request
31
38
 
32
39
  from _i18n import t
@@ -53,8 +60,8 @@ def build_endpoint():
53
60
  return "https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer"
54
61
 
55
62
 
56
- def synthesize(text, voice):
57
- """非流式合成:返回完整 wav 音频字节。"""
63
+ def synthesize_aliyun(text, voice):
64
+ """百炼非流式合成:返回完整 wav 音频字节。"""
58
65
  api_key = os.environ.get("QWEN_TTS_API_KEY", "")
59
66
  model = os.environ.get("QWEN_TTS_MODEL", "qwen-audio-3.0-tts-plus")
60
67
  if not api_key:
@@ -88,6 +95,109 @@ def synthesize(text, voice):
88
95
  return resp.read()
89
96
 
90
97
 
98
+ def _switch_weights(base_url, endpoint, label, weights_path, timeout, headers=None):
99
+ """调用 GPT-SoVITS api_v2 的 set_gpt_weights / set_sovits_weights(GET 查询串)。"""
100
+ url = f"{base_url}/set_{endpoint}?weights_path={urllib.parse.quote(weights_path)}"
101
+ req = urllib.request.Request(url, headers=headers or {})
102
+ try:
103
+ with _opener().open(req, timeout=timeout) as resp:
104
+ resp.read()
105
+ except urllib.error.HTTPError as e:
106
+ detail = e.read().decode("utf-8", "replace")[:500]
107
+ raise RuntimeError(t(
108
+ f"GPT-SoVITS {label} 权重切换失败 (HTTP {e.code}): {detail}",
109
+ f"GPT-SoVITS {label} weights switch failed (HTTP {e.code}): {detail}",
110
+ ))
111
+ except urllib.error.URLError as e:
112
+ raise RuntimeError(t(
113
+ f"GPT-SoVITS {label} 权重切换失败:{e.reason}",
114
+ f"GPT-SoVITS {label} weights switch failed: {e.reason}",
115
+ ))
116
+
117
+
118
+ def synthesize_gpt_sovits(cmd):
119
+ """GPT-SoVITS api_v2 非流式合成:必要时切换权重,POST /tts 返回 wav 字节。"""
120
+ base_url = (cmd.get("baseUrl") or "http://127.0.0.1:9880").rstrip("/")
121
+ timeout = float(cmd.get("timeoutMs") or 60000) / 1000.0
122
+ ref_audio_path = cmd.get("refAudioPath") or ""
123
+ if not ref_audio_path:
124
+ raise RuntimeError(t("TTS: 未配置 GPT-SoVITS 参考音频", "TTS: GPT-SoVITS ref audio not configured"))
125
+
126
+ gpt_weights = cmd.get("gptWeightsPath") or ""
127
+ sovits_weights = cmd.get("sovitsWeightsPath") or ""
128
+ api_key = cmd.get("apiKey") or ""
129
+ headers = {"Content-Type": "application/json"}
130
+ if api_key:
131
+ headers["Authorization"] = f"Bearer {api_key}"
132
+ switched = {}
133
+ if cmd.get("switchGpt") and gpt_weights:
134
+ _switch_weights(base_url, "gpt_weights", "GPT", gpt_weights, timeout, headers)
135
+ switched["gpt"] = gpt_weights
136
+ if cmd.get("switchSovits") and sovits_weights:
137
+ _switch_weights(base_url, "sovits_weights", "SoVITS", sovits_weights, timeout, headers)
138
+ switched["sovits"] = sovits_weights
139
+ if switched:
140
+ emit("weights_loaded", **switched)
141
+
142
+ body = {
143
+ "text": cmd.get("text") or "",
144
+ "text_lang": cmd.get("textLang") or "auto",
145
+ "ref_audio_path": ref_audio_path,
146
+ "prompt_text": cmd.get("promptText") or "",
147
+ "prompt_lang": cmd.get("promptLang") or "zh",
148
+ "text_split_method": "cut5",
149
+ "batch_size": 1,
150
+ "media_type": "wav",
151
+ "streaming_mode": False,
152
+ "parallel_infer": True,
153
+ "fragment_interval": 0.3,
154
+ "speed_factor": 1.0,
155
+ }
156
+ req = urllib.request.Request(
157
+ base_url + "/tts",
158
+ data=json.dumps(body).encode("utf-8"),
159
+ headers=headers,
160
+ )
161
+ try:
162
+ with _opener().open(req, timeout=timeout) as resp:
163
+ data = resp.read()
164
+ except urllib.error.HTTPError as e:
165
+ detail = e.read().decode("utf-8", "replace")[:500]
166
+ raise RuntimeError(t(
167
+ f"GPT-SoVITS 请求失败 (HTTP {e.code}): {detail}",
168
+ f"GPT-SoVITS request failed (HTTP {e.code}): {detail}",
169
+ ))
170
+ except urllib.error.URLError as e:
171
+ raise RuntimeError(t(
172
+ f"GPT-SoVITS 连接失败:{e.reason}",
173
+ f"GPT-SoVITS connection failed: {e.reason}",
174
+ ))
175
+ # 200 但返回 JSON 错误(部分分支异常):按错误处理
176
+ if data[:1] == b"{" or data[:1] == b"[":
177
+ try:
178
+ parsed = json.loads(data)
179
+ detail = parsed.get("detail") or parsed.get("msg") or parsed.get("message") or data[:300]
180
+ except Exception:
181
+ detail = data[:300]
182
+ raise RuntimeError(t(
183
+ f"GPT-SoVITS 返回错误:{detail}",
184
+ f"GPT-SoVITS returned error: {detail}",
185
+ ))
186
+ return data
187
+
188
+
189
+ def _audio_bytes(cmd):
190
+ """按 provider 分发合成,返回音频字节(wav)。"""
191
+ provider = cmd.get("provider") or "aliyun"
192
+ text = (cmd.get("text") or "").strip()
193
+ if not text:
194
+ raise RuntimeError(t("TTS: 空文本", "TTS: empty text"))
195
+ if provider == "gpt-sovits":
196
+ return synthesize_gpt_sovits(cmd)
197
+ voice = cmd.get("voice") or ""
198
+ return synthesize_aliyun(text, voice)
199
+
200
+
91
201
  def _play_command(wav_path):
92
202
  """按平台选择降级播放命令(pyaudio 不可用时的非实时兜底)。"""
93
203
  if sys.platform == "darwin":
@@ -101,6 +211,20 @@ def _play_command(wav_path):
101
211
  return ["aplay", wav_path]
102
212
 
103
213
 
214
+ def _remove_file(path):
215
+ """稳健删除临时文件:Windows 降级播放(PowerShell SoundPlayer)返回后句柄/杀软可能
216
+ 尚未释放,单次 os.unlink 会抛 PermissionError;以短间隔重试 ≤3 次收敛后静默。
217
+ macOS/Linux 通常一次即删。"""
218
+ for attempt in range(3):
219
+ try:
220
+ os.unlink(path)
221
+ return
222
+ except OSError:
223
+ if attempt < 2:
224
+ time.sleep(0.1)
225
+ # 最后一次失败:静默容忍(留作残留临时文件,不影响播放/功能)
226
+
227
+
104
228
  def _play_pyaudio(data):
105
229
  import pyaudio
106
230
  import wave
@@ -132,10 +256,7 @@ def _play_fallback(data):
132
256
  tmp.close()
133
257
  subprocess.run(_play_command(tmp.name), check=False)
134
258
  finally:
135
- try:
136
- os.unlink(tmp.name)
137
- except Exception:
138
- pass
259
+ _remove_file(tmp.name)
139
260
 
140
261
 
141
262
  def play_wav(data):
@@ -145,6 +266,51 @@ def play_wav(data):
145
266
  _play_fallback(data)
146
267
 
147
268
 
269
+ def _write_temp_wav(data):
270
+ """写入临时 wav,返回路径(供 synth_only 模式:合成后暂存,由 play 模式进程读取并删除)。"""
271
+ tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
272
+ tmp.write(data)
273
+ tmp.close()
274
+ return tmp.name
275
+
276
+
277
+ def _synth_and_play(cmd):
278
+ """默认模式:合成 + 播放(向后兼容,预合成流水线回退路径)。异常由 main() 统一发 error。"""
279
+ audio = _audio_bytes(cmd)
280
+ emit("play_start")
281
+ play_wav(audio)
282
+ emit("play_end")
283
+
284
+
285
+ def _synth_only(cmd):
286
+ """预合成模式:只合成,写临时 wav 后发 synth_done(带路径)退出,不播放。
287
+ Node 侧在上一句播放期间调用本模式预合成下一句,消除句间 HTTP 合成停顿。
288
+ 异常由 main() 统一发 error。"""
289
+ audio = _audio_bytes(cmd)
290
+ path = _write_temp_wav(audio)
291
+ emit("synth_done", path=path)
292
+
293
+
294
+ def _play_path(cmd):
295
+ """播放模式:读已合成的临时 wav 播放(无 HTTP),播完删除临时文件。"""
296
+ path = cmd.get("path") or ""
297
+ if not path or not os.path.exists(path):
298
+ emit("error", message=t("TTS: 无效的音频路径", "TTS: invalid audio path"))
299
+ sys.stdout.flush()
300
+ return
301
+ try:
302
+ with open(path, "rb") as f:
303
+ data = f.read()
304
+ except Exception as e:
305
+ emit("error", message=str(e))
306
+ sys.stdout.flush()
307
+ return
308
+ _remove_file(path)
309
+ emit("play_start")
310
+ play_wav(data)
311
+ emit("play_end")
312
+
313
+
148
314
  def main():
149
315
  emit("ready")
150
316
  line = sys.stdin.readline()
@@ -156,17 +322,14 @@ def main():
156
322
  emit("error", message=t("TTS: 无效的 stdin JSON", "TTS: invalid stdin JSON"))
157
323
  sys.stdout.flush()
158
324
  return
159
- text = (cmd.get("text") or "").strip()
160
- voice = cmd.get("voice") or ""
161
- if not text:
162
- emit("error", message=t("TTS: 空文本", "TTS: empty text"))
163
- sys.stdout.flush()
164
- return
325
+ mode = cmd.get("mode") or "synth_play"
165
326
  try:
166
- audio = synthesize(text, voice)
167
- emit("play_start")
168
- play_wav(audio)
169
- emit("play_end")
327
+ if mode == "synth_only":
328
+ _synth_only(cmd)
329
+ elif mode == "play":
330
+ _play_path(cmd)
331
+ else:
332
+ _synth_and_play(cmd)
170
333
  sys.stdout.flush()
171
334
  except Exception as e:
172
335
  emit("error", message=str(e))