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,436 @@
|
|
|
1
|
+
"""Wake-word enrollment: /sound's recording flow to the confirmed .rpw reference."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import io
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
import wave
|
|
8
|
+
from array import array as pyarray
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
import discord
|
|
12
|
+
from discord.ext import tasks
|
|
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 RecordingPromptView(discord.ui.View):
|
|
20
|
+
|
|
21
|
+
def __init__(self, manager: "EnrollmentManager", user_id: str):
|
|
22
|
+
super().__init__(timeout=120)
|
|
23
|
+
self.manager = manager
|
|
24
|
+
self.user_id = user_id
|
|
25
|
+
|
|
26
|
+
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
|
27
|
+
if str(interaction.user.id) != self.user_id:
|
|
28
|
+
await interaction.response.send_message("This isn't your recording session.", ephemeral=True)
|
|
29
|
+
return False
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
@discord.ui.button(label="❌ Cancel", style=discord.ButtonStyle.danger)
|
|
33
|
+
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
34
|
+
session = self.manager._enrollment.get(self.user_id)
|
|
35
|
+
word = session["word"] if session else "?"
|
|
36
|
+
self.stop()
|
|
37
|
+
for child in self.children:
|
|
38
|
+
child.disabled = True
|
|
39
|
+
await interaction.response.edit_message(
|
|
40
|
+
content=f"❌ Cancelled wake-word setup for **{word}** - nothing was saved.", view=self
|
|
41
|
+
)
|
|
42
|
+
await self.manager.cancel_enrollment(self.user_id, edit_status=False)
|
|
43
|
+
|
|
44
|
+
async def on_timeout(self):
|
|
45
|
+
# cleanup_stale_enrollments handles the actual expiry/status edit.
|
|
46
|
+
for child in self.children:
|
|
47
|
+
child.disabled = True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SampleConfirmView(discord.ui.View):
|
|
51
|
+
|
|
52
|
+
def __init__(self, manager: "EnrollmentManager", user_id: str):
|
|
53
|
+
super().__init__(timeout=60)
|
|
54
|
+
self.manager = manager
|
|
55
|
+
self.user_id = user_id
|
|
56
|
+
|
|
57
|
+
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
|
58
|
+
if str(interaction.user.id) != self.user_id:
|
|
59
|
+
await interaction.response.send_message("This isn't your recording session.", ephemeral=True)
|
|
60
|
+
return False
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
async def _disable(self, interaction: discord.Interaction, content: str | None = None):
|
|
64
|
+
self.stop()
|
|
65
|
+
for child in self.children:
|
|
66
|
+
child.disabled = True
|
|
67
|
+
if content is not None:
|
|
68
|
+
await interaction.response.edit_message(content=content, view=self)
|
|
69
|
+
else:
|
|
70
|
+
await interaction.response.edit_message(view=self)
|
|
71
|
+
|
|
72
|
+
@discord.ui.button(label="✅ Keep", style=discord.ButtonStyle.success)
|
|
73
|
+
async def keep(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
74
|
+
await self._disable(interaction)
|
|
75
|
+
await self.manager.resolve_sample_confirmation(self.user_id, keep=True)
|
|
76
|
+
|
|
77
|
+
@discord.ui.button(label="🔁 Re-record", style=discord.ButtonStyle.secondary)
|
|
78
|
+
async def redo(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
79
|
+
await self._disable(interaction)
|
|
80
|
+
await self.manager.resolve_sample_confirmation(self.user_id, keep=False)
|
|
81
|
+
|
|
82
|
+
@discord.ui.button(label="❌ Cancel", style=discord.ButtonStyle.danger)
|
|
83
|
+
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
84
|
+
session = self.manager._enrollment.get(self.user_id)
|
|
85
|
+
word = session["word"] if session else "?"
|
|
86
|
+
await self._disable(interaction, content=f"❌ Cancelled wake-word setup for **{word}** - nothing was saved.")
|
|
87
|
+
await self.manager.cancel_enrollment(self.user_id, edit_status=False)
|
|
88
|
+
|
|
89
|
+
async def on_timeout(self):
|
|
90
|
+
for child in self.children:
|
|
91
|
+
child.disabled = True
|
|
92
|
+
await self.manager.resolve_sample_confirmation(self.user_id, keep=False, timed_out=True)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class EnrollmentManager:
|
|
96
|
+
"""Owns /sound's recording flow - see _commit_enrollment."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, bot, voice_state: dict, play_audio, bot_settings: dict, save_bot_settings, logger):
|
|
99
|
+
self.bot = bot
|
|
100
|
+
self._voice_state = voice_state # shared with VoiceCog
|
|
101
|
+
self._play_audio = play_audio
|
|
102
|
+
self.bot_settings = bot_settings
|
|
103
|
+
self.save_bot_settings = save_bot_settings
|
|
104
|
+
self.logger = logger
|
|
105
|
+
# user_id -> {word, accepted_samples, pending_sample,
|
|
106
|
+
# awaiting_confirmation, needed, guild_id, started_at,
|
|
107
|
+
# last_activity, status_msg}
|
|
108
|
+
self._enrollment = {}
|
|
109
|
+
self.cleanup_stale_enrollments.start()
|
|
110
|
+
|
|
111
|
+
def stop(self):
|
|
112
|
+
self.cleanup_stale_enrollments.cancel()
|
|
113
|
+
|
|
114
|
+
def is_enrolling(self, user_id: str) -> bool:
|
|
115
|
+
return user_id in self._enrollment
|
|
116
|
+
|
|
117
|
+
async def handle_voice_service_down(self):
|
|
118
|
+
"""Called when Node dies."""
|
|
119
|
+
stale_user_ids = list(self._enrollment.keys())
|
|
120
|
+
for uid in stale_user_ids:
|
|
121
|
+
session = self._enrollment.pop(uid, None)
|
|
122
|
+
if not session:
|
|
123
|
+
continue
|
|
124
|
+
await self._update_status(
|
|
125
|
+
session,
|
|
126
|
+
f"🔌 Lost the connection to the voice service mid-recording for **{session['word']}** - "
|
|
127
|
+
f"nothing was saved. Run `/sound` again once I'm back.",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@tasks.loop(seconds=30)
|
|
131
|
+
async def cleanup_stale_enrollments(self):
|
|
132
|
+
now = time.time()
|
|
133
|
+
stale_user_ids = [uid for uid, s in self._enrollment.items() if now - s["last_activity"] > 120]
|
|
134
|
+
for uid in stale_user_ids:
|
|
135
|
+
session = self._enrollment.pop(uid, None)
|
|
136
|
+
if not session:
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as http:
|
|
140
|
+
await http.post(f"{NODE_VOICE_API}/enroll_stop", json={"user_id": uid})
|
|
141
|
+
except aiohttp.ClientError as e:
|
|
142
|
+
self.logger.warning(f"Failed to stop stale enrollment forwarding for {uid}: {e}")
|
|
143
|
+
await self._update_status(
|
|
144
|
+
session,
|
|
145
|
+
f"⌛ Wake-word recording for `{session['word']}` timed out - "
|
|
146
|
+
f"nothing was saved. Run `/sound wake_word:...` again if you want to retry.",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
async def cancel_enrollment(self, user_id: str, edit_status: bool = True):
|
|
150
|
+
"""edit_status=False when the caller already edited the message itself."""
|
|
151
|
+
session = self._enrollment.pop(user_id, None)
|
|
152
|
+
if not session:
|
|
153
|
+
return
|
|
154
|
+
try:
|
|
155
|
+
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as http:
|
|
156
|
+
await http.post(f"{NODE_VOICE_API}/enroll_stop", json={"user_id": user_id})
|
|
157
|
+
except aiohttp.ClientError as e:
|
|
158
|
+
self.logger.warning(f"Failed to stop enrollment forwarding for {user_id}: {e}")
|
|
159
|
+
if edit_status:
|
|
160
|
+
await self._update_status(
|
|
161
|
+
session, f"❌ Cancelled wake-word setup for **{session['word']}** - nothing was saved."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def _update_status(self, session: dict, content: str, view: discord.ui.View | None = None):
|
|
165
|
+
msg = session.get("status_msg")
|
|
166
|
+
if msg:
|
|
167
|
+
try:
|
|
168
|
+
await msg.edit(content=content, view=view)
|
|
169
|
+
return
|
|
170
|
+
except discord.NotFound:
|
|
171
|
+
session["status_msg"] = None
|
|
172
|
+
except discord.HTTPException as e:
|
|
173
|
+
self.logger.warning(f"Failed to edit enrollment status message: {e}")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
thread_id = self._voice_state.get(session["guild_id"])
|
|
177
|
+
thread = self.bot.get_channel(int(thread_id)) if thread_id else None
|
|
178
|
+
if not thread:
|
|
179
|
+
return
|
|
180
|
+
try:
|
|
181
|
+
session["status_msg"] = await thread.send(content, view=view)
|
|
182
|
+
except discord.HTTPException as e:
|
|
183
|
+
self.logger.warning(f"Failed to send enrollment status message: {e}")
|
|
184
|
+
|
|
185
|
+
async def start_wake_word_recording(self, interaction: discord.Interaction, word: str) -> bool:
|
|
186
|
+
"""Called by /sound's wake_word param."""
|
|
187
|
+
guild_id = interaction.guild_id
|
|
188
|
+
user_id = str(interaction.user.id)
|
|
189
|
+
|
|
190
|
+
if not self._voice_state.get(str(guild_id)):
|
|
191
|
+
await interaction.response.send_message(
|
|
192
|
+
"❌ Use `/join` first so I'm listening in this channel before setting a wake word.",
|
|
193
|
+
ephemeral=True,
|
|
194
|
+
)
|
|
195
|
+
return False
|
|
196
|
+
if not interaction.user.voice or not interaction.user.voice.channel:
|
|
197
|
+
await interaction.response.send_message(
|
|
198
|
+
"❌ Join a voice channel first - the wake word is recorded in your own voice.", ephemeral=True
|
|
199
|
+
)
|
|
200
|
+
return False
|
|
201
|
+
if user_id in self._enrollment:
|
|
202
|
+
await interaction.response.send_message(
|
|
203
|
+
"⚠️ You already have a wake-word recording in progress - say the word, or cancel it first.",
|
|
204
|
+
ephemeral=True,
|
|
205
|
+
)
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
word_clean = word.strip()
|
|
209
|
+
if not word_clean:
|
|
210
|
+
await interaction.response.send_message("❌ Give me an actual word.", ephemeral=True)
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
session = {
|
|
214
|
+
"word": word_clean,
|
|
215
|
+
"accepted_samples": [],
|
|
216
|
+
"pending_sample": None,
|
|
217
|
+
"awaiting_confirmation": False,
|
|
218
|
+
"needed": 5,
|
|
219
|
+
"guild_id": str(guild_id),
|
|
220
|
+
"started_at": time.time(),
|
|
221
|
+
"last_activity": time.time(),
|
|
222
|
+
"status_msg": None,
|
|
223
|
+
}
|
|
224
|
+
self._enrollment[user_id] = session
|
|
225
|
+
try:
|
|
226
|
+
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session_http:
|
|
227
|
+
await session_http.post(f"{NODE_VOICE_API}/enroll_start", json={"user_id": user_id})
|
|
228
|
+
except aiohttp.ClientError as e:
|
|
229
|
+
del self._enrollment[user_id]
|
|
230
|
+
await interaction.response.send_message(f"⚠️ Couldn't reach the voice service: {e}", ephemeral=True)
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
await interaction.response.send_message(
|
|
234
|
+
f"🎙️ **{interaction.user.display_name}** is setting the wake word to **{word_clean}**.\n"
|
|
235
|
+
f"I'll play back each recording so you can re-record it if it's noisy. "
|
|
236
|
+
f"Need 5 confirmed samples; nothing is saved until then."
|
|
237
|
+
)
|
|
238
|
+
await self._update_status(
|
|
239
|
+
session,
|
|
240
|
+
f"🎙️ **1/5** - say **{word_clean}** now.",
|
|
241
|
+
view=RecordingPromptView(self, user_id),
|
|
242
|
+
)
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
async def handle_enroll_sample(self, user_id: str, audio_bytes: bytes):
|
|
246
|
+
"""Called via /enroll_sample for each captured sample."""
|
|
247
|
+
session = self._enrollment.get(user_id)
|
|
248
|
+
if not session:
|
|
249
|
+
return # stray sample - recording already finished/cancelled/expired
|
|
250
|
+
|
|
251
|
+
if session.get("awaiting_confirmation"):
|
|
252
|
+
return # they're mid-playback/button-prompt for a previous sample - ignore stray audio
|
|
253
|
+
|
|
254
|
+
session["last_activity"] = time.time()
|
|
255
|
+
step = len(session["accepted_samples"]) + 1
|
|
256
|
+
|
|
257
|
+
if len(audio_bytes) < 8000:
|
|
258
|
+
await self._update_status(
|
|
259
|
+
session,
|
|
260
|
+
f"🎙️ **{step}/{session['needed']}** - didn't catch that clearly, say **{session['word']}** again.",
|
|
261
|
+
view=RecordingPromptView(self, user_id),
|
|
262
|
+
)
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
session["pending_sample"] = audio_bytes
|
|
266
|
+
session["awaiting_confirmation"] = True
|
|
267
|
+
|
|
268
|
+
await self._play_audio(session["guild_id"], audio_bytes, suppress_active_window=True)
|
|
269
|
+
await self._update_status(
|
|
270
|
+
session,
|
|
271
|
+
f"🔊 **{step}/{session['needed']}** captured - keep it, or re-record if it's noisy?",
|
|
272
|
+
view=SampleConfirmView(self, user_id),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
async def resolve_sample_confirmation(self, user_id: str, keep: bool, timed_out: bool = False):
|
|
276
|
+
session = self._enrollment.get(user_id)
|
|
277
|
+
if not session:
|
|
278
|
+
return
|
|
279
|
+
session["last_activity"] = time.time()
|
|
280
|
+
pending_sample = session.pop("pending_sample", None)
|
|
281
|
+
session["awaiting_confirmation"] = False
|
|
282
|
+
step = len(session["accepted_samples"]) + 1
|
|
283
|
+
|
|
284
|
+
if timed_out:
|
|
285
|
+
await self._update_status(
|
|
286
|
+
session,
|
|
287
|
+
f"⌛ **{step}/{session['needed']}** - no response, say **{session['word']}** again.",
|
|
288
|
+
view=RecordingPromptView(self, user_id),
|
|
289
|
+
)
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
if not keep or not pending_sample:
|
|
293
|
+
await self._update_status(
|
|
294
|
+
session,
|
|
295
|
+
f"🔁 **{step}/{session['needed']}** discarded - say **{session['word']}** again.",
|
|
296
|
+
view=RecordingPromptView(self, user_id),
|
|
297
|
+
)
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
session["accepted_samples"].append(pending_sample)
|
|
301
|
+
collected = len(session["accepted_samples"])
|
|
302
|
+
needed = session["needed"]
|
|
303
|
+
|
|
304
|
+
if collected < needed:
|
|
305
|
+
await self._update_status(
|
|
306
|
+
session,
|
|
307
|
+
f"✅ **{collected}/{needed}** saved!\n🎙️ **{collected + 1}/{needed}** - say **{session['word']}** now.",
|
|
308
|
+
view=RecordingPromptView(self, user_id),
|
|
309
|
+
)
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
await self._update_status(session, f"✅ **{needed}/{needed}** saved! Building voice reference...")
|
|
313
|
+
await self._commit_enrollment(user_id, session)
|
|
314
|
+
|
|
315
|
+
async def _commit_enrollment(self, user_id: str, session: dict):
|
|
316
|
+
from config import WAKE_REF_DIR
|
|
317
|
+
|
|
318
|
+
user_dir = WAKE_REF_DIR / user_id
|
|
319
|
+
user_dir.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
safe_word = re.sub(r"[^\w가-힣]", "_", session["word"])
|
|
321
|
+
|
|
322
|
+
for stale in user_dir.glob("*.rpw"): # drop old reference before rebuilding
|
|
323
|
+
stale.unlink(missing_ok=True)
|
|
324
|
+
|
|
325
|
+
wav_paths = []
|
|
326
|
+
for i, sample_bytes in enumerate(session["accepted_samples"], start=1):
|
|
327
|
+
wav_path = user_dir / f"{safe_word}-{i}.wav"
|
|
328
|
+
wav_path.write_bytes(self._trim_silence_wav(sample_bytes))
|
|
329
|
+
wav_paths.append(wav_path)
|
|
330
|
+
|
|
331
|
+
rpw_path = user_dir / f"{safe_word}.rpw"
|
|
332
|
+
build_ok = await self._build_rustpotter_reference(session["word"], rpw_path, wav_paths)
|
|
333
|
+
|
|
334
|
+
if build_ok:
|
|
335
|
+
# Node caches the detector per user_id - invalidate or it scores stale data.
|
|
336
|
+
try:
|
|
337
|
+
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as http:
|
|
338
|
+
await http.post(f"{NODE_VOICE_API}/invalidate_detector", json={"user_id": user_id})
|
|
339
|
+
except aiohttp.ClientError as e:
|
|
340
|
+
self.logger.warning(f"Failed to invalidate cached detector for {user_id}: {e}")
|
|
341
|
+
|
|
342
|
+
self.bot_settings["wake_words"] = session["word"]
|
|
343
|
+
self.save_bot_settings(self.bot_settings)
|
|
344
|
+
|
|
345
|
+
del self._enrollment[user_id]
|
|
346
|
+
try:
|
|
347
|
+
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as http:
|
|
348
|
+
await http.post(f"{NODE_VOICE_API}/enroll_stop", json={"user_id": user_id})
|
|
349
|
+
except aiohttp.ClientError as e:
|
|
350
|
+
self.logger.warning(f"Failed to stop enrollment forwarding for {user_id}: {e}")
|
|
351
|
+
|
|
352
|
+
if build_ok:
|
|
353
|
+
await self._update_status(session, f"✅ Wake word **{session['word']}** is registered to your voice.")
|
|
354
|
+
else:
|
|
355
|
+
await self._update_status(
|
|
356
|
+
session,
|
|
357
|
+
f"⚠️ Wake word set to **{session['word']}**, but I couldn't build the voice-matching "
|
|
358
|
+
f"reference - voice wake-up won't work until this is fixed. "
|
|
359
|
+
f"Check the logs for details, then run `/sound` again.",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def _trim_silence_wav(self, wav_bytes: bytes, threshold: int = 400, margin_sec: float = 0.05) -> bytes:
|
|
363
|
+
"""Trims trailing silence (recordings end ~800ms after speech
|
|
364
|
+
stops) - untrimmed, rustpotter's sustain-duration check never
|
|
365
|
+
fires in time. Falls back to the original bytes on any failure."""
|
|
366
|
+
try:
|
|
367
|
+
with wave.open(io.BytesIO(wav_bytes), "rb") as reader:
|
|
368
|
+
channels = reader.getnchannels()
|
|
369
|
+
sample_width = reader.getsampwidth()
|
|
370
|
+
frame_rate = reader.getframerate()
|
|
371
|
+
raw = reader.readframes(reader.getnframes())
|
|
372
|
+
|
|
373
|
+
if sample_width != 2:
|
|
374
|
+
return wav_bytes # not 16-bit PCM
|
|
375
|
+
|
|
376
|
+
samples = pyarray("h")
|
|
377
|
+
samples.frombytes(raw[: len(raw) - (len(raw) % 2)])
|
|
378
|
+
total_frames = len(samples) // channels if channels else 0
|
|
379
|
+
if total_frames == 0:
|
|
380
|
+
return wav_bytes
|
|
381
|
+
|
|
382
|
+
def frame_amplitude(frame_index: int) -> int:
|
|
383
|
+
base = frame_index * channels
|
|
384
|
+
return max(abs(samples[base + c]) for c in range(channels))
|
|
385
|
+
|
|
386
|
+
start = 0
|
|
387
|
+
while start < total_frames and frame_amplitude(start) < threshold:
|
|
388
|
+
start += 1
|
|
389
|
+
end = total_frames
|
|
390
|
+
while end > start and frame_amplitude(end - 1) < threshold:
|
|
391
|
+
end -= 1
|
|
392
|
+
|
|
393
|
+
margin = int(margin_sec * frame_rate)
|
|
394
|
+
start = max(0, start - margin)
|
|
395
|
+
end = min(total_frames, end + margin)
|
|
396
|
+
|
|
397
|
+
if (end - start) < int(0.1 * frame_rate): # near-silent clip - keep original
|
|
398
|
+
return wav_bytes
|
|
399
|
+
|
|
400
|
+
trimmed = samples[start * channels : end * channels]
|
|
401
|
+
out = io.BytesIO()
|
|
402
|
+
with wave.open(out, "wb") as writer:
|
|
403
|
+
writer.setnchannels(channels)
|
|
404
|
+
writer.setsampwidth(sample_width)
|
|
405
|
+
writer.setframerate(frame_rate)
|
|
406
|
+
writer.writeframes(trimmed.tobytes())
|
|
407
|
+
return out.getvalue()
|
|
408
|
+
except Exception as e:
|
|
409
|
+
self.logger.warning(f"Silence trim failed, using untrimmed sample: {e}")
|
|
410
|
+
return wav_bytes
|
|
411
|
+
|
|
412
|
+
async def _build_rustpotter_reference(self, word: str, rpw_path, wav_paths: list) -> bool:
|
|
413
|
+
import base64
|
|
414
|
+
|
|
415
|
+
samples = [
|
|
416
|
+
{"filename": p.name, "data_base64": base64.b64encode(p.read_bytes()).decode("ascii")} for p in wav_paths
|
|
417
|
+
]
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as http:
|
|
421
|
+
resp = await http.post(f"{NODE_VOICE_API}/build_wakeword", json={"name": word, "samples": samples})
|
|
422
|
+
data = await resp.json()
|
|
423
|
+
except aiohttp.ClientError as e:
|
|
424
|
+
self.logger.error(f"Failed to reach voice service to build wakeword reference for '{word}': {e}")
|
|
425
|
+
return False
|
|
426
|
+
except asyncio.TimeoutError:
|
|
427
|
+
self.logger.error(f"Timed out building wakeword reference for '{word}'")
|
|
428
|
+
return False
|
|
429
|
+
|
|
430
|
+
if not data.get("success"):
|
|
431
|
+
self.logger.error(f"Failed to build wakeword reference for '{word}': {data.get('error')}")
|
|
432
|
+
return False
|
|
433
|
+
|
|
434
|
+
rpw_path.write_bytes(base64.b64decode(data["rpw_base64"]))
|
|
435
|
+
self.logger.info(f"Built {rpw_path.name} for '{word}' via voice-service's WakewordRefCreator.")
|
|
436
|
+
return True
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""The live "listening..." placeholder message and the post-wake "stay awake" window."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
import discord
|
|
7
|
+
|
|
8
|
+
NODE_VOICE_API = "http://localhost:18081"
|
|
9
|
+
NODE_REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=5)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SttSessionTracker:
|
|
13
|
+
def __init__(self, bot, voice_state: dict, bot_settings: dict, logger):
|
|
14
|
+
self.bot = bot
|
|
15
|
+
self._voice_state = voice_state # shared with VoiceCog
|
|
16
|
+
self.bot_settings = bot_settings
|
|
17
|
+
self.logger = logger
|
|
18
|
+
self._last_active_time = {}
|
|
19
|
+
self._partial_msg = {}
|
|
20
|
+
|
|
21
|
+
def is_active(self, guild_id: str) -> bool:
|
|
22
|
+
"""Whether the "stay awake" window is still open for this guild."""
|
|
23
|
+
active_duration = self.bot_settings.get("active_timer", 60)
|
|
24
|
+
last_active = self._last_active_time.get(str(guild_id), 0)
|
|
25
|
+
return (time.time() - last_active) < active_duration
|
|
26
|
+
|
|
27
|
+
def mark_tts_finished(self, guild_id: str):
|
|
28
|
+
"""Called via /tts_finished once the spoken reply finishes playing."""
|
|
29
|
+
self.extend_active_window(str(guild_id))
|
|
30
|
+
|
|
31
|
+
def extend_active_window(self, guild_id: str):
|
|
32
|
+
"""Starts/renews the "stay awake" window, locally and on Node."""
|
|
33
|
+
import asyncio
|
|
34
|
+
|
|
35
|
+
self._last_active_time[guild_id] = time.time()
|
|
36
|
+
active_duration = self.bot_settings.get("active_timer", 60)
|
|
37
|
+
active_until_ms = int((time.time() + active_duration) * 1000)
|
|
38
|
+
asyncio.create_task(self._sync_active_window_to_node(guild_id, active_until_ms))
|
|
39
|
+
|
|
40
|
+
def clear_active_window(self, guild_id: str):
|
|
41
|
+
"""Ends the window immediately, locally and on Node - called on
|
|
42
|
+
/join so a fresh connection doesn't inherit one left open."""
|
|
43
|
+
import asyncio
|
|
44
|
+
|
|
45
|
+
self._last_active_time.pop(str(guild_id), None)
|
|
46
|
+
asyncio.create_task(self._sync_active_window_to_node(guild_id, 0))
|
|
47
|
+
|
|
48
|
+
async def _sync_active_window_to_node(self, guild_id: str, active_until_ms: int):
|
|
49
|
+
try:
|
|
50
|
+
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
|
|
51
|
+
await session.post(
|
|
52
|
+
f"{NODE_VOICE_API}/set_active",
|
|
53
|
+
json={"guild_id": guild_id, "active_until": active_until_ms},
|
|
54
|
+
)
|
|
55
|
+
except aiohttp.ClientError as e:
|
|
56
|
+
self.logger.warning(f"Failed to sync active window to Node for guild {guild_id}: {e}")
|
|
57
|
+
|
|
58
|
+
async def handle_stt_partial(self, data: dict):
|
|
59
|
+
"""Called by voice-service while the user speaks, only during the
|
|
60
|
+
active window. Just updates a live "listening..." placeholder -
|
|
61
|
+
handle_stt_input's final call is what reaches the LLM."""
|
|
62
|
+
try:
|
|
63
|
+
guild_id = str(data.get("guild_id"))
|
|
64
|
+
text = data.get("text")
|
|
65
|
+
thread_id = self._voice_state.get(guild_id)
|
|
66
|
+
if not thread_id:
|
|
67
|
+
return
|
|
68
|
+
thread = self.bot.get_channel(int(thread_id))
|
|
69
|
+
if not thread:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
display_text = text if text else "..."
|
|
73
|
+
content = f"🎤 *(listening...)* {display_text}"
|
|
74
|
+
|
|
75
|
+
existing = self._partial_msg.get(guild_id)
|
|
76
|
+
if existing:
|
|
77
|
+
try:
|
|
78
|
+
await existing.edit(content=content)
|
|
79
|
+
return
|
|
80
|
+
except discord.NotFound:
|
|
81
|
+
self._partial_msg.pop(guild_id, None)
|
|
82
|
+
except discord.HTTPException as e:
|
|
83
|
+
self.logger.warning(f"Failed to edit partial STT message: {e}")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
self._partial_msg[guild_id] = await thread.send(content)
|
|
88
|
+
except discord.HTTPException as e:
|
|
89
|
+
self.logger.warning(f"Failed to send partial STT message: {e}")
|
|
90
|
+
except Exception as e:
|
|
91
|
+
self.logger.exception(f"Error in handle_stt_partial: {e}")
|
|
92
|
+
|
|
93
|
+
async def cancel_stt_partial(self, guild_id: str):
|
|
94
|
+
"""Called when an utterance that had a live partial message
|
|
95
|
+
showing turned out too short, or the active window lapsed
|
|
96
|
+
mid-utterance so the final result got dropped."""
|
|
97
|
+
await self.clear_partial_msg(str(guild_id))
|
|
98
|
+
|
|
99
|
+
async def clear_partial_msg(self, guild_id: str):
|
|
100
|
+
msg = self._partial_msg.pop(guild_id, None)
|
|
101
|
+
if not msg:
|
|
102
|
+
return
|
|
103
|
+
try:
|
|
104
|
+
await msg.delete()
|
|
105
|
+
except discord.HTTPException:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
async def finalize_partial_msg(self, guild_id: str, thread, final_content: str):
|
|
109
|
+
"""Turns the live "listening..." placeholder into the final
|
|
110
|
+
recognized-text message, if one exists; otherwise sends it fresh
|
|
111
|
+
(e.g. the utterance was short enough that no partial ever fired)."""
|
|
112
|
+
msg = self._partial_msg.pop(guild_id, None)
|
|
113
|
+
if msg:
|
|
114
|
+
try:
|
|
115
|
+
await msg.edit(content=final_content)
|
|
116
|
+
return
|
|
117
|
+
except discord.NotFound:
|
|
118
|
+
pass
|
|
119
|
+
except discord.HTTPException as e:
|
|
120
|
+
self.logger.warning(f"Failed to finalize partial STT message: {e}")
|
|
121
|
+
await thread.send(final_content)
|