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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.5.12",
3
+ "version": "1.5.14",
4
4
  "description": "Discord/Telegram bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "start": "node npm-scripts/run-dev.js",
@@ -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})"
@@ -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
- return safe_load_json(MODELS_CACHE_FILE, default_models, logger=logger)
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):
@@ -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 tts_voice_autocomplete(
142
+ async def language_autocomplete(
144
143
  self, interaction: discord.Interaction, current: str
145
144
  ) -> list[app_commands.Choice[str]]:
146
- current_val = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
147
- options = [
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 current.lower() in current_val.lower() or not current:
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
- for opt in options:
173
- if current.lower() in opt.lower() and opt != current_val and len(choices) < 25:
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
- tts_voice="Select the AI TTS voice",
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
- curr_tts = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
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": "",
@@ -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
 
@@ -22,7 +22,3 @@ def set_status(platform: str, status: str, detail: str = "") -> None:
22
22
  "at": datetime.now().isoformat(),
23
23
  }
24
24
  atomic_write_json(path, data)
25
-
26
-
27
- def get_all() -> dict:
28
- return safe_load_json(_path(), {})
@@ -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
@@ -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, stt, tts
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
@@ -1,6 +1,3 @@
1
- """Slack bot setup. Runs in the same process as Discord/Telegram (see
2
- main.py), which starts all enabled platforms concurrently."""
3
-
4
1
  import asyncio
5
2
  import re
6
3
  import uuid
@@ -1,6 +1,3 @@
1
- """Telegram bot setup. Runs in the same process as Discord (see main.py),
2
- which starts both concurrently when both platforms are enabled."""
3
-
4
1
  import asyncio
5
2
  import uuid
6
3
  from datetime import datetime
@@ -1,6 +1,4 @@
1
- """Core messenger interface. Every backend (Discord now, Slack/Telegram
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
- """A file on an inbound message; reader defers fetching bytes until needed."""
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
- main.py. Both bots now share one process, so callers must say which
3
- platform they mean - either directly (voice/Discord-only code) or by
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, /credit, which
50
- can't target a specific thread since Slack slash commands can't be invoked inside one."""
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, active_voice, rate=rate)
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="ko-KR")
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:
@@ -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.log(
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
- console.log(
240
- wakeConfirmed
241
- ? `[Wake] ${userId}: CONFIRMED (score ${bestWakeScore.toFixed(3)} for ` +
242
- `"${bestWakeScoreName}", threshold ${threshold})`
243
- : `[Wake] ${userId}: no match (score ${bestWakeScore.toFixed(3)}, ` +
244
- `threshold ${threshold}; diagnostic-only closeness ` +
245
- `${bestDiagScore.toFixed(3)} for "${bestDiagScoreName ?? 'n/a'}" - ` +
246
- `different scoring config, not directly comparable to the threshold)`,
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);
@@ -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 = 'ko-KR') {
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;
@@ -14,7 +14,7 @@ function interruptTTS(guildId) {
14
14
 
15
15
  if (player && player.state.status !== AudioPlayerStatus.Idle) {
16
16
  player.stop();
17
- console.log(`[VAD] Interrupted TTS in guild ${guildId}`);
17
+ console.debug(`[VAD] Interrupted TTS in guild ${guildId}`);
18
18
  interrupted = true;
19
19
  }
20
20