linkgravity 1.5.12 → 1.5.14
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/package.json +1 -1
- package/src/api/ui_routes.py +1 -6
- package/src/approval/tool_formatter.py +0 -3
- package/src/cogs/general_cog.py +4 -2
- package/src/cogs/voice/enrollment.py +0 -8
- package/src/cogs/voice/stt_session.py +0 -2
- package/src/cogs/voice_cog.py +41 -31
- package/src/config.py +3 -0
- package/src/core/agy_runner.py +7 -2
- package/src/core/platform_health.py +0 -4
- package/src/core/session_manager.py +0 -5
- package/src/main_discord.py +1 -2
- package/src/main_slack.py +0 -3
- package/src/main_telegram.py +0 -3
- package/src/messengers/base.py +2 -8
- package/src/messengers/registry.py +3 -13
- package/src/messengers/slack_adapter.py +2 -6
- package/src/services/audio_service.py +96 -15
- package/voice-service/logger.js +7 -0
- package/voice-service/receiver.js +13 -10
- package/voice-service/stt.js +13 -1
- package/voice-service/tts.js +1 -1
package/package.json
CHANGED
package/src/api/ui_routes.py
CHANGED
|
@@ -6,18 +6,13 @@ import uuid
|
|
|
6
6
|
|
|
7
7
|
from aiohttp import web
|
|
8
8
|
|
|
9
|
+
from api.server import is_tool_allowed
|
|
9
10
|
from config import APPROVAL_TIMEOUT_SEC, MAX_EMBED_LEN, logger, session_manager
|
|
10
11
|
from messengers.base import ScopeOption
|
|
11
12
|
from messengers.registry import get_adapter_for_platform, get_adapter_for_thread
|
|
12
13
|
from utils.utils import split_message
|
|
13
14
|
|
|
14
15
|
|
|
15
|
-
def is_tool_allowed(tool_name, tool_input):
|
|
16
|
-
from api.server import is_tool_allowed as is_tool_allowed_orig
|
|
17
|
-
|
|
18
|
-
return is_tool_allowed_orig(tool_name, tool_input)
|
|
19
|
-
|
|
20
|
-
|
|
21
16
|
async def send_ordered(target_thread_id, send_coro_factory):
|
|
22
17
|
"""Routes through the same per-conversation stream queue as the answer
|
|
23
18
|
text, so tool-call messages can't arrive out of order. Falls back to
|
|
@@ -57,9 +57,6 @@ def format_tool_display(tool_name: str, tool_input: dict) -> tuple[str, str, dic
|
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
def format_bash_display(sub_cmd: str) -> tuple[str, str, dict]:
|
|
60
|
-
"""
|
|
61
|
-
Formats a single bash sub-command for display.
|
|
62
|
-
"""
|
|
63
60
|
is_long = "\n" in sub_cmd or len(sub_cmd) > 50
|
|
64
61
|
display_cmd = sub_cmd.split("\n")[0][:50] + "..." if is_long else sub_cmd
|
|
65
62
|
tool_msg_text = f"● Bash({display_cmd})"
|
package/src/cogs/general_cog.py
CHANGED
|
@@ -18,6 +18,7 @@ from config import (
|
|
|
18
18
|
save_bot_settings,
|
|
19
19
|
session_manager,
|
|
20
20
|
)
|
|
21
|
+
from core.agy_runner import clean_model_name
|
|
21
22
|
from core.atomic_io import atomic_write_json, safe_load_json
|
|
22
23
|
from utils.utils import get_default_cwd
|
|
23
24
|
|
|
@@ -35,7 +36,8 @@ def load_cached_models():
|
|
|
35
36
|
"Claude Opus 4.6 (Thinking)",
|
|
36
37
|
"GPT-OSS 120B (Medium)",
|
|
37
38
|
]
|
|
38
|
-
|
|
39
|
+
cached = safe_load_json(MODELS_CACHE_FILE, default_models, logger=logger)
|
|
40
|
+
return [clean_model_name(m) for m in cached]
|
|
39
41
|
|
|
40
42
|
|
|
41
43
|
cached_models = load_cached_models()
|
|
@@ -79,7 +81,7 @@ async def fetch_models_background():
|
|
|
79
81
|
line = line.strip()
|
|
80
82
|
line = re.sub(r"[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]", "", line).strip()
|
|
81
83
|
if line and "Fetching available models" not in line:
|
|
82
|
-
models.append(line)
|
|
84
|
+
models.append(clean_model_name(line))
|
|
83
85
|
|
|
84
86
|
if models:
|
|
85
87
|
cached_models = models
|
|
@@ -91,8 +91,6 @@ class SampleConfirmView(discord.ui.View):
|
|
|
91
91
|
|
|
92
92
|
|
|
93
93
|
class EnrollmentManager:
|
|
94
|
-
"""Owns /sound's recording flow - see _commit_enrollment."""
|
|
95
|
-
|
|
96
94
|
def __init__(self, bot, voice_state: dict, play_audio, bot_settings: dict, save_bot_settings, logger):
|
|
97
95
|
self.bot = bot
|
|
98
96
|
self._voice_state = voice_state # shared with VoiceCog
|
|
@@ -109,11 +107,7 @@ class EnrollmentManager:
|
|
|
109
107
|
def stop(self):
|
|
110
108
|
self.cleanup_stale_enrollments.cancel()
|
|
111
109
|
|
|
112
|
-
def is_enrolling(self, user_id: str) -> bool:
|
|
113
|
-
return user_id in self._enrollment
|
|
114
|
-
|
|
115
110
|
async def handle_voice_service_down(self):
|
|
116
|
-
"""Called when Node dies."""
|
|
117
111
|
stale_user_ids = list(self._enrollment.keys())
|
|
118
112
|
for uid in stale_user_ids:
|
|
119
113
|
session = self._enrollment.pop(uid, None)
|
|
@@ -181,7 +175,6 @@ class EnrollmentManager:
|
|
|
181
175
|
self.logger.warning(f"Failed to send enrollment status message: {e}")
|
|
182
176
|
|
|
183
177
|
async def start_wake_word_recording(self, interaction: discord.Interaction, word: str) -> bool:
|
|
184
|
-
"""Called by /sound's wake_word param."""
|
|
185
178
|
guild_id = interaction.guild_id
|
|
186
179
|
user_id = str(interaction.user.id)
|
|
187
180
|
|
|
@@ -241,7 +234,6 @@ class EnrollmentManager:
|
|
|
241
234
|
return True
|
|
242
235
|
|
|
243
236
|
async def handle_enroll_sample(self, user_id: str, audio_bytes: bytes):
|
|
244
|
-
"""Called via /enroll_sample for each captured sample."""
|
|
245
237
|
session = self._enrollment.get(user_id)
|
|
246
238
|
if not session:
|
|
247
239
|
return # stray sample - recording already finished/cancelled/expired
|
|
@@ -19,13 +19,11 @@ class SttSessionTracker:
|
|
|
19
19
|
self._partial_msg = {}
|
|
20
20
|
|
|
21
21
|
def is_active(self, guild_id: str) -> bool:
|
|
22
|
-
"""Whether the "stay awake" window is still open for this guild."""
|
|
23
22
|
active_duration = self.bot_settings.get("active_timer", 60)
|
|
24
23
|
last_active = self._last_active_time.get(str(guild_id), 0)
|
|
25
24
|
return (time.time() - last_active) < active_duration
|
|
26
25
|
|
|
27
26
|
def mark_tts_finished(self, guild_id: str):
|
|
28
|
-
"""Called via /tts_finished once the spoken reply finishes playing."""
|
|
29
27
|
self.extend_active_window(str(guild_id))
|
|
30
28
|
|
|
31
29
|
def extend_active_window(self, guild_id: str):
|
package/src/cogs/voice_cog.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import math
|
|
3
|
+
import re
|
|
3
4
|
import time
|
|
4
5
|
|
|
5
6
|
import aiohttp
|
|
@@ -9,6 +10,7 @@ from discord.ext import commands, tasks
|
|
|
9
10
|
|
|
10
11
|
from config import allowed, logger
|
|
11
12
|
from messengers.registry import get_adapter_for_platform
|
|
13
|
+
from services.audio_service import LANGUAGES, TTS_VOICES, default_voice_for, resolve_language
|
|
12
14
|
|
|
13
15
|
from .voice.enrollment import EnrollmentManager
|
|
14
16
|
from .voice.stt_session import SttSessionTracker
|
|
@@ -34,7 +36,6 @@ class VoiceCog(commands.Cog):
|
|
|
34
36
|
def __init__(
|
|
35
37
|
self,
|
|
36
38
|
bot,
|
|
37
|
-
stt,
|
|
38
39
|
tts,
|
|
39
40
|
send_agy_response,
|
|
40
41
|
agy_send,
|
|
@@ -46,8 +47,6 @@ class VoiceCog(commands.Cog):
|
|
|
46
47
|
logger,
|
|
47
48
|
):
|
|
48
49
|
self.bot = bot
|
|
49
|
-
# Unused by the voice pipeline now (STT moved to Node); kept for other callers.
|
|
50
|
-
self.stt = stt
|
|
51
50
|
self.tts = tts
|
|
52
51
|
self.send_agy_response = send_agy_response
|
|
53
52
|
self.agy_send = agy_send
|
|
@@ -140,37 +139,36 @@ class VoiceCog(commands.Cog):
|
|
|
140
139
|
opts.append(app_commands.Choice(name=str(v), value=v))
|
|
141
140
|
return opts
|
|
142
141
|
|
|
143
|
-
async def
|
|
142
|
+
async def language_autocomplete(
|
|
144
143
|
self, interaction: discord.Interaction, current: str
|
|
145
144
|
) -> list[app_commands.Choice[str]]:
|
|
146
|
-
current_val =
|
|
147
|
-
|
|
148
|
-
"en-US-AriaNeural",
|
|
149
|
-
"en-US-GuyNeural",
|
|
150
|
-
"en-US-AnaNeural",
|
|
151
|
-
"en-US-ChristopherNeural",
|
|
152
|
-
"en-US-EricNeural",
|
|
153
|
-
"en-US-MichelleNeural",
|
|
154
|
-
"en-US-RogerNeural",
|
|
155
|
-
"en-GB-SoniaNeural",
|
|
156
|
-
"en-GB-RyanNeural",
|
|
157
|
-
"en-AU-NatashaNeural",
|
|
158
|
-
"en-AU-WilliamNeural",
|
|
159
|
-
"ko-KR-SunHiNeural",
|
|
160
|
-
"ko-KR-InJoonNeural",
|
|
161
|
-
"ja-JP-NanamiNeural",
|
|
162
|
-
"ja-JP-KeitaNeural",
|
|
163
|
-
"fr-FR-DeniseNeural",
|
|
164
|
-
"de-DE-KatjaNeural",
|
|
165
|
-
"es-ES-ElviraNeural",
|
|
166
|
-
]
|
|
145
|
+
current_val = resolve_language()
|
|
146
|
+
query = _autocomplete_query(current).lower()
|
|
167
147
|
|
|
168
148
|
choices = []
|
|
169
|
-
if
|
|
149
|
+
if query in current_val.lower() or not query:
|
|
170
150
|
choices.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
|
|
151
|
+
for opt in LANGUAGES:
|
|
152
|
+
if query in opt.lower() and opt != current_val and len(choices) < 25:
|
|
153
|
+
choices.append(app_commands.Choice(name=opt, value=opt))
|
|
154
|
+
return choices
|
|
171
155
|
|
|
172
|
-
|
|
173
|
-
|
|
156
|
+
async def tts_voice_autocomplete(
|
|
157
|
+
self, interaction: discord.Interaction, current: str
|
|
158
|
+
) -> list[app_commands.Choice[str]]:
|
|
159
|
+
# Only same-language voices - a voice from another language would read the reply
|
|
160
|
+
# with that language's pronunciation.
|
|
161
|
+
language = resolve_language()
|
|
162
|
+
current_val = self.bot_settings.get("tts_voice") or default_voice_for(language)
|
|
163
|
+
query = _autocomplete_query(current).lower()
|
|
164
|
+
|
|
165
|
+
choices = []
|
|
166
|
+
if query in current_val.lower() or not query:
|
|
167
|
+
choices.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
|
|
168
|
+
for opt in TTS_VOICES:
|
|
169
|
+
if not opt.startswith(f"{language}-") or opt == current_val:
|
|
170
|
+
continue
|
|
171
|
+
if query in opt.lower() and len(choices) < 25:
|
|
174
172
|
choices.append(app_commands.Choice(name=opt, value=opt))
|
|
175
173
|
return choices
|
|
176
174
|
|
|
@@ -325,7 +323,8 @@ class VoiceCog(commands.Cog):
|
|
|
325
323
|
active_times="Duration in seconds the bot stays awake",
|
|
326
324
|
interrupt_threshold="Mic volume that interrupts (barges into) TTS playback (1000~10000)",
|
|
327
325
|
wake_sensitivity="Wake word match sensitivity (0.1~0.9, lower = easier to trigger but more false wakes)",
|
|
328
|
-
|
|
326
|
+
language="Language I listen and speak in (BCP-47, e.g. en-US)",
|
|
327
|
+
tts_voice="Pick a different voice within that language",
|
|
329
328
|
tts_enabled="Turn Text-to-Speech ON or OFF",
|
|
330
329
|
tts_speed="TTS playback speed multiplier, e.g. 1.3 for 1.3x (0.5~2.0)",
|
|
331
330
|
require_wake_word="Require your wake word before I listen (default ON) - turn OFF if you use push-to-talk",
|
|
@@ -334,6 +333,7 @@ class VoiceCog(commands.Cog):
|
|
|
334
333
|
active_times=active_times_autocomplete,
|
|
335
334
|
interrupt_threshold=interrupt_threshold_autocomplete,
|
|
336
335
|
wake_sensitivity=wake_sensitivity_autocomplete,
|
|
336
|
+
language=language_autocomplete,
|
|
337
337
|
tts_voice=tts_voice_autocomplete,
|
|
338
338
|
tts_enabled=tts_enabled_autocomplete,
|
|
339
339
|
tts_speed=tts_speed_autocomplete,
|
|
@@ -346,6 +346,7 @@ class VoiceCog(commands.Cog):
|
|
|
346
346
|
active_times: int = None,
|
|
347
347
|
interrupt_threshold: int = None,
|
|
348
348
|
wake_sensitivity: float = None,
|
|
349
|
+
language: str = None,
|
|
349
350
|
tts_voice: str = None,
|
|
350
351
|
tts_enabled: str = None,
|
|
351
352
|
tts_speed: float = None,
|
|
@@ -362,6 +363,7 @@ class VoiceCog(commands.Cog):
|
|
|
362
363
|
and active_times is None
|
|
363
364
|
and interrupt_threshold is None
|
|
364
365
|
and wake_sensitivity is None
|
|
366
|
+
and language is None
|
|
365
367
|
and tts_voice is None
|
|
366
368
|
and tts_enabled is None
|
|
367
369
|
and tts_speed is None
|
|
@@ -371,7 +373,8 @@ class VoiceCog(commands.Cog):
|
|
|
371
373
|
curr_timer = self.bot_settings.get("active_timer", 60)
|
|
372
374
|
curr_interrupt_thresh = self._vad_threshold(interaction.user.id)
|
|
373
375
|
curr_wake_sens = self._wake_threshold(interaction.user.id)
|
|
374
|
-
|
|
376
|
+
curr_lang = resolve_language()
|
|
377
|
+
curr_tts = self.bot_settings.get("tts_voice") or default_voice_for(curr_lang)
|
|
375
378
|
curr_tts_on = "ON" if self.bot_settings.get("tts_enabled", True) else "OFF"
|
|
376
379
|
curr_tts_speed = self.bot_settings.get("tts_speed", 1.0)
|
|
377
380
|
curr_required = self._wake_word_required(interaction.user.id)
|
|
@@ -382,6 +385,7 @@ class VoiceCog(commands.Cog):
|
|
|
382
385
|
embed.add_field(name="⏱️ Active Time", value=f"`{curr_timer}s`", inline=False)
|
|
383
386
|
embed.add_field(name="🎯 Wake Sensitivity", value=f"`{curr_wake_sens}`", inline=False)
|
|
384
387
|
embed.add_field(name="🔊 Interrupt Threshold", value=f"`{curr_interrupt_thresh}`", inline=False)
|
|
388
|
+
embed.add_field(name="🌐 Language", value=f"`{curr_lang}`", inline=False)
|
|
385
389
|
embed.add_field(name="🗣️ TTS Voice", value=f"`{curr_tts}`", inline=False)
|
|
386
390
|
embed.add_field(name="🔊 TTS Enabled", value=f"`{curr_tts_on}`", inline=False)
|
|
387
391
|
embed.add_field(name="⏩ TTS Speed", value=f"`{curr_tts_speed}x`", inline=False)
|
|
@@ -420,6 +424,13 @@ class VoiceCog(commands.Cog):
|
|
|
420
424
|
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
421
425
|
self.logger.warning(f"Node.js wake-threshold sync failed for {interaction.user.id}: {e}")
|
|
422
426
|
updated.append(f"(⚠️ Node.js Sync Failed: {e})")
|
|
427
|
+
if language is not None:
|
|
428
|
+
self.bot_settings["language"] = language
|
|
429
|
+
self.bot_settings["tts_voice"] = default_voice_for(language)
|
|
430
|
+
updated.append(
|
|
431
|
+
f"🌐 Language: `{language}` (voice set to `{self.bot_settings['tts_voice']}`; "
|
|
432
|
+
"run `lgy restart` to apply it to speech recognition)"
|
|
433
|
+
)
|
|
423
434
|
if tts_voice is not None:
|
|
424
435
|
self.bot_settings["tts_voice"] = tts_voice
|
|
425
436
|
updated.append(f"🗣️ TTS Voice: `{tts_voice}`")
|
|
@@ -532,7 +543,6 @@ class VoiceCog(commands.Cog):
|
|
|
532
543
|
return
|
|
533
544
|
|
|
534
545
|
import difflib
|
|
535
|
-
import re
|
|
536
546
|
|
|
537
547
|
# Wake detection is Node's Rustpotter detector's job - no text-similarity fallback.
|
|
538
548
|
is_waking_up = bool(data.get("wake_confirmed"))
|
package/src/config.py
CHANGED
|
@@ -31,6 +31,9 @@ DEFAULT_LGY_CONFIG = {
|
|
|
31
31
|
"voice_thresholds": {},
|
|
32
32
|
"active_timer": 60,
|
|
33
33
|
"tts_voice": "ko-KR-SunHiNeural",
|
|
34
|
+
# BCP-47 tag driving both speech recognition and the TTS voice.
|
|
35
|
+
# Empty means derive it from tts_voice, which is how pre-1.6 configs carry their language.
|
|
36
|
+
"language": "",
|
|
34
37
|
"tts_enabled": True,
|
|
35
38
|
# Sticky default for /new sessions, set whenever /model succeeds.
|
|
36
39
|
"default_model": "",
|
package/src/core/agy_runner.py
CHANGED
|
@@ -339,13 +339,18 @@ async def run_agy(
|
|
|
339
339
|
_intentionally_stopped.discard(thread_id)
|
|
340
340
|
|
|
341
341
|
|
|
342
|
+
def clean_model_name(model: str) -> str:
|
|
343
|
+
# agy models prints "<id>\t<display name>" but --model only accepts the display name.
|
|
344
|
+
return model.split("\t")[-1].strip()
|
|
345
|
+
|
|
346
|
+
|
|
342
347
|
async def agy_new_conversation(
|
|
343
348
|
content: str, model: str = None, stream_queue: asyncio.Queue = None, thread_id: str = None, cwd: str = None
|
|
344
349
|
) -> tuple[str, str]:
|
|
345
350
|
# --print consumes the next token as the prompt, so the flag must come first.
|
|
346
351
|
args = ["--dangerously-skip-permissions", "--print", content]
|
|
347
352
|
if model:
|
|
348
|
-
args.extend(["--model", model])
|
|
353
|
+
args.extend(["--model", clean_model_name(model)])
|
|
349
354
|
result_text = await run_agy(*args, stream_queue=stream_queue, thread_id=thread_id, cwd=cwd)
|
|
350
355
|
conv_id = await _get_latest_conversation_id()
|
|
351
356
|
return result_text, conv_id
|
|
@@ -361,7 +366,7 @@ async def agy_send_message(
|
|
|
361
366
|
) -> str:
|
|
362
367
|
args = ["--dangerously-skip-permissions", "--print", content, "--conversation", conv_id]
|
|
363
368
|
if model:
|
|
364
|
-
args.extend(["--model", model])
|
|
369
|
+
args.extend(["--model", clean_model_name(model)])
|
|
365
370
|
return await run_agy(*args, stream_queue=stream_queue, thread_id=thread_id, cwd=cwd)
|
|
366
371
|
|
|
367
372
|
|
|
@@ -7,8 +7,6 @@ from core.atomic_io import atomic_write_json, safe_load_json
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class SessionManager:
|
|
10
|
-
"""Manages conversation state, async streaming queues, and user approval states."""
|
|
11
|
-
|
|
12
10
|
def __init__(self, data_dir: Path):
|
|
13
11
|
self.session_file = data_dir / "sessions.json"
|
|
14
12
|
self.persistent_file = data_dir / "persistent_tools.json"
|
|
@@ -120,9 +118,6 @@ class SessionManager:
|
|
|
120
118
|
if conv_id:
|
|
121
119
|
self.active_approval_by_conv[conv_id] = approval_key
|
|
122
120
|
|
|
123
|
-
def get_pending_approval(self, approval_key: str) -> asyncio.Future | None:
|
|
124
|
-
return self.pending_approvals.get(approval_key)
|
|
125
|
-
|
|
126
121
|
def get_pending_approval_by_conv(self, conv_id: str) -> asyncio.Future | None:
|
|
127
122
|
"""Looks up whichever approval is CURRENTLY active for a given
|
|
128
123
|
conversation - for callers that only have the stable
|
package/src/main_discord.py
CHANGED
|
@@ -17,7 +17,7 @@ from messengers.discord_adapter import DiscordAdapter
|
|
|
17
17
|
from messengers.registry import register_adapter
|
|
18
18
|
from services.response import send_agy_response
|
|
19
19
|
from services.streaming import stream_thinking_latest
|
|
20
|
-
from utils.utils import agy_new_conversation, agy_send_message,
|
|
20
|
+
from utils.utils import agy_new_conversation, agy_send_message, tts
|
|
21
21
|
|
|
22
22
|
voice_process = None
|
|
23
23
|
_voice_shutting_down = False
|
|
@@ -290,7 +290,6 @@ async def run_discord(stop_event: asyncio.Event) -> None:
|
|
|
290
290
|
await bot.add_cog(
|
|
291
291
|
VoiceCog(
|
|
292
292
|
bot=bot,
|
|
293
|
-
stt=stt,
|
|
294
293
|
tts=tts,
|
|
295
294
|
send_agy_response=send_agy_response,
|
|
296
295
|
agy_send=agy_send_message,
|
package/src/main_slack.py
CHANGED
package/src/main_telegram.py
CHANGED
package/src/messengers/base.py
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
"""Core messenger interface.
|
|
2
|
-
later) implements MessengerAdapter; business logic never touches
|
|
3
|
-
platform SDK types directly.
|
|
1
|
+
"""Core messenger interface. Business logic never touches platform SDK types directly.
|
|
4
2
|
|
|
5
3
|
Futures for approval/question prompts are owned by business logic, not
|
|
6
4
|
the adapter - the same future can also be resolved by a typed reply,
|
|
@@ -17,7 +15,7 @@ from typing import Any
|
|
|
17
15
|
|
|
18
16
|
@dataclass
|
|
19
17
|
class IncomingAttachment:
|
|
20
|
-
"""
|
|
18
|
+
"""reader defers fetching bytes until the attachment is actually read."""
|
|
21
19
|
|
|
22
20
|
filename: str
|
|
23
21
|
content_type: str | None
|
|
@@ -144,10 +142,6 @@ class MessengerAdapter(ABC):
|
|
|
144
142
|
) -> PromptHandle:
|
|
145
143
|
raise NotImplementedError
|
|
146
144
|
|
|
147
|
-
@property
|
|
148
|
-
def supports_voice(self) -> bool:
|
|
149
|
-
return isinstance(self, VoiceCapable)
|
|
150
|
-
|
|
151
145
|
|
|
152
146
|
class VoiceCapable(ABC):
|
|
153
147
|
@abstractmethod
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
"""Per-platform messenger adapter registry, populated once at startup by
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
looking up which platform a given thread_id/conversation belongs to."""
|
|
1
|
+
"""Per-platform messenger adapter registry, populated once at startup by main.py. All platforms
|
|
2
|
+
share one process, so callers must say which platform they mean - either directly (voice/
|
|
3
|
+
Discord-only code) or by looking up which platform a thread_id belongs to."""
|
|
5
4
|
|
|
6
5
|
from messengers.base import MessengerAdapter
|
|
7
6
|
|
|
@@ -24,12 +23,3 @@ def get_adapter_for_thread(thread_id: str) -> MessengerAdapter:
|
|
|
24
23
|
session = session_manager.get_session(thread_id) or {}
|
|
25
24
|
platform = session.get("platform", "discord") # pre-multi-platform sessions have no tag - assume discord
|
|
26
25
|
return get_adapter_for_platform(platform)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def get_adapter_for_conv_id(conv_id: str) -> MessengerAdapter | None:
|
|
30
|
-
from config import session_manager
|
|
31
|
-
|
|
32
|
-
for _thread_id, sess in session_manager.get_all_sessions().items():
|
|
33
|
-
if sess.get("conversation_id") == conv_id:
|
|
34
|
-
return get_adapter_for_platform(sess.get("platform", "discord"))
|
|
35
|
-
return None
|
|
@@ -46,8 +46,8 @@ def decode_conversation_id(conversation_id: str) -> tuple[str, str] | None:
|
|
|
46
46
|
|
|
47
47
|
def latest_channel_session(channel: str) -> tuple[str, dict] | None:
|
|
48
48
|
"""Most recently created Slack session in a channel - used as a fallback for un-threaded
|
|
49
|
-
messages (users rarely bother clicking "Reply in thread") and for /model,
|
|
50
|
-
|
|
49
|
+
messages (users rarely bother clicking "Reply in thread") and for /model, which can't target
|
|
50
|
+
a specific thread since Slack slash commands can't be invoked inside one."""
|
|
51
51
|
candidates = [
|
|
52
52
|
(cid, s)
|
|
53
53
|
for cid, s in session_manager.get_all_sessions().items()
|
|
@@ -59,8 +59,6 @@ def latest_channel_session(channel: str) -> tuple[str, dict] | None:
|
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
class SlackConversationRef:
|
|
62
|
-
"""conversation_ref for Slack - a channel + the thread_ts all replies go under."""
|
|
63
|
-
|
|
64
62
|
__slots__ = ("channel", "thread_ts")
|
|
65
63
|
|
|
66
64
|
def __init__(self, channel: str, thread_ts: str):
|
|
@@ -77,8 +75,6 @@ class SlackConversationRef:
|
|
|
77
75
|
|
|
78
76
|
|
|
79
77
|
class SlackMessageRef:
|
|
80
|
-
"""message_ref for edit_message - a specific message within a channel."""
|
|
81
|
-
|
|
82
78
|
__slots__ = ("channel", "ts")
|
|
83
79
|
|
|
84
80
|
def __init__(self, channel: str, ts: str):
|
|
@@ -6,9 +6,102 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
from config import TTS_VOICE, bot_settings, logger
|
|
8
8
|
|
|
9
|
+
# Verified against edge-tts's live voice list; a name that isn't in it fails at synthesis time.
|
|
10
|
+
# Ordered - the first voice of a language is that language's default.
|
|
11
|
+
TTS_VOICES = [
|
|
12
|
+
"en-US-AriaNeural",
|
|
13
|
+
"en-US-GuyNeural",
|
|
14
|
+
"en-US-AnaNeural",
|
|
15
|
+
"en-US-ChristopherNeural",
|
|
16
|
+
"en-US-EricNeural",
|
|
17
|
+
"en-US-MichelleNeural",
|
|
18
|
+
"en-US-RogerNeural",
|
|
19
|
+
"en-GB-SoniaNeural",
|
|
20
|
+
"en-GB-RyanNeural",
|
|
21
|
+
"en-AU-NatashaNeural",
|
|
22
|
+
"en-AU-WilliamMultilingualNeural",
|
|
23
|
+
"ko-KR-SunHiNeural",
|
|
24
|
+
"ko-KR-InJoonNeural",
|
|
25
|
+
"ja-JP-NanamiNeural",
|
|
26
|
+
"ja-JP-KeitaNeural",
|
|
27
|
+
"zh-CN-XiaoxiaoNeural",
|
|
28
|
+
"zh-CN-YunxiNeural",
|
|
29
|
+
"fr-FR-DeniseNeural",
|
|
30
|
+
"de-DE-KatjaNeural",
|
|
31
|
+
"es-ES-ElviraNeural",
|
|
32
|
+
"it-IT-ElsaNeural",
|
|
33
|
+
"pt-BR-FranciscaNeural",
|
|
34
|
+
"ru-RU-SvetlanaNeural",
|
|
35
|
+
"hi-IN-SwaraNeural",
|
|
36
|
+
"id-ID-GadisNeural",
|
|
37
|
+
"vi-VN-HoaiMyNeural",
|
|
38
|
+
"th-TH-PremwadeeNeural",
|
|
39
|
+
"tr-TR-EmelNeural",
|
|
40
|
+
"pl-PL-ZofiaNeural",
|
|
41
|
+
"nl-NL-ColetteNeural",
|
|
42
|
+
"ar-SA-ZariyahNeural",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
LANGUAGES = list(dict.fromkeys(v.rsplit("-", 1)[0] for v in TTS_VOICES))
|
|
46
|
+
|
|
47
|
+
DEFAULT_LANGUAGE = "en-US"
|
|
48
|
+
|
|
49
|
+
# Ordered: the first script reaching the share threshold wins, so Hangul beats Latin in mixed
|
|
50
|
+
# Korean/English text, and kana beats Han in Japanese (which is written with both).
|
|
51
|
+
SCRIPT_PATTERNS = [
|
|
52
|
+
("ko", re.compile(r"[가-힣]")),
|
|
53
|
+
("ja", re.compile(r"[ぁ-ゖァ-ヺ]")),
|
|
54
|
+
("ru", re.compile(r"[\u0400-\u04ff]")),
|
|
55
|
+
("ar", re.compile(r"[\u0600-\u06ff]")),
|
|
56
|
+
("th", re.compile(r"[\u0e00-\u0e7f]")),
|
|
57
|
+
("hi", re.compile(r"[\u0900-\u097f]")),
|
|
58
|
+
("zh", re.compile(r"[\u4e00-\u9fff]")),
|
|
59
|
+
("en", re.compile(r"[a-zA-Z]")),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
SCRIPT_SHARE = 0.3
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def detect_script(text: str) -> str | None:
|
|
66
|
+
counts = {tag: len(pattern.findall(text)) for tag, pattern in SCRIPT_PATTERNS}
|
|
67
|
+
total = sum(counts.values())
|
|
68
|
+
if not total:
|
|
69
|
+
return None
|
|
70
|
+
for tag, _ in SCRIPT_PATTERNS:
|
|
71
|
+
if counts[tag] / total >= SCRIPT_SHARE:
|
|
72
|
+
return tag
|
|
73
|
+
return max(counts, key=counts.get)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def default_voice_for(tag: str) -> str:
|
|
77
|
+
for voice in TTS_VOICES:
|
|
78
|
+
if voice.startswith(f"{tag}-"):
|
|
79
|
+
return voice
|
|
80
|
+
if "-" in tag:
|
|
81
|
+
return default_voice_for(tag.split("-")[0])
|
|
82
|
+
return TTS_VOICES[0]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve_language() -> str:
|
|
86
|
+
# Mirrored by resolveLanguage() in voice-service/stt.js, which is what reaches Google.
|
|
87
|
+
configured = bot_settings.get("language")
|
|
88
|
+
if configured:
|
|
89
|
+
return configured
|
|
90
|
+
voice = bot_settings.get("tts_voice") or TTS_VOICE or ""
|
|
91
|
+
return voice.rsplit("-", 1)[0] if voice.count("-") >= 2 else DEFAULT_LANGUAGE
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def voice_for(text: str) -> str:
|
|
95
|
+
# Latin script covers dozens of languages, so a configured en-GB voice must survive a bare
|
|
96
|
+
# "en" detection instead of being reset to the en-US default.
|
|
97
|
+
configured = bot_settings.get("tts_voice") or TTS_VOICE
|
|
98
|
+
script = detect_script(text)
|
|
99
|
+
if script is None or configured.startswith(f"{script}-"):
|
|
100
|
+
return configured
|
|
101
|
+
return default_voice_for(script)
|
|
102
|
+
|
|
9
103
|
|
|
10
104
|
async def tts(text: str, voice: str = None) -> bytes | None:
|
|
11
|
-
voice = voice or bot_settings.get("tts_voice", TTS_VOICE)
|
|
12
105
|
try:
|
|
13
106
|
import edge_tts
|
|
14
107
|
|
|
@@ -17,24 +110,12 @@ async def tts(text: str, voice: str = None) -> bytes | None:
|
|
|
17
110
|
clean = re.sub(r"\n+", ". ", clean).strip()[:800]
|
|
18
111
|
if not clean:
|
|
19
112
|
return None
|
|
20
|
-
hangul_chars = len(re.findall(r"[가-힣]", clean))
|
|
21
|
-
alpha_chars = len(re.findall(r"[a-zA-Z]", clean))
|
|
22
|
-
total_letters = hangul_chars + alpha_chars
|
|
23
|
-
ko_voice = os.getenv("TTS_VOICE_KO", "ko-KR-SunHiNeural")
|
|
24
|
-
en_voice = os.getenv("TTS_VOICE_EN", "en-US-AriaNeural")
|
|
25
|
-
|
|
26
|
-
if total_letters == 0:
|
|
27
|
-
active_voice = TTS_VOICE
|
|
28
|
-
elif (hangul_chars / total_letters) >= 0.3:
|
|
29
|
-
active_voice = ko_voice
|
|
30
|
-
else:
|
|
31
|
-
active_voice = en_voice
|
|
32
113
|
|
|
33
114
|
speed = bot_settings.get("tts_speed", 1.0)
|
|
34
115
|
pct = round((speed - 1.0) * 100)
|
|
35
116
|
rate = f"{'+' if pct >= 0 else ''}{pct}%"
|
|
36
117
|
|
|
37
|
-
communicate = edge_tts.Communicate(clean,
|
|
118
|
+
communicate = edge_tts.Communicate(clean, voice or voice_for(clean), rate=rate)
|
|
38
119
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
|
39
120
|
tmp = f.name
|
|
40
121
|
await communicate.save(tmp)
|
|
@@ -58,7 +139,7 @@ async def stt(audio_bytes: bytes) -> str | None:
|
|
|
58
139
|
with sr.AudioFile(io.BytesIO(audio_bytes)) as source:
|
|
59
140
|
audio = r.record(source)
|
|
60
141
|
try:
|
|
61
|
-
return r.recognize_google(audio, language=
|
|
142
|
+
return r.recognize_google(audio, language=resolve_language())
|
|
62
143
|
except sr.UnknownValueError:
|
|
63
144
|
return None
|
|
64
145
|
except sr.RequestError as e:
|
package/voice-service/logger.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const originalLog = console.log;
|
|
2
2
|
const originalError = console.error;
|
|
3
3
|
|
|
4
|
+
const debugEnabled = (process.env.LOG_LEVEL || 'INFO').toUpperCase() === 'DEBUG';
|
|
5
|
+
|
|
4
6
|
function getTimestamp() {
|
|
5
7
|
const now = new Date();
|
|
6
8
|
const offset = now.getTimezoneOffset() * 60000;
|
|
@@ -14,3 +16,8 @@ console.log = function (...args) {
|
|
|
14
16
|
console.error = function (...args) {
|
|
15
17
|
originalError(`${getTimestamp()} ERROR Voice:`, ...args);
|
|
16
18
|
};
|
|
19
|
+
console.debug = function (...args) {
|
|
20
|
+
if (debugEnabled) {
|
|
21
|
+
originalLog(`${getTimestamp()} DEBUG Voice:`, ...args);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
@@ -148,7 +148,7 @@ function setupReceiver(connection, guildId, client) {
|
|
|
148
148
|
const dynamicThreshold = isBotPlaying ? baseThreshold * 3 : baseThreshold;
|
|
149
149
|
if (rms > dynamicThreshold) {
|
|
150
150
|
if (interruptTTS(guildId)) {
|
|
151
|
-
console.
|
|
151
|
+
console.debug(
|
|
152
152
|
`[VAD] Loud voice detected (${Math.round(rms)}), interrupting TTS (Threshold: ${dynamicThreshold})`,
|
|
153
153
|
);
|
|
154
154
|
hasInterrupted = true;
|
|
@@ -236,15 +236,18 @@ function setupReceiver(connection, guildId, client) {
|
|
|
236
236
|
const threshold = wakeThresholdFor(userId);
|
|
237
237
|
wakeConfirmed = bestWakeScoreName !== null;
|
|
238
238
|
matchedWakeWord = wakeConfirmed ? bestWakeScoreName : null;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
239
|
+
if (wakeConfirmed) {
|
|
240
|
+
console.log(
|
|
241
|
+
`[Wake] ${userId}: CONFIRMED (score ${bestWakeScore.toFixed(3)} for ` +
|
|
242
|
+
`"${bestWakeScoreName}", threshold ${threshold})`,
|
|
243
|
+
);
|
|
244
|
+
} else {
|
|
245
|
+
console.debug(
|
|
246
|
+
`[Wake] ${userId}: no match (threshold ${threshold}; diagnostic-only ` +
|
|
247
|
+
`closeness ${bestDiagScore.toFixed(3)} for "${bestDiagScoreName ?? 'n/a'}" - ` +
|
|
248
|
+
`different scoring config, not directly comparable to the threshold)`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
248
251
|
}
|
|
249
252
|
|
|
250
253
|
const pcmBuffer = Buffer.concat(chunks);
|
package/voice-service/stt.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
const { spawn } = require('child_process');
|
|
2
2
|
const ffmpegPath = require('ffmpeg-static');
|
|
3
|
+
const { aglConfig } = require('./config');
|
|
3
4
|
|
|
4
5
|
// Unofficial Google STT key - same default Python's SpeechRecognition (recognize_google) ships with.
|
|
5
6
|
const GOOGLE_STT_KEY = 'AIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgw';
|
|
6
7
|
|
|
8
|
+
// Mirrored by resolve_language() in src/services/audio_service.py. edge-tts voice names are
|
|
9
|
+
// <lang>-<REGION>-<Name>, so a pre-1.6 config carries its language in tts_voice alone.
|
|
10
|
+
function resolveLanguage() {
|
|
11
|
+
if (aglConfig.language) return aglConfig.language;
|
|
12
|
+
const match = /^([a-z]{2}-[A-Z]{2})-/.exec(aglConfig.tts_voice || '');
|
|
13
|
+
return match ? match[1] : 'en-US';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const LANGUAGE = resolveLanguage();
|
|
17
|
+
|
|
7
18
|
function flacEncode(wavBuffer) {
|
|
8
19
|
return new Promise((resolve, reject) => {
|
|
9
20
|
const ff = spawn(ffmpegPath, [
|
|
@@ -29,7 +40,7 @@ function flacEncode(wavBuffer) {
|
|
|
29
40
|
});
|
|
30
41
|
}
|
|
31
42
|
|
|
32
|
-
async function googleSTT(wavBuffer, lang =
|
|
43
|
+
async function googleSTT(wavBuffer, lang = LANGUAGE) {
|
|
33
44
|
let flacBuffer;
|
|
34
45
|
try {
|
|
35
46
|
flacBuffer = await flacEncode(wavBuffer);
|
|
@@ -54,6 +65,7 @@ async function googleSTT(wavBuffer, lang = 'ko-KR') {
|
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
const raw = await res.text();
|
|
68
|
+
console.debug('[STT] raw:', raw.trim().replace(/\n/g, ' | '));
|
|
57
69
|
// Response is newline-delimited JSON, one object per line.
|
|
58
70
|
for (const line of raw.trim().split('\n')) {
|
|
59
71
|
if (!line) continue;
|
package/voice-service/tts.js
CHANGED
|
@@ -14,7 +14,7 @@ function interruptTTS(guildId) {
|
|
|
14
14
|
|
|
15
15
|
if (player && player.state.status !== AudioPlayerStatus.Idle) {
|
|
16
16
|
player.stop();
|
|
17
|
-
console.
|
|
17
|
+
console.debug(`[VAD] Interrupted TTS in guild ${guildId}`);
|
|
18
18
|
interrupted = true;
|
|
19
19
|
}
|
|
20
20
|
|