linkgravity 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 +114 -0
- package/bin/cli.js +278 -0
- package/bin/setup.js +260 -0
- package/hooks/hook.py +60 -0
- package/hooks/stop_hook.py +64 -0
- package/npm-scripts/postinstall.js +62 -0
- package/npm-scripts/prepare.js +45 -0
- package/npm-scripts/register-hook.js +182 -0
- package/npm-scripts/run-dev.js +9 -0
- package/npm-scripts/venv-paths.js +45 -0
- package/package.json +59 -0
- package/requirements.txt +13 -0
- package/src/api/server.py +48 -0
- package/src/api/ui_routes.py +340 -0
- package/src/api/voice_routes.py +94 -0
- package/src/approval/command_parser.py +62 -0
- package/src/approval/tool_formatter.py +68 -0
- package/src/cogs/general_cog.py +287 -0
- package/src/cogs/voice/__init__.py +0 -0
- package/src/cogs/voice/enrollment.py +436 -0
- package/src/cogs/voice/stt_session.py +121 -0
- package/src/cogs/voice_cog.py +573 -0
- package/src/config.py +123 -0
- package/src/core/agy_runner.py +380 -0
- package/src/core/atomic_io.py +31 -0
- package/src/core/logger.py +28 -0
- package/src/core/session_manager.py +126 -0
- package/src/handlers/message_router.py +16 -0
- package/src/handlers/thread_reply.py +165 -0
- package/src/main.py +313 -0
- package/src/messengers/base.py +105 -0
- package/src/messengers/discord_adapter.py +240 -0
- package/src/messengers/registry.py +19 -0
- package/src/services/audio_service.py +67 -0
- package/src/services/discord_helpers.py +95 -0
- package/src/services/discord_mcp.py +50 -0
- package/src/services/response.py +51 -0
- package/src/services/streaming.py +199 -0
- package/src/utils/utils.py +40 -0
- package/voice-service/index.js +1048 -0
- package/voice-service/package-lock.json +1880 -0
- package/voice-service/package.json +24 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import aiohttp
|
|
5
|
+
import discord
|
|
6
|
+
from discord import app_commands
|
|
7
|
+
from discord.ext import commands, tasks
|
|
8
|
+
|
|
9
|
+
from config import logger
|
|
10
|
+
|
|
11
|
+
from .voice.enrollment import EnrollmentManager
|
|
12
|
+
from .voice.stt_session import SttSessionTracker
|
|
13
|
+
|
|
14
|
+
NODE_VOICE_API = "http://localhost:18081"
|
|
15
|
+
# Default aiohttp timeout is 5 minutes - too long for a dead voice service.
|
|
16
|
+
NODE_REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=5)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class VoiceCog(commands.Cog):
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
bot,
|
|
23
|
+
stt,
|
|
24
|
+
tts,
|
|
25
|
+
send_agy_response,
|
|
26
|
+
agy_send,
|
|
27
|
+
stream_thinking_latest,
|
|
28
|
+
agy_start_session,
|
|
29
|
+
session_manager,
|
|
30
|
+
bot_settings,
|
|
31
|
+
save_bot_settings,
|
|
32
|
+
logger,
|
|
33
|
+
):
|
|
34
|
+
self.bot = bot
|
|
35
|
+
# Unused by the voice pipeline now (STT moved to Node); kept for other callers.
|
|
36
|
+
self.stt = stt
|
|
37
|
+
self.tts = tts
|
|
38
|
+
self.send_agy_response = send_agy_response
|
|
39
|
+
self.agy_send = agy_send
|
|
40
|
+
self.stream_thinking_latest = stream_thinking_latest
|
|
41
|
+
self.agy_start_session = agy_start_session
|
|
42
|
+
self.session_manager = session_manager
|
|
43
|
+
self.bot_settings = bot_settings
|
|
44
|
+
self.save_bot_settings = save_bot_settings
|
|
45
|
+
self.logger = logger
|
|
46
|
+
self._voice_state = {}
|
|
47
|
+
# guild_id -> in-flight handle_stt_input task, if any. A new
|
|
48
|
+
# utterance cancels the previous one so replies can't race out of order.
|
|
49
|
+
self._active_turns = {}
|
|
50
|
+
self.enrollment = EnrollmentManager(
|
|
51
|
+
bot, self._voice_state, self._play_audio, self.bot_settings, self.save_bot_settings, self.logger
|
|
52
|
+
)
|
|
53
|
+
self.stt_session = SttSessionTracker(bot, self._voice_state, self.bot_settings, self.logger)
|
|
54
|
+
self.cleanup_old_voice_files.start()
|
|
55
|
+
|
|
56
|
+
def cog_unload(self):
|
|
57
|
+
self.cleanup_old_voice_files.cancel()
|
|
58
|
+
self.enrollment.stop()
|
|
59
|
+
|
|
60
|
+
async def handle_voice_service_down(self):
|
|
61
|
+
await self.enrollment.handle_voice_service_down()
|
|
62
|
+
|
|
63
|
+
@tasks.loop(minutes=5)
|
|
64
|
+
async def cleanup_old_voice_files(self):
|
|
65
|
+
try:
|
|
66
|
+
from config import TMP_VOICE_DIR
|
|
67
|
+
|
|
68
|
+
now = time.time()
|
|
69
|
+
count = 0
|
|
70
|
+
for f in TMP_VOICE_DIR.glob("*.mp3"):
|
|
71
|
+
if now - f.stat().st_mtime > 600:
|
|
72
|
+
f.unlink(missing_ok=True)
|
|
73
|
+
count += 1
|
|
74
|
+
if count > 0:
|
|
75
|
+
self.logger.debug(f"Garbage Collector: Deleted {count} old TTS audio files.")
|
|
76
|
+
except Exception as e:
|
|
77
|
+
self.logger.error(f"Garbage Collector error: {e}")
|
|
78
|
+
|
|
79
|
+
async def active_times_autocomplete(
|
|
80
|
+
self, interaction: discord.Interaction, current: str
|
|
81
|
+
) -> list[app_commands.Choice[int]]:
|
|
82
|
+
current_val = int(self.bot_settings.get("active_timer", 60))
|
|
83
|
+
opts = []
|
|
84
|
+
if str(current_val) in current or not current:
|
|
85
|
+
opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
|
|
86
|
+
|
|
87
|
+
for v in [30, 60, 120, 300]:
|
|
88
|
+
if v != current_val and len(opts) < 25:
|
|
89
|
+
opts.append(app_commands.Choice(name=str(v), value=v))
|
|
90
|
+
return opts
|
|
91
|
+
|
|
92
|
+
async def threshold_autocomplete(
|
|
93
|
+
self, interaction: discord.Interaction, current: str
|
|
94
|
+
) -> list[app_commands.Choice[int]]:
|
|
95
|
+
current_val = int(self.bot_settings.get("voice_threshold", 3000))
|
|
96
|
+
opts = []
|
|
97
|
+
if str(current_val) in current or not current:
|
|
98
|
+
opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
|
|
99
|
+
|
|
100
|
+
for v in [1000, 2000, 3000, 5000]:
|
|
101
|
+
if v != current_val and len(opts) < 25:
|
|
102
|
+
opts.append(app_commands.Choice(name=str(v), value=v))
|
|
103
|
+
return opts
|
|
104
|
+
|
|
105
|
+
async def tts_voice_autocomplete(
|
|
106
|
+
self, interaction: discord.Interaction, current: str
|
|
107
|
+
) -> list[app_commands.Choice[str]]:
|
|
108
|
+
current_val = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
|
|
109
|
+
options = [
|
|
110
|
+
"en-US-AriaNeural",
|
|
111
|
+
"en-US-GuyNeural",
|
|
112
|
+
"en-US-AnaNeural",
|
|
113
|
+
"en-US-ChristopherNeural",
|
|
114
|
+
"en-US-EricNeural",
|
|
115
|
+
"en-US-MichelleNeural",
|
|
116
|
+
"en-US-RogerNeural",
|
|
117
|
+
"en-GB-SoniaNeural",
|
|
118
|
+
"en-GB-RyanNeural",
|
|
119
|
+
"en-AU-NatashaNeural",
|
|
120
|
+
"en-AU-WilliamNeural",
|
|
121
|
+
"ko-KR-SunHiNeural",
|
|
122
|
+
"ko-KR-InJoonNeural",
|
|
123
|
+
"ja-JP-NanamiNeural",
|
|
124
|
+
"ja-JP-KeitaNeural",
|
|
125
|
+
"fr-FR-DeniseNeural",
|
|
126
|
+
"de-DE-KatjaNeural",
|
|
127
|
+
"es-ES-ElviraNeural",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
choices = []
|
|
131
|
+
if current.lower() in current_val.lower() or not current:
|
|
132
|
+
choices.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
|
|
133
|
+
|
|
134
|
+
for opt in options:
|
|
135
|
+
if current.lower() in opt.lower() and opt != current_val and len(choices) < 25:
|
|
136
|
+
choices.append(app_commands.Choice(name=opt, value=opt))
|
|
137
|
+
return choices
|
|
138
|
+
|
|
139
|
+
async def tts_enabled_autocomplete(
|
|
140
|
+
self, interaction: discord.Interaction, current: str
|
|
141
|
+
) -> list[app_commands.Choice[str]]:
|
|
142
|
+
is_on = self.bot_settings.get("tts_enabled", True)
|
|
143
|
+
current_val = "ON" if is_on else "OFF"
|
|
144
|
+
other_val = "OFF" if is_on else "ON"
|
|
145
|
+
|
|
146
|
+
opts = []
|
|
147
|
+
if current.lower() in current_val.lower() or not current:
|
|
148
|
+
opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val.lower()))
|
|
149
|
+
if current.lower() in other_val.lower():
|
|
150
|
+
opts.append(app_commands.Choice(name=other_val, value=other_val.lower()))
|
|
151
|
+
return opts
|
|
152
|
+
|
|
153
|
+
@app_commands.command(name="join", description="Summon the bot to your current voice channel")
|
|
154
|
+
async def cmd_join(self, interaction: discord.Interaction):
|
|
155
|
+
if not isinstance(interaction.channel, discord.Thread):
|
|
156
|
+
await interaction.response.send_message(
|
|
157
|
+
"â This command can only be used inside a thread created with `/new`.", ephemeral=True
|
|
158
|
+
)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
if not self.session_manager.get_session(str(interaction.channel_id)):
|
|
162
|
+
await interaction.response.send_message(
|
|
163
|
+
"â This thread is not an active agy session. Use `/new` to start a new session.", ephemeral=True
|
|
164
|
+
)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
if not interaction.user.voice or not interaction.user.voice.channel:
|
|
168
|
+
await interaction.response.send_message("â Please join a voice channel first.", ephemeral=True)
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
vc_chan = interaction.user.voice.channel
|
|
172
|
+
guild_id = interaction.guild_id
|
|
173
|
+
|
|
174
|
+
has_wake_word = bool(self.bot_settings.get("wake_words"))
|
|
175
|
+
active_timer = self.bot_settings.get("active_timer", 60)
|
|
176
|
+
|
|
177
|
+
if has_wake_word:
|
|
178
|
+
wake_words_raw = self.bot_settings["wake_words"]
|
|
179
|
+
ww_list = [f"`{w.strip()}`" for w in wake_words_raw.split(",") if w.strip()]
|
|
180
|
+
ww_str = ", ".join(ww_list[:-1]) + f", or {ww_list[-1]}" if len(ww_list) > 1 else ww_list[0]
|
|
181
|
+
msg = (
|
|
182
|
+
f"đ¤ Connected to `{vc_chan.name}`.\n"
|
|
183
|
+
f"đĄ Say {ww_str} to activate me. Once awake, I'll keep listening for {active_timer} seconds after each interaction.\n"
|
|
184
|
+
f"âď¸ You can customize settings using `/sound`."
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
msg = (
|
|
188
|
+
f"đ¤ Connected to `{vc_chan.name}`.\n"
|
|
189
|
+
f"đď¸ No wake word is set up yet, so I can't hear you yet - run `/sound wake_word:<word>` "
|
|
190
|
+
f"and say your chosen word a few times to register it in your voice."
|
|
191
|
+
)
|
|
192
|
+
await interaction.response.send_message(msg)
|
|
193
|
+
|
|
194
|
+
self.stt_session.clear_active_window(str(guild_id)) # don't inherit a window left open by a prior /join
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
|
|
198
|
+
try:
|
|
199
|
+
await session.post(f"{NODE_VOICE_API}/leave", json={"guild_id": str(guild_id)})
|
|
200
|
+
except Exception as e:
|
|
201
|
+
self.logger.warning(f"Failed to call /leave before /join: {e}")
|
|
202
|
+
|
|
203
|
+
resp = await session.post(
|
|
204
|
+
f"{NODE_VOICE_API}/join", json={"guild_id": str(guild_id), "channel_id": str(vc_chan.id)}
|
|
205
|
+
)
|
|
206
|
+
data = await resp.json()
|
|
207
|
+
if data.get("success"):
|
|
208
|
+
self._voice_state[str(guild_id)] = interaction.channel_id
|
|
209
|
+
|
|
210
|
+
if has_wake_word:
|
|
211
|
+
if self.bot_settings.get("tts_enabled", True):
|
|
212
|
+
welcome_audio = await self.tts("Yes, I am listening.")
|
|
213
|
+
if welcome_audio:
|
|
214
|
+
await self._play_audio(str(guild_id), welcome_audio)
|
|
215
|
+
elif self.bot_settings.get("tts_enabled", True):
|
|
216
|
+
prompt_audio = await self.tts(
|
|
217
|
+
"No wake word is set up yet. Please use the sound command to set one."
|
|
218
|
+
)
|
|
219
|
+
if prompt_audio:
|
|
220
|
+
await self._play_audio(str(guild_id), prompt_audio)
|
|
221
|
+
else:
|
|
222
|
+
await interaction.channel.send(f"â ď¸ Node.js integration failed: {data.get('error')}")
|
|
223
|
+
except aiohttp.ClientConnectorError:
|
|
224
|
+
self.logger.error("Voice service is unreachable (not started yet, or crashed)")
|
|
225
|
+
await interaction.channel.send(
|
|
226
|
+
"â ď¸ The voice service isn't reachable right now - it may still be starting up "
|
|
227
|
+
"(wait a few seconds and try `/join` again), or it may have crashed (check `lgy logs`)."
|
|
228
|
+
)
|
|
229
|
+
except aiohttp.ClientError as e:
|
|
230
|
+
self.logger.error(f"Node.js connection error in /join: {e}")
|
|
231
|
+
await interaction.channel.send(f"â ď¸ Node.js connection error: {e}")
|
|
232
|
+
except asyncio.TimeoutError:
|
|
233
|
+
self.logger.error("Timeout connecting to Node.js backend")
|
|
234
|
+
await interaction.channel.send("â ď¸ Timeout connecting to voice backend.")
|
|
235
|
+
|
|
236
|
+
@app_commands.command(
|
|
237
|
+
name="sound", description="Configure voice settings (Wake word, active time, threshold, TTS voice, TTS on/off)"
|
|
238
|
+
)
|
|
239
|
+
@app_commands.describe(
|
|
240
|
+
wake_word="The single word/phrase that wakes the bot (recorded in your voice)",
|
|
241
|
+
active_times="Duration in seconds the bot stays awake",
|
|
242
|
+
threshold="Voice volume sensitivity (1000~10000)",
|
|
243
|
+
tts_voice="Select the AI TTS voice",
|
|
244
|
+
tts_enabled="Turn Text-to-Speech ON or OFF",
|
|
245
|
+
)
|
|
246
|
+
@app_commands.autocomplete(
|
|
247
|
+
active_times=active_times_autocomplete,
|
|
248
|
+
threshold=threshold_autocomplete,
|
|
249
|
+
tts_voice=tts_voice_autocomplete,
|
|
250
|
+
tts_enabled=tts_enabled_autocomplete,
|
|
251
|
+
)
|
|
252
|
+
async def cmd_voice(
|
|
253
|
+
self,
|
|
254
|
+
interaction: discord.Interaction,
|
|
255
|
+
wake_word: str = None,
|
|
256
|
+
active_times: int = None,
|
|
257
|
+
threshold: int = None,
|
|
258
|
+
tts_voice: str = None,
|
|
259
|
+
tts_enabled: str = None,
|
|
260
|
+
):
|
|
261
|
+
import aiohttp
|
|
262
|
+
|
|
263
|
+
if (
|
|
264
|
+
wake_word is None
|
|
265
|
+
and active_times is None
|
|
266
|
+
and threshold is None
|
|
267
|
+
and tts_voice is None
|
|
268
|
+
and tts_enabled is None
|
|
269
|
+
):
|
|
270
|
+
curr_wake = self.bot_settings.get("wake_words", "None")
|
|
271
|
+
curr_timer = self.bot_settings.get("active_timer", 60)
|
|
272
|
+
curr_thresh = self.bot_settings.get("voice_threshold", 3000)
|
|
273
|
+
curr_tts = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
|
|
274
|
+
curr_tts_on = "ON" if self.bot_settings.get("tts_enabled", True) else "OFF"
|
|
275
|
+
|
|
276
|
+
embed = discord.Embed(title="âď¸ Current Voice Settings", color=0x3498DB)
|
|
277
|
+
embed.add_field(name="đď¸ Wake Word", value=f"`{curr_wake}`", inline=False)
|
|
278
|
+
embed.add_field(name="âąď¸ Active Time", value=f"`{curr_timer}s`", inline=False)
|
|
279
|
+
embed.add_field(name="đ Threshold", value=f"`{curr_thresh}`", inline=False)
|
|
280
|
+
embed.add_field(name="đŁď¸ TTS Voice", value=f"`{curr_tts}`", inline=False)
|
|
281
|
+
embed.add_field(name="đ TTS Enabled", value=f"`{curr_tts_on}`", inline=False)
|
|
282
|
+
return await interaction.response.send_message(embed=embed)
|
|
283
|
+
|
|
284
|
+
updated = []
|
|
285
|
+
wake_word_pending = False
|
|
286
|
+
if wake_word is not None:
|
|
287
|
+
# Deferred: wake_words only gets set once 5 samples are
|
|
288
|
+
# recorded and confirmed (see EnrollmentManager), not here.
|
|
289
|
+
wake_word_pending = True
|
|
290
|
+
if active_times is not None:
|
|
291
|
+
self.bot_settings["active_timer"] = active_times
|
|
292
|
+
updated.append(f"âąď¸ Active Timer: `{active_times}s`")
|
|
293
|
+
if threshold is not None:
|
|
294
|
+
self.bot_settings["voice_threshold"] = threshold
|
|
295
|
+
updated.append(f"đ Threshold: `{threshold}`")
|
|
296
|
+
try:
|
|
297
|
+
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
|
|
298
|
+
await session.post(f"{NODE_VOICE_API}/set_config", json={"voice_threshold": threshold})
|
|
299
|
+
except aiohttp.ClientError as e:
|
|
300
|
+
self.logger.warning(f"Node.js sync failed for {interaction.guild_id}: {e}")
|
|
301
|
+
updated.append(f"(â ď¸ Node.js Sync Failed: {e})")
|
|
302
|
+
except asyncio.TimeoutError:
|
|
303
|
+
self.logger.warning(f"Node.js sync timeout for {interaction.guild_id}")
|
|
304
|
+
updated.append("(â ď¸ Node.js Sync Timeout)")
|
|
305
|
+
if tts_voice is not None:
|
|
306
|
+
self.bot_settings["tts_voice"] = tts_voice
|
|
307
|
+
updated.append(f"đŁď¸ TTS Voice: `{tts_voice}`")
|
|
308
|
+
if tts_enabled is not None:
|
|
309
|
+
is_on = tts_enabled.lower() == "on"
|
|
310
|
+
self.bot_settings["tts_enabled"] = is_on
|
|
311
|
+
updated.append(f"đ TTS Enabled: `{'ON' if is_on else 'OFF'}`")
|
|
312
|
+
|
|
313
|
+
self.save_bot_settings(self.bot_settings)
|
|
314
|
+
|
|
315
|
+
if wake_word_pending:
|
|
316
|
+
# start_wake_word_recording owns the interaction reply (error or "say it now").
|
|
317
|
+
started = await self.enrollment.start_wake_word_recording(interaction, wake_word)
|
|
318
|
+
if updated:
|
|
319
|
+
embed = discord.Embed(
|
|
320
|
+
title="âď¸ Other Voice Settings Updated", description="\n".join(updated), color=0x3498DB
|
|
321
|
+
)
|
|
322
|
+
if started:
|
|
323
|
+
await interaction.channel.send(embed=embed)
|
|
324
|
+
else:
|
|
325
|
+
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
embed = discord.Embed(title="âď¸ Voice Settings Updated", description="\n".join(updated), color=0x3498DB)
|
|
329
|
+
await interaction.response.send_message(embed=embed)
|
|
330
|
+
|
|
331
|
+
@app_commands.command(name="leave", description="Make the bot leave the voice channel")
|
|
332
|
+
async def cmd_leave(self, interaction: discord.Interaction):
|
|
333
|
+
guild_id = interaction.guild_id
|
|
334
|
+
await interaction.response.send_message("đ Disconnected from voice channel.")
|
|
335
|
+
try:
|
|
336
|
+
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
|
|
337
|
+
await session.post(f"{NODE_VOICE_API}/leave", json={"guild_id": str(guild_id)})
|
|
338
|
+
if str(guild_id) in self._voice_state:
|
|
339
|
+
del self._voice_state[str(guild_id)]
|
|
340
|
+
except aiohttp.ClientError as e:
|
|
341
|
+
self.logger.warning(f"Websocket connection closed with error: {e}")
|
|
342
|
+
except asyncio.CancelledError:
|
|
343
|
+
self.logger.debug("Websocket listener task cancelled.")
|
|
344
|
+
|
|
345
|
+
async def handle_enroll_sample(self, user_id: str, audio_bytes: bytes):
|
|
346
|
+
await self.enrollment.handle_enroll_sample(user_id, audio_bytes)
|
|
347
|
+
|
|
348
|
+
async def _play_audio(self, guild_id: str, audio_bytes: bytes, suppress_active_window: bool = False):
|
|
349
|
+
"""Sends TTS bytes to Node directly (no temp file). suppress_active_window=True
|
|
350
|
+
(enrollment playback) stops it from extending the awake window."""
|
|
351
|
+
try:
|
|
352
|
+
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
|
|
353
|
+
await session.post(
|
|
354
|
+
f"{NODE_VOICE_API}/play",
|
|
355
|
+
params={"guild_id": guild_id, "suppress_active_window": str(suppress_active_window).lower()},
|
|
356
|
+
data=audio_bytes,
|
|
357
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
358
|
+
)
|
|
359
|
+
except aiohttp.ClientError as e:
|
|
360
|
+
self.logger.error(f"Node.js play error (network): {e}")
|
|
361
|
+
except asyncio.TimeoutError:
|
|
362
|
+
self.logger.error("Node.js play error: Timeout")
|
|
363
|
+
|
|
364
|
+
async def handle_stt_input(self, data):
|
|
365
|
+
try:
|
|
366
|
+
guild_id = data.get("guild_id")
|
|
367
|
+
user_id = data.get("user_id")
|
|
368
|
+
# Node runs STT itself before calling this endpoint, so this is
|
|
369
|
+
# already-recognized text, not raw audio - keeps STT off every VAD hit.
|
|
370
|
+
text = data.get("text")
|
|
371
|
+
|
|
372
|
+
thread_id = self._voice_state.get(str(guild_id))
|
|
373
|
+
self.logger.debug(f"STT handler: guild_id={guild_id}, thread_id={thread_id}")
|
|
374
|
+
if not thread_id:
|
|
375
|
+
self.logger.debug("STT: thread_id is None, skipping")
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
thread = self.bot.get_channel(int(thread_id))
|
|
379
|
+
self.logger.debug(f"STT: thread={thread}")
|
|
380
|
+
if not thread:
|
|
381
|
+
self.logger.debug("STT: thread is None, skipping")
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
if not text:
|
|
385
|
+
self.logger.debug("STT: silence/no speech detected, skipping")
|
|
386
|
+
await self.stt_session.clear_partial_msg(str(guild_id))
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
import difflib
|
|
390
|
+
import re
|
|
391
|
+
|
|
392
|
+
# Wake detection is Node's audio-based Rustpotter detector's job.
|
|
393
|
+
# No text-similarity fallback: an unenrolled user (or a failed
|
|
394
|
+
# .rpw build - see EnrollmentManager._build_rustpotter_reference)
|
|
395
|
+
# just can't wake the bot until that's fixed.
|
|
396
|
+
is_waking_up = bool(data.get("wake_confirmed"))
|
|
397
|
+
matched_wake_word = data.get("matched_wake_word")
|
|
398
|
+
is_active = self.stt_session.is_active(str(guild_id))
|
|
399
|
+
|
|
400
|
+
if not is_active and not is_waking_up:
|
|
401
|
+
self.logger.debug(f"STT: ignored (sleeping): {text}")
|
|
402
|
+
await self.stt_session.clear_partial_msg(str(guild_id))
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
text_to_ai = text
|
|
406
|
+
prefix_similarity = None
|
|
407
|
+
if matched_wake_word:
|
|
408
|
+
pattern = re.compile(re.escape(matched_wake_word) + r"[ěěź]?\s*[^\w\s]*\s*", re.IGNORECASE)
|
|
409
|
+
text_to_ai = pattern.sub("", text_to_ai, count=1).strip()
|
|
410
|
+
|
|
411
|
+
if text_to_ai == text:
|
|
412
|
+
# Exact match failed (STT can mistranscribe); try a fuzzy phonetic prefix match.
|
|
413
|
+
words = text.split()
|
|
414
|
+
wake_word_count = max(1, len(matched_wake_word.split()))
|
|
415
|
+
if words:
|
|
416
|
+
prefix = re.sub(r"[^\wę°-íŁ]", "", "".join(words[:wake_word_count]))
|
|
417
|
+
wake_clean = re.sub(r"[^\wę°-íŁ]", "", matched_wake_word)
|
|
418
|
+
try:
|
|
419
|
+
from jamo import h2j, j2hcj
|
|
420
|
+
|
|
421
|
+
prefix_similarity = difflib.SequenceMatcher(
|
|
422
|
+
None, j2hcj(h2j(prefix)), j2hcj(h2j(wake_clean))
|
|
423
|
+
).ratio()
|
|
424
|
+
except Exception:
|
|
425
|
+
prefix_similarity = difflib.SequenceMatcher(None, prefix, wake_clean).ratio()
|
|
426
|
+
if prefix_similarity >= 0.6:
|
|
427
|
+
text_to_ai = " ".join(words[wake_word_count:]).strip()
|
|
428
|
+
|
|
429
|
+
# No text resemblance at all + short wake word (less acoustic
|
|
430
|
+
# signal, more false-wakes) -> require a closer match to trust it.
|
|
431
|
+
wake_syllables = len(re.sub(r"[^\wę°-íŁ]", "", matched_wake_word or ""))
|
|
432
|
+
min_prefix_similarity = 0.55 if wake_syllables <= 2 else 0.35
|
|
433
|
+
if is_waking_up and prefix_similarity is not None and prefix_similarity < min_prefix_similarity:
|
|
434
|
+
self.logger.info(
|
|
435
|
+
f"STT: ignoring wake - '{text}' doesn't resemble '{matched_wake_word}' "
|
|
436
|
+
f"(prefix similarity {prefix_similarity:.2f}, needed {min_prefix_similarity:.2f})"
|
|
437
|
+
)
|
|
438
|
+
await self.stt_session.clear_partial_msg(str(guild_id))
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
self.logger.debug(f"STT recognized: {text} -> AI: {text_to_ai}")
|
|
442
|
+
|
|
443
|
+
# A previous in-flight turn for this guild is now stale - cancel
|
|
444
|
+
# it, kill its agy process (like /stop), and stop its playback,
|
|
445
|
+
# rather than letting two turns race to completion.
|
|
446
|
+
prev_task = self._active_turns.get(str(guild_id))
|
|
447
|
+
if prev_task and not prev_task.done():
|
|
448
|
+
prev_task.cancel()
|
|
449
|
+
|
|
450
|
+
from core.agy_runner import stop_active_process
|
|
451
|
+
|
|
452
|
+
stop_active_process(str(thread_id))
|
|
453
|
+
|
|
454
|
+
prev_session = self.session_manager.get_session(str(thread_id))
|
|
455
|
+
if prev_session:
|
|
456
|
+
self.session_manager.set_session(
|
|
457
|
+
str(thread_id), {**prev_session, "status": "pending", "conversation_id": None}
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
|
|
462
|
+
await session.post(f"{NODE_VOICE_API}/interrupt", json={"guild_id": guild_id})
|
|
463
|
+
except aiohttp.ClientError as e:
|
|
464
|
+
self.logger.warning(f"Failed to interrupt playback for guild {guild_id}: {e}")
|
|
465
|
+
self._active_turns[str(guild_id)] = asyncio.current_task()
|
|
466
|
+
|
|
467
|
+
user = self.bot.get_user(int(user_id))
|
|
468
|
+
if not user:
|
|
469
|
+
try:
|
|
470
|
+
user = await self.bot.fetch_user(int(user_id))
|
|
471
|
+
except discord.NotFound:
|
|
472
|
+
pass
|
|
473
|
+
except discord.HTTPException as e:
|
|
474
|
+
self.logger.warning(f"fetch_user failed for {user_id}: {e}")
|
|
475
|
+
username = user.display_name if user else f"User {user_id}"
|
|
476
|
+
await self.stt_session.finalize_partial_msg(str(guild_id), thread, f"đ¤ **{username}**: {text}")
|
|
477
|
+
|
|
478
|
+
if not text_to_ai:
|
|
479
|
+
self.logger.debug("STT: isolated wake word handled via direct TTS")
|
|
480
|
+
if self.bot_settings.get("tts_enabled", True):
|
|
481
|
+
audio_reply = await self.tts("Yes, I am listening.")
|
|
482
|
+
if audio_reply:
|
|
483
|
+
await self._play_audio(str(guild_id), audio_reply)
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
sess = self.session_manager.get_session(str(thread_id))
|
|
487
|
+
if not sess:
|
|
488
|
+
sess = {"status": "pending", "user_id": str(user_id)}
|
|
489
|
+
self.session_manager.set_session(str(thread_id), sess)
|
|
490
|
+
|
|
491
|
+
conv_id = sess.get("conversation_id")
|
|
492
|
+
pa = self.session_manager.get_pending_approval_by_conv(conv_id) if conv_id else None
|
|
493
|
+
|
|
494
|
+
if conv_id and pa and not pa.done():
|
|
495
|
+
app_type = self.session_manager.get_pending_approval_type_by_conv(conv_id)
|
|
496
|
+
if app_type == "ask_question":
|
|
497
|
+
pa.set_result(text)
|
|
498
|
+
await thread.send(f'â
*Voice Answer Received: "{text}"*')
|
|
499
|
+
return
|
|
500
|
+
|
|
501
|
+
from services.discord_helpers import check_approval_intent
|
|
502
|
+
|
|
503
|
+
intent = check_approval_intent(text)
|
|
504
|
+
if intent == "allow":
|
|
505
|
+
pa.set_result("allow")
|
|
506
|
+
await thread.send("â
*Voice Approval Received*")
|
|
507
|
+
return
|
|
508
|
+
elif intent == "reject":
|
|
509
|
+
pa.set_result("reject")
|
|
510
|
+
await thread.send("â *Voice Rejection Received*")
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
async with thread.typing():
|
|
514
|
+
self.logger.debug(f"đ¤ [{username} ({user_id})] said: {text}")
|
|
515
|
+
|
|
516
|
+
sess = self.session_manager.get_session(str(thread_id))
|
|
517
|
+
is_new_session = sess.get("status") == "pending" or not conv_id
|
|
518
|
+
|
|
519
|
+
queue = asyncio.Queue()
|
|
520
|
+
self.session_manager.register_queue(str(thread.id), queue)
|
|
521
|
+
|
|
522
|
+
ctx = {"status_msg": None}
|
|
523
|
+
consume_task = asyncio.create_task(self.stream_thinking_latest(thread, ctx, queue))
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
if is_new_session:
|
|
527
|
+
raw_ans, new_conv_id = await self.agy_start_session(
|
|
528
|
+
text_to_ai,
|
|
529
|
+
model=sess.get("model"),
|
|
530
|
+
stream_queue=queue,
|
|
531
|
+
thread_id=str(thread_id),
|
|
532
|
+
cwd=sess.get("cwd"),
|
|
533
|
+
)
|
|
534
|
+
sess["conversation_id"] = new_conv_id
|
|
535
|
+
sess["status"] = "active"
|
|
536
|
+
self.session_manager.set_session(str(thread_id), sess)
|
|
537
|
+
conv_id = new_conv_id
|
|
538
|
+
else:
|
|
539
|
+
logger.debug("Voice: calling agy_send...")
|
|
540
|
+
raw_ans = await self.agy_send(
|
|
541
|
+
conv_id, text_to_ai, model=sess.get("model"), thread_id=str(thread_id), stream_queue=queue
|
|
542
|
+
)
|
|
543
|
+
logger.debug("Voice: agy_send finished")
|
|
544
|
+
|
|
545
|
+
final_text = raw_ans
|
|
546
|
+
finally:
|
|
547
|
+
logger.debug("Voice: sending __END__ and waiting for consume_task")
|
|
548
|
+
await queue.put(("__END__", True))
|
|
549
|
+
await consume_task
|
|
550
|
+
|
|
551
|
+
logger.debug("Voice: calling send_agy_response...")
|
|
552
|
+
response_text = ctx.get("final_text", final_text)
|
|
553
|
+
await self.send_agy_response(
|
|
554
|
+
thread, response_text, sess, ctx=ctx, start_time=time.time(), conv_id=conv_id
|
|
555
|
+
)
|
|
556
|
+
logger.debug("Voice: send_agy_response finished")
|
|
557
|
+
|
|
558
|
+
if not self.bot_settings.get("tts_enabled", True):
|
|
559
|
+
self.stt_session.extend_active_window(str(guild_id))
|
|
560
|
+
|
|
561
|
+
except asyncio.CancelledError:
|
|
562
|
+
self.logger.debug(f"handle_stt_input task cancelled for thread {thread_id}")
|
|
563
|
+
except Exception as e:
|
|
564
|
+
self.logger.exception(f"Unhandled error in handle_stt_input: {e}")
|
|
565
|
+
|
|
566
|
+
def mark_tts_finished(self, guild_id: str):
|
|
567
|
+
self.stt_session.mark_tts_finished(guild_id)
|
|
568
|
+
|
|
569
|
+
async def handle_stt_partial(self, data: dict):
|
|
570
|
+
await self.stt_session.handle_stt_partial(data)
|
|
571
|
+
|
|
572
|
+
async def cancel_stt_partial(self, guild_id: str):
|
|
573
|
+
await self.stt_session.cancel_stt_partial(guild_id)
|