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 +1 -1
- package/src/cogs/general_cog.py +4 -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/main_discord.py +4 -2
- package/src/services/audio_service.py +96 -15
- package/voice-service/stt.js +13 -1
package/package.json
CHANGED
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
|
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
|
|
package/src/main_discord.py
CHANGED
|
@@ -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,
|
|
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,
|
|
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/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;
|