linkgravity 1.5.13 → 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.13",
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",
@@ -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
@@ -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
 
@@ -1,3 +1,6 @@
1
+ """Discord bot setup. Runs in the same process as Telegram (see main.py),
2
+ which starts both concurrently when both platforms are enabled."""
3
+
1
4
  import asyncio
2
5
  import atexit
3
6
  import os
@@ -14,7 +17,7 @@ from messengers.discord_adapter import DiscordAdapter
14
17
  from messengers.registry import register_adapter
15
18
  from services.response import send_agy_response
16
19
  from services.streaming import stream_thinking_latest
17
- from utils.utils import agy_new_conversation, agy_send_message, stt, tts
20
+ from utils.utils import agy_new_conversation, agy_send_message, tts
18
21
 
19
22
  voice_process = None
20
23
  _voice_shutting_down = False
@@ -287,7 +290,6 @@ async def run_discord(stop_event: asyncio.Event) -> None:
287
290
  await bot.add_cog(
288
291
  VoiceCog(
289
292
  bot=bot,
290
- stt=stt,
291
293
  tts=tts,
292
294
  send_agy_response=send_agy_response,
293
295
  agy_send=agy_send_message,
@@ -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,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;