arona-agent 1.0.0

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 (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/assets/blue-archive/arona/spine/arona_spr.atlas.txt +1168 -0
  4. package/assets/blue-archive/arona/spine/arona_spr.png +0 -0
  5. package/assets/blue-archive/arona/spine/arona_spr.skel +0 -0
  6. package/assets/blue-archive/arona/voice.mp3 +0 -0
  7. package/assets/blue-archive/hoshino/hoshino_spr.atlas.txt +174 -0
  8. package/assets/blue-archive/hoshino/hoshino_spr.json +15385 -0
  9. package/assets/blue-archive/hoshino/hoshino_spr.png +0 -0
  10. package/assets/blue-archive/hoshino/hoshino_spr.skel +0 -0
  11. package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.atlas.txt +111 -0
  12. package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.png +0 -0
  13. package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.skel +0 -0
  14. package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.atlas.txt +251 -0
  15. package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.png +0 -0
  16. package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.skel +0 -0
  17. package/assets/blue-archive/hoshino/voice.mp3 +0 -0
  18. package/assets/blue-archive/plana/spine/plana_spr.atlas.txt +1266 -0
  19. package/assets/blue-archive/plana/spine/plana_spr.png +0 -0
  20. package/assets/blue-archive/plana/spine/plana_spr.skel +0 -0
  21. package/assets/blue-archive/plana/voice.mp3 +0 -0
  22. package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.atlas.txt +97 -0
  23. package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.png +0 -0
  24. package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.skel +0 -0
  25. package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.atlas.txt +174 -0
  26. package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.png +0 -0
  27. package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.skel +0 -0
  28. package/assets/blue-archive/shiroko/shiroko_spr.atlas.txt +167 -0
  29. package/assets/blue-archive/shiroko/shiroko_spr.json +10702 -0
  30. package/assets/blue-archive/shiroko/shiroko_spr.png +0 -0
  31. package/assets/blue-archive/shiroko/shiroko_spr.skel +0 -0
  32. package/assets/blue-archive/shiroko/voice.mp3 +0 -0
  33. package/bin/arona.mjs +43 -0
  34. package/package.json +37 -0
  35. package/pet/agents.cjs +197 -0
  36. package/pet/main.cjs +462 -0
  37. package/pet/preload.cjs +21 -0
  38. package/pet/renderer/fx.html +30 -0
  39. package/pet/renderer/fx.js +41 -0
  40. package/pet/renderer/gallery.html +18 -0
  41. package/pet/renderer/gallery.js +31 -0
  42. package/pet/renderer/index.html +18 -0
  43. package/pet/renderer/renderer.js +382 -0
  44. package/pet/renderer/spine_layer.js +609 -0
  45. package/pet/renderer/spinetest.html +24 -0
  46. package/pet/renderer/spinetest.js +295 -0
  47. package/pet/renderer/style.css +63 -0
  48. package/pet/tools/gallery_capture.cjs +267 -0
  49. package/pet/tools/gen_sway.cjs +170 -0
  50. package/pet/tools/meshify_region.cjs +105 -0
  51. package/pet/tools/skel_inspect.cjs +112 -0
  52. package/pet/tools/skel_to_json.cjs +410 -0
  53. package/pet/tools/spine_node.cjs +69 -0
  54. package/pet/tools/visual_test.cjs +262 -0
  55. package/pet/vendor/spine/spine-canvas.js +8472 -0
  56. package/pet/vendor/spine/spine-webgl.js +10839 -0
  57. package/python/__pycache__/_i18n.cpython-313.pyc +0 -0
  58. package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
  59. package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
  60. package/python/__pycache__/hotkey.cpython-313.pyc +0 -0
  61. package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
  62. package/python/__pycache__/stt.cpython-313.pyc +0 -0
  63. package/python/__pycache__/stt.cpython-314.pyc +0 -0
  64. package/python/__pycache__/tts.cpython-313.pyc +0 -0
  65. package/python/__pycache__/tts.cpython-314.pyc +0 -0
  66. package/python/__pycache__/tts_stream.cpython-313.pyc +0 -0
  67. package/python/__pycache__/tts_stream.cpython-314.pyc +0 -0
  68. package/python/__pycache__/voice_clone.cpython-314.pyc +0 -0
  69. package/python/_i18n.py +18 -0
  70. package/python/computer_use.py +190 -0
  71. package/python/hotkey.py +196 -0
  72. package/python/stt.py +240 -0
  73. package/python/tts_say.py +177 -0
  74. package/python/voice_clone.py +98 -0
  75. package/requirements.txt +6 -0
  76. package/src/agent.ts +791 -0
  77. package/src/agent_registry.ts +109 -0
  78. package/src/commands.ts +641 -0
  79. package/src/config.ts +245 -0
  80. package/src/in_memory_credentials.ts +61 -0
  81. package/src/index.ts +78 -0
  82. package/src/locale.ts +131 -0
  83. package/src/logo.ts +120 -0
  84. package/src/mcp.ts +192 -0
  85. package/src/memory.ts +303 -0
  86. package/src/pet.ts +346 -0
  87. package/src/renderer.ts +350 -0
  88. package/src/repl.ts +902 -0
  89. package/src/setup.ts +371 -0
  90. package/src/skills.ts +96 -0
  91. package/src/slash_menu.ts +371 -0
  92. package/src/slash_registry.ts +53 -0
  93. package/src/speaker_context.ts +41 -0
  94. package/src/text_split.ts +79 -0
  95. package/src/tools/computer_use.ts +142 -0
  96. package/src/tools/emotion_tool.ts +55 -0
  97. package/src/tools/keep_silent_tool.ts +26 -0
  98. package/src/tools/memory_tool.ts +24 -0
  99. package/src/tools/skill_tools.ts +100 -0
  100. package/src/tools/tavily_tools.ts +431 -0
  101. package/src/tools/voice_tools.ts +28 -0
  102. package/src/tts_stream.ts +260 -0
  103. package/src/tui_select.ts +146 -0
  104. package/src/undo.ts +451 -0
  105. package/src/utils/python.ts +211 -0
  106. package/src/utils/spawn.ts +56 -0
  107. package/src/voice.ts +83 -0
  108. package/src/voice_cli.ts +198 -0
  109. package/src/voices.ts +135 -0
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env python3
2
+ """ARONA i18n 辅助(仅用于 stderr 人读日志;stdout 协议通道禁止使用)。
3
+
4
+ 语言判定优先级:ARONA_LANG(Node 注入,最可靠)> LANG 环境变量。
5
+ 协议通道(stdout JSON / READY / 事件名 / 识别文本)为结构豁免,不得翻译。
6
+ """
7
+
8
+ import os
9
+
10
+
11
+ def is_en() -> bool:
12
+ """是否英文模式。ARONA_LANG 由 Node 侧 src/utils/python.ts 注入。"""
13
+ return (os.environ.get("ARONA_LANG") or os.environ.get("LANG") or "").startswith("en")
14
+
15
+
16
+ def t(zh: str, en: str) -> str:
17
+ """按当前语言返回中英两版文案之一。"""
18
+ return en if is_en() else zh
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ARONA Computer Use - cua wrapper (local-only)
4
+ Persistent process that reads JSON commands from stdin, writes JSON responses to stdout.
5
+
6
+ Commands:
7
+ {"action": "screenshot"} -> {"screenshot": "<base64>"}
8
+ {"action": "click", "x": 100, "y": 200} -> {"screenshot": "<base64>"}
9
+ {"action": "click", "x": 100, "y": 200, "button": "right"} -> {"screenshot": "<base64>"}
10
+ {"action": "type", "text": "hello"} -> {"screenshot": "<base64>"}
11
+ {"action": "key", "keys": ["ctrl", "c"]} -> {"screenshot": "<base64>"}
12
+ {"action": "scroll", "x": 100, "y": 200, "direction": "down", "amount": 3} -> {"screenshot": "<base64>"}
13
+ {"action": "move", "x": 100, "y": 200} -> {"screenshot": "<base64>"}
14
+ """
15
+
16
+ import asyncio
17
+ import json
18
+ import sys
19
+ import traceback
20
+
21
+ from _i18n import t
22
+
23
+
24
+ # 需要 macOS 辅助功能权限的操作(pynput 控制鼠标/键盘;截图不需要)
25
+ _AX_REQUIRED_ACTIONS = {"click", "type", "key", "scroll", "move"}
26
+
27
+
28
+ def check_accessibility_permission() -> bool:
29
+ """检查 macOS 辅助功能权限(pynput 控制鼠标/键盘需要此权限,截图不需要)。"""
30
+ import platform
31
+ if platform.system() != "Darwin":
32
+ return True
33
+ try:
34
+ import ctypes
35
+ lib = ctypes.cdll.LoadLibrary(
36
+ '/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices'
37
+ )
38
+ lib.AXIsProcessTrustedWithOptions.restype = ctypes.c_bool
39
+ lib.AXIsProcessTrustedWithOptions.argtypes = [ctypes.c_void_p]
40
+ return lib.AXIsProcessTrustedWithOptions(None)
41
+ except Exception:
42
+ return True # 检查失败时不阻止操作
43
+
44
+
45
+ def check_screen_capture_permission() -> bool:
46
+ """检查 macOS 屏幕录制权限(截图需要此权限;未授权时静默返回空白图)。"""
47
+ import platform
48
+ if platform.system() != "Darwin":
49
+ return True
50
+ try:
51
+ import ctypes
52
+ lib = ctypes.cdll.LoadLibrary(
53
+ '/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics'
54
+ )
55
+ lib.CGPreflightScreenCaptureAccess.restype = ctypes.c_bool
56
+ lib.CGPreflightScreenCaptureAccess.argtypes = []
57
+ return lib.CGPreflightScreenCaptureAccess()
58
+ except Exception:
59
+ return True # 老版本 macOS 无此 API,不阻止
60
+
61
+
62
+ async def main():
63
+ # 检查 macOS 辅助功能权限(pynput 控制鼠标/键盘需要此权限,截图不受影响)
64
+ has_ax_permission = check_accessibility_permission()
65
+ if not has_ax_permission:
66
+ print(t(
67
+ "WARNING: 未授予 macOS 辅助功能权限,鼠标/键盘操作将无法生效(截图不受影响)",
68
+ "WARNING: macOS Accessibility permission not granted — mouse/keyboard actions will not work (screenshot unaffected)",
69
+ ), file=sys.stderr, flush=True)
70
+ print(t(
71
+ "修复方法:系统设置 > 隐私与安全性 > 辅助功能 > 启用运行此程序的终端应用",
72
+ "To fix: System Settings > Privacy & Security > Accessibility > enable the terminal app running this program",
73
+ ), file=sys.stderr, flush=True)
74
+
75
+ # 检查 macOS 屏幕录制权限(截图需要此权限;未授权时静默返回空白图,不报错但 AI 看不到真实屏幕)
76
+ has_screen_permission = check_screen_capture_permission()
77
+ if not has_screen_permission:
78
+ print(t(
79
+ "WARNING: 未授予 macOS 屏幕录制权限,截图将返回空白图(不报错但 AI 看不到真实屏幕内容)",
80
+ "WARNING: macOS Screen Recording permission not granted — screenshots will return a blank image (no error, but the AI cannot see the real screen)",
81
+ ), file=sys.stderr, flush=True)
82
+ print(t(
83
+ "修复方法:系统设置 > 隐私与安全性 > 屏幕录制 > 启用运行此程序的终端应用",
84
+ "To fix: System Settings > Privacy & Security > Screen Recording > enable the terminal app running this program",
85
+ ), file=sys.stderr, flush=True)
86
+
87
+ # Sandbox mode is hard-wired to local (controls the local machine's mouse/keyboard)
88
+ try:
89
+ from cua import Localhost
90
+ computer = await Localhost.connect()
91
+ except Exception as e:
92
+ print(json.dumps({"error": f"Failed to connect to cua Localhost: {e}"}), flush=True)
93
+ sys.exit(1)
94
+
95
+ # Signal ready
96
+ print("READY", flush=True)
97
+
98
+ # Read commands from stdin
99
+ loop = asyncio.get_event_loop()
100
+
101
+ while True:
102
+ try:
103
+ line = await loop.run_in_executor(None, sys.stdin.readline)
104
+ if not line:
105
+ break
106
+
107
+ line = line.strip()
108
+ if not line:
109
+ continue
110
+
111
+ cmd = json.loads(line)
112
+ if not isinstance(cmd, dict):
113
+ print(json.dumps({"error": "Expected JSON object"}), flush=True)
114
+ continue
115
+ action = cmd.get("action", "")
116
+
117
+ result = {}
118
+
119
+ if action in _AX_REQUIRED_ACTIONS and not has_ax_permission:
120
+ result = {"error": "macOS 辅助功能权限未授予,无法执行鼠标/键盘操作。请到「系统设置 > 隐私与安全性 > 辅助功能」中为终端应用授权。"}
121
+
122
+ elif action == "screenshot" and not has_screen_permission:
123
+ result = {"error": "macOS 屏幕录制权限未授予,截图将返回空白图。请到「系统设置 > 隐私与安全性 > 屏幕录制」中为终端应用授权。"}
124
+
125
+ elif action == "screenshot":
126
+ b64 = await computer.screenshot_base64()
127
+ result = {"screenshot": b64}
128
+
129
+ elif action == "click":
130
+ x = cmd["x"]
131
+ y = cmd["y"]
132
+ button = cmd.get("button", "left")
133
+ if button == "right":
134
+ await computer.mouse.right_click(x, y)
135
+ elif button == "double":
136
+ await computer.mouse.double_click(x, y)
137
+ else:
138
+ await computer.mouse.click(x, y)
139
+ b64 = await computer.screenshot_base64()
140
+ result = {"screenshot": b64}
141
+
142
+ elif action == "type":
143
+ text = cmd["text"]
144
+ await computer.keyboard.type(text)
145
+ b64 = await computer.screenshot_base64()
146
+ result = {"screenshot": b64}
147
+
148
+ elif action == "key":
149
+ keys = cmd["keys"]
150
+ await computer.keyboard.keypress(keys)
151
+ b64 = await computer.screenshot_base64()
152
+ result = {"screenshot": b64}
153
+
154
+ elif action == "scroll":
155
+ x = cmd["x"]
156
+ y = cmd["y"]
157
+ direction = cmd.get("direction", "down")
158
+ amount = cmd.get("amount", 3)
159
+ # pynput 语义:正 dy = 向上滚动,负 dy = 向下滚动
160
+ scroll_y = -amount if direction == "down" else amount
161
+ await computer.mouse.scroll(x, y, scroll_x=0, scroll_y=scroll_y)
162
+ b64 = await computer.screenshot_base64()
163
+ result = {"screenshot": b64}
164
+
165
+ elif action == "move":
166
+ x = cmd["x"]
167
+ y = cmd["y"]
168
+ await computer.mouse.move(x, y)
169
+ b64 = await computer.screenshot_base64()
170
+ result = {"screenshot": b64}
171
+
172
+ else:
173
+ result = {"error": f"Unknown action: {action}"}
174
+
175
+ print(json.dumps(result), flush=True)
176
+
177
+ except json.JSONDecodeError:
178
+ print(json.dumps({"error": "Invalid JSON input"}), flush=True)
179
+ except Exception as e:
180
+ print(json.dumps({"error": str(e), "trace": traceback.format_exc()}), flush=True)
181
+
182
+ # Cleanup
183
+ try:
184
+ await computer.disconnect()
185
+ except Exception:
186
+ pass
187
+
188
+
189
+ if __name__ == "__main__":
190
+ asyncio.run(main())
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ARONA 全局热键监听 - pynput 实现
4
+
5
+ 常驻进程,监听全局键盘事件。检测到右 Cmd 长按 >= 2 秒时,
6
+ 向 stdout 输出一行 JSON {"event":"trigger"},Node 端接收后触发 STT 录音。
7
+
8
+ 启动时输出 READY 表示就绪。
9
+ 退出时输出 {"event":"exit"}。
10
+
11
+ 环境变量:
12
+ ARONA_HOTKEY_KEY - 热键名称(默认 cmd_r,即右 Cmd)
13
+ ARONA_HOTKEY_HOLD_MS - 长按阈值(默认 2000 毫秒)
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import sys
19
+ import time
20
+ import threading
21
+
22
+ from _i18n import t
23
+
24
+
25
+ HOLD_MS = int(os.environ.get("ARONA_HOTKEY_HOLD_MS", "2000"))
26
+ HOTKEY_NAME = os.environ.get("ARONA_HOTKEY_KEY", "cmd_r")
27
+
28
+ # pynput key.code 映射
29
+ KEY_CODES = {
30
+ "cmd_r": "cmd",
31
+ "cmd_l": "cmd",
32
+ "ctrl_r": "ctrl",
33
+ "ctrl_l": "ctrl",
34
+ "alt_r": "alt",
35
+ "alt_l": "alt",
36
+ "shift_r": "shift",
37
+ "shift_l": "shift",
38
+ "f8": "f8",
39
+ }
40
+
41
+
42
+ def emit(obj):
43
+ """输出 JSON 到 stdout 并 flush。"""
44
+ sys.stdout.write(json.dumps(obj) + "\n")
45
+ sys.stdout.flush()
46
+
47
+
48
+ def main():
49
+ try:
50
+ from pynput import keyboard
51
+ except Exception as e:
52
+ print(t(f"hotkey: 导入 pynput 失败:{e}", f"hotkey: import pynput failed: {e}"), file=sys.stderr)
53
+ sys.exit(1)
54
+
55
+ # 目标 key code
56
+ target_code = KEY_CODES.get(HOTKEY_NAME, "cmd")
57
+ if HOTKEY_NAME not in KEY_CODES:
58
+ print(t(
59
+ f"hotkey: 未知的 ARONA_HOTKEY_KEY='{HOTKEY_NAME}',回退到 'cmd'",
60
+ f"hotkey: unknown ARONA_HOTKEY_KEY='{HOTKEY_NAME}', falling back to 'cmd'",
61
+ ), file=sys.stderr)
62
+ target_is_right = HOTKEY_NAME.endswith("_r")
63
+
64
+ state = {"pressed_at": None, "fired": False, "lock": threading.Lock()}
65
+
66
+ def on_press(key):
67
+ # 判断是否目标键
68
+ code = None
69
+ is_right = None
70
+ try:
71
+ # pynput 的 key 可能是 KeyCode 或 Key
72
+ if hasattr(key, "name"):
73
+ code = key.name
74
+ elif hasattr(key, "value"):
75
+ code = key.value.vk if hasattr(key.value, "vk") else None
76
+ # 右修饰键判断:pynput 在 macOS 上用 Key.cmd / Key.cmd_l / Key.cmd_r
77
+ if key == keyboard.Key.cmd_r:
78
+ code = "cmd"
79
+ is_right = True
80
+ elif key == keyboard.Key.cmd_l:
81
+ code = "cmd"
82
+ is_right = False
83
+ elif key == keyboard.Key.ctrl_r:
84
+ code = "ctrl"
85
+ is_right = True
86
+ elif key == keyboard.Key.ctrl_l:
87
+ code = "ctrl"
88
+ is_right = False
89
+ elif key == keyboard.Key.alt_r:
90
+ code = "alt"
91
+ is_right = True
92
+ elif key == keyboard.Key.alt_l:
93
+ code = "alt"
94
+ is_right = False
95
+ elif key == keyboard.Key.shift_r:
96
+ code = "shift"
97
+ is_right = True
98
+ elif key == keyboard.Key.shift_l:
99
+ code = "shift"
100
+ is_right = False
101
+ elif key == keyboard.Key.f8:
102
+ code = "f8"
103
+ is_right = None
104
+ except Exception:
105
+ return
106
+
107
+ if code != target_code:
108
+ return
109
+ if target_is_right and is_right is not True:
110
+ return
111
+ if not target_is_right and is_right is True and target_code in ("cmd", "ctrl", "alt", "shift"):
112
+ # 左键模式:排除右键
113
+ return
114
+
115
+ with state["lock"]:
116
+ if state["pressed_at"] is not None:
117
+ # 已按下,忽略重复
118
+ return
119
+ state["pressed_at"] = time.monotonic()
120
+ state["fired"] = False
121
+
122
+ def on_release(key):
123
+ code = None
124
+ is_right = None
125
+ try:
126
+ if key == keyboard.Key.cmd_r:
127
+ code = "cmd"
128
+ is_right = True
129
+ elif key == keyboard.Key.cmd_l:
130
+ code = "cmd"
131
+ is_right = False
132
+ elif key == keyboard.Key.ctrl_r:
133
+ code = "ctrl"
134
+ is_right = True
135
+ elif key == keyboard.Key.ctrl_l:
136
+ code = "ctrl"
137
+ is_right = False
138
+ elif key == keyboard.Key.alt_r:
139
+ code = "alt"
140
+ is_right = True
141
+ elif key == keyboard.Key.alt_l:
142
+ code = "alt"
143
+ is_right = False
144
+ elif key == keyboard.Key.shift_r:
145
+ code = "shift"
146
+ is_right = True
147
+ elif key == keyboard.Key.shift_l:
148
+ code = "shift"
149
+ is_right = False
150
+ elif key == keyboard.Key.f8:
151
+ code = "f8"
152
+ is_right = None
153
+ except Exception:
154
+ return
155
+
156
+ if code != target_code:
157
+ return
158
+ if target_is_right and is_right is not True:
159
+ return
160
+
161
+ with state["lock"]:
162
+ state["pressed_at"] = None
163
+ state["fired"] = False
164
+
165
+ # 长按检测线程:轮询检查是否达到阈值
166
+ def hold_checker():
167
+ while True:
168
+ time.sleep(0.1)
169
+ with state["lock"]:
170
+ pressed_at = state["pressed_at"]
171
+ fired = state["fired"]
172
+ if pressed_at is not None and not fired:
173
+ elapsed_ms = (time.monotonic() - pressed_at) * 1000
174
+ if elapsed_ms >= HOLD_MS:
175
+ state["fired"] = True
176
+ emit({"event": "trigger"})
177
+ # 检查主线程是否还活着
178
+ if not threading.main_thread().is_alive():
179
+ break
180
+
181
+ checker_thread = threading.Thread(target=hold_checker, daemon=True)
182
+ checker_thread.start()
183
+
184
+ emit({"event": "ready"})
185
+
186
+ try:
187
+ with keyboard.Listener(on_press=on_press, on_release=on_release) as listener:
188
+ listener.join()
189
+ except KeyboardInterrupt:
190
+ pass
191
+ finally:
192
+ emit({"event": "exit"})
193
+
194
+
195
+ if __name__ == "__main__":
196
+ main()
package/python/stt.py ADDED
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ARONA STT - Qwen ASR (阿里云百炼 DashScope) 实时语音识别客户端
4
+ 录音并流式上传,返回识别文本到 stdout。
5
+
6
+ 环境变量:
7
+ QWEN_WORKSPACE_ID - 百炼业务空间 ID(可选;留空走旧域名 dashscope.aliyuncs.com)
8
+ QWEN_STT_API_KEY - 百炼 API Key
9
+ QWEN_STT_MODEL - 模型名(默认 qwen-audio-3.0-asr-flash-streaming)
10
+ QWEN_STT_FORMAT - 音频格式(默认 pcm)
11
+ QWEN_STT_SAMPLE_RATE - 采样率(默认 16000)
12
+
13
+ 流程:run-task → (等 task-started) → 录音并 send_bytes → finish-task
14
+ 识别结果在 result-generated 的 payload.output.sentence 中,
15
+ sentence_end=true 为最终结果,聚合后输出到 stdout。
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ import uuid
22
+ import asyncio
23
+ import traceback
24
+
25
+ from _i18n import t
26
+
27
+ # 录音参数
28
+ CHUNK_DURATION_MS = 200
29
+ MAX_RECORDING_SECONDS = 15
30
+ SILENCE_THRESHOLD = 500 # RMS 阈值
31
+ SILENCE_DURATION_MS = 1500 # 静音超过此时长则结束
32
+
33
+
34
+ def build_uri(workspace_id):
35
+ if workspace_id:
36
+ return f"wss://{workspace_id}.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference"
37
+ return "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
38
+
39
+
40
+ async def receive_results(ws):
41
+ """持续接收识别结果,返回最终聚合文本。"""
42
+ final_text = ""
43
+ last_partial = ""
44
+ while True:
45
+ try:
46
+ msg = await asyncio.wait_for(ws.recv(), timeout=5.0)
47
+ except asyncio.TimeoutError:
48
+ break
49
+
50
+ if isinstance(msg, (bytes, bytearray)):
51
+ continue
52
+
53
+ try:
54
+ data = json.loads(msg)
55
+ except Exception:
56
+ continue
57
+
58
+ header = data.get("header", {})
59
+ event = header.get("event")
60
+
61
+ if event == "result-generated":
62
+ sentence = data.get("payload", {}).get("output", {}).get("sentence", {})
63
+ text = sentence.get("text", "")
64
+ if sentence.get("sentence_end"):
65
+ final_text += text
66
+ last_partial = "" # 该句已终结,清空部分结果
67
+ else:
68
+ # 保留最新部分结果,防止长句在 sentence_end 前被截断而丢字
69
+ last_partial = text
70
+ elif event == "task-finished":
71
+ break
72
+ elif event == "task-failed":
73
+ err_code = header.get("error_code", "")
74
+ err_msg = header.get("error_message", "")
75
+ print(t(f"STT task-failed: {err_code} {err_msg}", f"STT task-failed: {err_code} {err_msg}"), file=sys.stderr)
76
+ break
77
+
78
+ # 若最后一段未收到 sentence_end,补上已识别的部分结果
79
+ return final_text + last_partial
80
+
81
+
82
+ async def stt_recognize(api_key, workspace_id, model, audio_format, sample_rate):
83
+ import websockets
84
+ import pyaudio
85
+ import numpy as np
86
+
87
+ uri = build_uri(workspace_id)
88
+ task_id = str(uuid.uuid4())
89
+ headers = {
90
+ "Authorization": f"bearer {api_key}",
91
+ "X-DashScope-DataInspection": "enable",
92
+ }
93
+
94
+ channels = 1
95
+ sample_width = 2 # 16-bit
96
+ frames_per_buffer = int(sample_rate * CHUNK_DURATION_MS / 1000)
97
+
98
+ run_task = {
99
+ "header": {
100
+ "action": "run-task",
101
+ "task_id": task_id,
102
+ "streaming": "duplex",
103
+ },
104
+ "payload": {
105
+ "task_group": "audio",
106
+ "task": "asr",
107
+ "function": "recognition",
108
+ "model": model,
109
+ "parameters": {
110
+ "format": audio_format,
111
+ "sample_rate": sample_rate,
112
+ },
113
+ "input": {},
114
+ },
115
+ }
116
+
117
+ pa = None
118
+ stream = None
119
+ final_text = ""
120
+ try:
121
+ pa = pyaudio.PyAudio()
122
+ stream = pa.open(
123
+ format=pyaudio.paInt16,
124
+ channels=channels,
125
+ rate=sample_rate,
126
+ input=True,
127
+ frames_per_buffer=frames_per_buffer,
128
+ )
129
+
130
+ async with websockets.connect(uri, additional_headers=headers, max_size=64 * 1024 * 1024, proxy=None) as ws:
131
+ await ws.send(json.dumps(run_task))
132
+
133
+ # 等 task-started
134
+ started = False
135
+ while not started:
136
+ try:
137
+ msg = await asyncio.wait_for(ws.recv(), timeout=30.0)
138
+ except asyncio.TimeoutError:
139
+ print(t("STT: 等待 task-started 超时", "STT: timeout waiting for task-started"), file=sys.stderr)
140
+ return ""
141
+ if isinstance(msg, (bytes, bytearray)):
142
+ continue
143
+ try:
144
+ data = json.loads(msg)
145
+ except Exception:
146
+ # 非 JSON 消息,跳过继续等待 task-started
147
+ continue
148
+ ev = data.get("header", {}).get("event")
149
+ if ev == "task-started":
150
+ started = True
151
+ elif ev == "task-failed":
152
+ err = data.get("header", {})
153
+ print(t(f"STT task-failed: {err.get('error_code')} {err.get('error_message')}", f"STT task-failed: {err.get('error_code')} {err.get('error_message')}"), file=sys.stderr)
154
+ return ""
155
+
156
+ # 后台接收结果
157
+ receive_task = asyncio.create_task(receive_results(ws))
158
+
159
+ # 录音并上传
160
+ silence_start = None
161
+ speech_started = False # 首次检测到声音后才开始静音计时,给用户反应宽限
162
+ total_chunks = 0
163
+ max_chunks = int(MAX_RECORDING_SECONDS * 1000 / CHUNK_DURATION_MS)
164
+
165
+ print(t("正在录音…请说话", "Recording... (speak now)"), file=sys.stderr)
166
+
167
+ while total_chunks < max_chunks:
168
+ audio_data = stream.read(frames_per_buffer, exception_on_overflow=False)
169
+
170
+ # 静音检测
171
+ samples = np.frombuffer(audio_data, dtype=np.int16)
172
+ rms = float(np.sqrt(np.mean(samples.astype(np.float64) ** 2)))
173
+ if rms < SILENCE_THRESHOLD:
174
+ if speech_started:
175
+ if silence_start is None:
176
+ silence_start = total_chunks
177
+ elif (total_chunks - silence_start) * CHUNK_DURATION_MS >= SILENCE_DURATION_MS:
178
+ break
179
+ else:
180
+ silence_start = None
181
+ speech_started = True
182
+
183
+ # 发送二进制音频帧
184
+ await ws.send(audio_data)
185
+ total_chunks += 1
186
+
187
+ # 结束任务
188
+ finish_task = {
189
+ "header": {
190
+ "action": "finish-task",
191
+ "task_id": task_id,
192
+ "streaming": "duplex",
193
+ },
194
+ "payload": {
195
+ "input": {},
196
+ },
197
+ }
198
+ await ws.send(json.dumps(finish_task))
199
+
200
+ try:
201
+ final_text = await asyncio.wait_for(receive_task, timeout=10.0)
202
+ except asyncio.TimeoutError:
203
+ final_text = ""
204
+
205
+ except Exception as e:
206
+ print(t(f"STT 错误:{e}", f"STT error: {e}"), file=sys.stderr)
207
+ traceback.print_exc(file=sys.stderr)
208
+ finally:
209
+ if stream is not None:
210
+ try:
211
+ stream.stop_stream()
212
+ stream.close()
213
+ except Exception:
214
+ pass
215
+ if pa is not None:
216
+ try:
217
+ pa.terminate()
218
+ except Exception:
219
+ pass
220
+
221
+ return final_text
222
+
223
+
224
+ def main():
225
+ api_key = os.environ.get("QWEN_STT_API_KEY", "")
226
+ workspace_id = os.environ.get("QWEN_WORKSPACE_ID", "")
227
+ model = os.environ.get("QWEN_STT_MODEL", "qwen-audio-3.0-asr-flash-streaming")
228
+ audio_format = os.environ.get("QWEN_STT_FORMAT", "pcm")
229
+ sample_rate = int(os.environ.get("QWEN_STT_SAMPLE_RATE", "16000"))
230
+
231
+ if not api_key:
232
+ print(t("STT: 未设置 QWEN_STT_API_KEY", "STT: QWEN_STT_API_KEY not set"), file=sys.stderr)
233
+ sys.exit(1)
234
+
235
+ text = asyncio.run(stt_recognize(api_key, workspace_id, model, audio_format, sample_rate))
236
+ print(text)
237
+
238
+
239
+ if __name__ == "__main__":
240
+ main()