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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/assets/blue-archive/arona/spine/arona_spr.atlas.txt +1168 -0
- package/assets/blue-archive/arona/spine/arona_spr.png +0 -0
- package/assets/blue-archive/arona/spine/arona_spr.skel +0 -0
- package/assets/blue-archive/arona/voice.mp3 +0 -0
- package/assets/blue-archive/hoshino/hoshino_spr.atlas.txt +174 -0
- package/assets/blue-archive/hoshino/hoshino_spr.json +15385 -0
- package/assets/blue-archive/hoshino/hoshino_spr.png +0 -0
- package/assets/blue-archive/hoshino/hoshino_spr.skel +0 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.atlas.txt +111 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.png +0 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.skel +0 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.atlas.txt +251 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.png +0 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.skel +0 -0
- package/assets/blue-archive/hoshino/voice.mp3 +0 -0
- package/assets/blue-archive/plana/spine/plana_spr.atlas.txt +1266 -0
- package/assets/blue-archive/plana/spine/plana_spr.png +0 -0
- package/assets/blue-archive/plana/spine/plana_spr.skel +0 -0
- package/assets/blue-archive/plana/voice.mp3 +0 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.atlas.txt +97 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.png +0 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.skel +0 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.atlas.txt +174 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.png +0 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.skel +0 -0
- package/assets/blue-archive/shiroko/shiroko_spr.atlas.txt +167 -0
- package/assets/blue-archive/shiroko/shiroko_spr.json +10702 -0
- package/assets/blue-archive/shiroko/shiroko_spr.png +0 -0
- package/assets/blue-archive/shiroko/shiroko_spr.skel +0 -0
- package/assets/blue-archive/shiroko/voice.mp3 +0 -0
- package/bin/arona.mjs +43 -0
- package/package.json +37 -0
- package/pet/agents.cjs +197 -0
- package/pet/main.cjs +462 -0
- package/pet/preload.cjs +21 -0
- package/pet/renderer/fx.html +30 -0
- package/pet/renderer/fx.js +41 -0
- package/pet/renderer/gallery.html +18 -0
- package/pet/renderer/gallery.js +31 -0
- package/pet/renderer/index.html +18 -0
- package/pet/renderer/renderer.js +382 -0
- package/pet/renderer/spine_layer.js +609 -0
- package/pet/renderer/spinetest.html +24 -0
- package/pet/renderer/spinetest.js +295 -0
- package/pet/renderer/style.css +63 -0
- package/pet/tools/gallery_capture.cjs +267 -0
- package/pet/tools/gen_sway.cjs +170 -0
- package/pet/tools/meshify_region.cjs +105 -0
- package/pet/tools/skel_inspect.cjs +112 -0
- package/pet/tools/skel_to_json.cjs +410 -0
- package/pet/tools/spine_node.cjs +69 -0
- package/pet/tools/visual_test.cjs +262 -0
- package/pet/vendor/spine/spine-canvas.js +8472 -0
- package/pet/vendor/spine/spine-webgl.js +10839 -0
- package/python/__pycache__/_i18n.cpython-313.pyc +0 -0
- package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
- package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-313.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
- package/python/__pycache__/stt.cpython-313.pyc +0 -0
- package/python/__pycache__/stt.cpython-314.pyc +0 -0
- package/python/__pycache__/tts.cpython-313.pyc +0 -0
- package/python/__pycache__/tts.cpython-314.pyc +0 -0
- package/python/__pycache__/tts_stream.cpython-313.pyc +0 -0
- package/python/__pycache__/tts_stream.cpython-314.pyc +0 -0
- package/python/__pycache__/voice_clone.cpython-314.pyc +0 -0
- package/python/_i18n.py +18 -0
- package/python/computer_use.py +190 -0
- package/python/hotkey.py +196 -0
- package/python/stt.py +240 -0
- package/python/tts_say.py +177 -0
- package/python/voice_clone.py +98 -0
- package/requirements.txt +6 -0
- package/src/agent.ts +791 -0
- package/src/agent_registry.ts +109 -0
- package/src/commands.ts +641 -0
- package/src/config.ts +245 -0
- package/src/in_memory_credentials.ts +61 -0
- package/src/index.ts +78 -0
- package/src/locale.ts +131 -0
- package/src/logo.ts +120 -0
- package/src/mcp.ts +192 -0
- package/src/memory.ts +303 -0
- package/src/pet.ts +346 -0
- package/src/renderer.ts +350 -0
- package/src/repl.ts +902 -0
- package/src/setup.ts +371 -0
- package/src/skills.ts +96 -0
- package/src/slash_menu.ts +371 -0
- package/src/slash_registry.ts +53 -0
- package/src/speaker_context.ts +41 -0
- package/src/text_split.ts +79 -0
- package/src/tools/computer_use.ts +142 -0
- package/src/tools/emotion_tool.ts +55 -0
- package/src/tools/keep_silent_tool.ts +26 -0
- package/src/tools/memory_tool.ts +24 -0
- package/src/tools/skill_tools.ts +100 -0
- package/src/tools/tavily_tools.ts +431 -0
- package/src/tools/voice_tools.ts +28 -0
- package/src/tts_stream.ts +260 -0
- package/src/tui_select.ts +146 -0
- package/src/undo.ts +451 -0
- package/src/utils/python.ts +211 -0
- package/src/utils/spawn.ts +56 -0
- package/src/voice.ts +83 -0
- package/src/voice_cli.ts +198 -0
- package/src/voices.ts +135 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/python/_i18n.py
ADDED
|
@@ -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())
|
package/python/hotkey.py
ADDED
|
@@ -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()
|