linkgravity 1.5.13 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/setup.js +17 -6
- package/package.json +1 -1
- package/src/approval/diff_preview.py +143 -0
- package/src/approval/tool_formatter.py +9 -4
- package/src/cogs/general_cog.py +4 -2
- package/src/cogs/voice_cog.py +45 -34
- package/src/config.py +7 -2
- package/src/core/agy_runner.py +7 -2
- package/src/main_discord.py +4 -2
- package/src/main_telegram.py +17 -1
- package/src/messengers/discord_adapter.py +9 -1
- package/src/messengers/slack_adapter.py +17 -1
- package/src/messengers/telegram_adapter.py +4 -1
- package/src/services/audio_service.py +107 -15
- package/voice-service/stt.js +20 -1
package/bin/setup.js
CHANGED
|
@@ -112,13 +112,16 @@ async function collectSessionScopes(existingScopes) {
|
|
|
112
112
|
return scopes;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
async function collectUserIds(existingIds, platformLabel) {
|
|
115
|
+
async function collectUserIds(existingIds, platformLabel, required = false) {
|
|
116
116
|
const ids = [];
|
|
117
117
|
const hasExisting = existingIds && existingIds.length > 0;
|
|
118
118
|
|
|
119
119
|
p.note(
|
|
120
|
-
|
|
121
|
-
'
|
|
120
|
+
required
|
|
121
|
+
? 'ONLY these users can use the bot. At least one is required - anyone who knows the ' +
|
|
122
|
+
'bot username can message it, and there is no channel scope to fall back on.'
|
|
123
|
+
: 'ONLY these users can use the bot (leave completely empty to allow EVERYONE). ' +
|
|
124
|
+
'Not related to DMs - this only gates the channel/threads configured above.',
|
|
122
125
|
`Allowed ${platformLabel} Users`,
|
|
123
126
|
);
|
|
124
127
|
|
|
@@ -143,7 +146,9 @@ async function collectUserIds(existingIds, platformLabel) {
|
|
|
143
146
|
while (true) {
|
|
144
147
|
const userId = await p.text({
|
|
145
148
|
message: isFirst
|
|
146
|
-
?
|
|
149
|
+
? required
|
|
150
|
+
? `${platformLabel} User ID to allow (required - message @userinfobot to find yours):`
|
|
151
|
+
: `${platformLabel} User ID to allow (leave empty to allow EVERYONE):`
|
|
147
152
|
: `Another ${platformLabel} user ID to allow (leave empty if done):`,
|
|
148
153
|
});
|
|
149
154
|
if (p.isCancel(userId)) {
|
|
@@ -151,7 +156,13 @@ async function collectUserIds(existingIds, platformLabel) {
|
|
|
151
156
|
process.exit(0);
|
|
152
157
|
}
|
|
153
158
|
|
|
154
|
-
if (!userId)
|
|
159
|
+
if (!userId) {
|
|
160
|
+
if (required && isFirst) {
|
|
161
|
+
p.note(`At least one allowed user is required for ${platformLabel}.`, 'Required');
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
155
166
|
isFirst = false;
|
|
156
167
|
|
|
157
168
|
ids.push(userId.trim());
|
|
@@ -334,7 +345,7 @@ async function configureTelegram(existingSettings) {
|
|
|
334
345
|
const existingTelegramUserIds = existingSettings.telegram_allowed_user_ids
|
|
335
346
|
? splitIds(existingSettings.telegram_allowed_user_ids)
|
|
336
347
|
: [];
|
|
337
|
-
const telegramUserIds = await collectUserIds(existingTelegramUserIds, 'Telegram');
|
|
348
|
+
const telegramUserIds = await collectUserIds(existingTelegramUserIds, 'Telegram', true);
|
|
338
349
|
|
|
339
350
|
const updates = {};
|
|
340
351
|
if (telegramToken) updates.telegram_token = telegramToken;
|
package/package.json
CHANGED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
MAX_LINES = 120
|
|
7
|
+
TAIL_LINES = 30
|
|
8
|
+
MAX_CHARS = 5000
|
|
9
|
+
CONTEXT_LINES = 3
|
|
10
|
+
|
|
11
|
+
PATH_KEYS = ("TargetFile", "AbsolutePath")
|
|
12
|
+
|
|
13
|
+
HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)")
|
|
14
|
+
|
|
15
|
+
MISSING = object()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _existing_text(tool_input: dict):
|
|
19
|
+
path = next((tool_input[k] for k in PATH_KEYS if tool_input.get(k)), None)
|
|
20
|
+
if not path:
|
|
21
|
+
return MISSING, None
|
|
22
|
+
if not os.path.isfile(path):
|
|
23
|
+
return "", path
|
|
24
|
+
try:
|
|
25
|
+
return Path(path).read_text(encoding="utf-8"), path
|
|
26
|
+
except (OSError, UnicodeDecodeError):
|
|
27
|
+
return MISSING, path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _find_replaced_text(tool_input: dict, haystack: str, replacement: str) -> str | None:
|
|
31
|
+
# agy doesn't say which key holds the replaced text, so candidates are checked against the
|
|
32
|
+
# file instead of trusted by name.
|
|
33
|
+
best = None
|
|
34
|
+
for key, value in tool_input.items():
|
|
35
|
+
if key in PATH_KEYS or not isinstance(value, str) or not value or value == replacement:
|
|
36
|
+
continue
|
|
37
|
+
if haystack.count(value) == 1 and (best is None or len(value) > len(best)):
|
|
38
|
+
best = value
|
|
39
|
+
return best
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _entries(old: list[str], new: list[str], first_line: int, width: int) -> list[tuple[int, bool, str]]:
|
|
43
|
+
out, old_no, new_no = [], first_line, first_line
|
|
44
|
+
for line in difflib.unified_diff(old, new, lineterm="", n=CONTEXT_LINES):
|
|
45
|
+
if line.startswith(("---", "+++")):
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
header = HUNK_HEADER.match(line)
|
|
49
|
+
if header:
|
|
50
|
+
# difflib counts from the start of what it was given, which is a slice of the file
|
|
51
|
+
# for chunk edits.
|
|
52
|
+
old_no = int(header.group(1)) + first_line - 1
|
|
53
|
+
new_no = int(header.group(2)) + first_line - 1
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
sign, body = line[0], line[1:]
|
|
57
|
+
number = old_no if sign == "-" else new_no
|
|
58
|
+
# A removed line still sits at new_no, but it doesn't occupy one, so it anchors the gap
|
|
59
|
+
# marker without advancing past it.
|
|
60
|
+
out.append((new_no, sign != "-", f"{sign} {number:>{width}} | {body}"))
|
|
61
|
+
if sign in " -":
|
|
62
|
+
old_no += 1
|
|
63
|
+
if sign in " +":
|
|
64
|
+
new_no += 1
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _with_gaps(entries: list[tuple[int, bool, str]], total: int, width: int) -> list[str]:
|
|
69
|
+
def gap(count):
|
|
70
|
+
return f"{'':>{width + 5}}... ({count} unchanged {'line' if count == 1 else 'lines'})"
|
|
71
|
+
|
|
72
|
+
out, last = [], 0
|
|
73
|
+
for position, occupies, text in entries:
|
|
74
|
+
if position > last + 1:
|
|
75
|
+
out.append(gap(position - last - 1))
|
|
76
|
+
out.append(text)
|
|
77
|
+
last = position if occupies else max(last, position - 1)
|
|
78
|
+
if total > last:
|
|
79
|
+
out.append(gap(total - last))
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _clip(lines: list[str]) -> str:
|
|
84
|
+
if len(lines) > MAX_LINES:
|
|
85
|
+
head = MAX_LINES - TAIL_LINES - 1
|
|
86
|
+
lines = [*lines[:head], f"... ({len(lines) - head - TAIL_LINES} more lines)", *lines[-TAIL_LINES:]]
|
|
87
|
+
kept, size = [], 0
|
|
88
|
+
for line in lines:
|
|
89
|
+
if size + len(line) > MAX_CHARS:
|
|
90
|
+
kept.append(f"... ({len(lines) - len(kept)} more lines)")
|
|
91
|
+
break
|
|
92
|
+
kept.append(line)
|
|
93
|
+
size += len(line) + 1
|
|
94
|
+
return "\n".join(kept)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _render(name: str, entries, total: int, width: int) -> str | None:
|
|
98
|
+
if not entries:
|
|
99
|
+
return None
|
|
100
|
+
added = sum(1 for *_, text in entries if text.startswith("+"))
|
|
101
|
+
removed = sum(1 for *_, text in entries if text.startswith("-"))
|
|
102
|
+
return f"{name} · +{added} -{removed}\n{_clip(_with_gaps(entries, total, width))}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_diff(tool_input: dict) -> str | None:
|
|
106
|
+
before, path = _existing_text(tool_input)
|
|
107
|
+
if before is MISSING:
|
|
108
|
+
return None
|
|
109
|
+
name = os.path.basename(path)
|
|
110
|
+
on_disk = os.path.isfile(path)
|
|
111
|
+
|
|
112
|
+
chunks = tool_input.get("ReplacementChunks")
|
|
113
|
+
if chunks:
|
|
114
|
+
if not on_disk:
|
|
115
|
+
return None
|
|
116
|
+
existing = before.splitlines()
|
|
117
|
+
total = len(existing)
|
|
118
|
+
entries, width = [], len(str(total))
|
|
119
|
+
for chunk in chunks:
|
|
120
|
+
start, end = chunk.get("StartLine"), chunk.get("EndLine")
|
|
121
|
+
replacement = chunk.get("ReplacementContent")
|
|
122
|
+
if not isinstance(start, int) or not isinstance(end, int) or replacement is None:
|
|
123
|
+
return None
|
|
124
|
+
replaced_lines = replacement.splitlines()
|
|
125
|
+
total += len(replaced_lines) - (end - start + 1)
|
|
126
|
+
entries += _entries(existing[start - 1 : end], replaced_lines, start, width)
|
|
127
|
+
return _render(name, entries, total, width)
|
|
128
|
+
|
|
129
|
+
if "CodeContent" in tool_input:
|
|
130
|
+
content = tool_input["CodeContent"]
|
|
131
|
+
else:
|
|
132
|
+
replacement = tool_input.get("ReplacementContent")
|
|
133
|
+
if replacement is None or not on_disk:
|
|
134
|
+
return None
|
|
135
|
+
replaced = _find_replaced_text(tool_input, before, replacement)
|
|
136
|
+
if replaced is None:
|
|
137
|
+
return None
|
|
138
|
+
content = before.replace(replaced, replacement, 1)
|
|
139
|
+
|
|
140
|
+
old_lines, new_lines = before.splitlines(), content.splitlines()
|
|
141
|
+
width = len(str(max(len(old_lines), len(new_lines))))
|
|
142
|
+
label = f"{name} (new file)" if not on_disk else name
|
|
143
|
+
return _render(label, _entries(old_lines, new_lines, 1, width), len(new_lines), width)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
3
|
|
|
4
|
+
from approval.diff_preview import build_diff
|
|
5
|
+
|
|
4
6
|
TOOL_DISPLAY_NAMES = {
|
|
5
7
|
"view_file": "Read",
|
|
6
8
|
"write_to_file": "Write",
|
|
@@ -39,14 +41,17 @@ def format_tool_display(tool_name: str, tool_input: dict) -> tuple[str, str, dic
|
|
|
39
41
|
fields_text += f"**{k}**: {v}\n"
|
|
40
42
|
|
|
41
43
|
code_text = ""
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
diff = build_diff(tool_input)
|
|
45
|
+
if diff:
|
|
46
|
+
code_text = f"\n**Changes:**\n```diff\n{diff}\n```"
|
|
47
|
+
elif "CodeContent" in tool_input:
|
|
48
|
+
code_text = f"\n**Code Content:**\n```\n{tool_input['CodeContent'][:1000]}\n```"
|
|
44
49
|
elif "ReplacementChunks" in tool_input:
|
|
45
50
|
for i, chunk in enumerate(tool_input["ReplacementChunks"]):
|
|
46
51
|
code_text += f"\n**Replacement Chunk #{i + 1} (Lines {chunk.get('StartLine')}-{chunk.get('EndLine')}):**\n"
|
|
47
|
-
code_text += f"
|
|
52
|
+
code_text += f"```\n{chunk.get('ReplacementContent')[:500]}\n```"
|
|
48
53
|
elif "ReplacementContent" in tool_input:
|
|
49
|
-
code_text += f"\n**Replacement Content:**\n
|
|
54
|
+
code_text += f"\n**Replacement Content:**\n```\n{tool_input['ReplacementContent'][:1000]}\n```"
|
|
50
55
|
|
|
51
56
|
if fields_text or code_text:
|
|
52
57
|
desc_json = f"\n{fields_text}{code_text}"
|
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
|
|
|
@@ -292,12 +290,13 @@ class VoiceCog(commands.Cog):
|
|
|
292
290
|
|
|
293
291
|
if own_word or not required:
|
|
294
292
|
if self.bot_settings.get("tts_enabled", True):
|
|
295
|
-
welcome_audio = await self.tts("Voice connected.")
|
|
293
|
+
welcome_audio = await self.tts("Voice connected.", cache=True)
|
|
296
294
|
if welcome_audio:
|
|
297
295
|
await self._play_audio(str(guild_id), welcome_audio, suppress_active_window=True)
|
|
298
296
|
elif self.bot_settings.get("tts_enabled", True):
|
|
299
297
|
prompt_audio = await self.tts(
|
|
300
|
-
"No wake word is set up yet. Please use the sound command to set one."
|
|
298
|
+
"No wake word is set up yet. Please use the sound command to set one.",
|
|
299
|
+
cache=True,
|
|
301
300
|
)
|
|
302
301
|
if prompt_audio:
|
|
303
302
|
await self._play_audio(str(guild_id), prompt_audio, suppress_active_window=True)
|
|
@@ -325,7 +324,8 @@ class VoiceCog(commands.Cog):
|
|
|
325
324
|
active_times="Duration in seconds the bot stays awake",
|
|
326
325
|
interrupt_threshold="Mic volume that interrupts (barges into) TTS playback (1000~10000)",
|
|
327
326
|
wake_sensitivity="Wake word match sensitivity (0.1~0.9, lower = easier to trigger but more false wakes)",
|
|
328
|
-
|
|
327
|
+
language="Language I listen and speak in (BCP-47, e.g. en-US)",
|
|
328
|
+
tts_voice="Pick a different voice within that language",
|
|
329
329
|
tts_enabled="Turn Text-to-Speech ON or OFF",
|
|
330
330
|
tts_speed="TTS playback speed multiplier, e.g. 1.3 for 1.3x (0.5~2.0)",
|
|
331
331
|
require_wake_word="Require your wake word before I listen (default ON) - turn OFF if you use push-to-talk",
|
|
@@ -334,6 +334,7 @@ class VoiceCog(commands.Cog):
|
|
|
334
334
|
active_times=active_times_autocomplete,
|
|
335
335
|
interrupt_threshold=interrupt_threshold_autocomplete,
|
|
336
336
|
wake_sensitivity=wake_sensitivity_autocomplete,
|
|
337
|
+
language=language_autocomplete,
|
|
337
338
|
tts_voice=tts_voice_autocomplete,
|
|
338
339
|
tts_enabled=tts_enabled_autocomplete,
|
|
339
340
|
tts_speed=tts_speed_autocomplete,
|
|
@@ -346,6 +347,7 @@ class VoiceCog(commands.Cog):
|
|
|
346
347
|
active_times: int = None,
|
|
347
348
|
interrupt_threshold: int = None,
|
|
348
349
|
wake_sensitivity: float = None,
|
|
350
|
+
language: str = None,
|
|
349
351
|
tts_voice: str = None,
|
|
350
352
|
tts_enabled: str = None,
|
|
351
353
|
tts_speed: float = None,
|
|
@@ -362,6 +364,7 @@ class VoiceCog(commands.Cog):
|
|
|
362
364
|
and active_times is None
|
|
363
365
|
and interrupt_threshold is None
|
|
364
366
|
and wake_sensitivity is None
|
|
367
|
+
and language is None
|
|
365
368
|
and tts_voice is None
|
|
366
369
|
and tts_enabled is None
|
|
367
370
|
and tts_speed is None
|
|
@@ -371,7 +374,8 @@ class VoiceCog(commands.Cog):
|
|
|
371
374
|
curr_timer = self.bot_settings.get("active_timer", 60)
|
|
372
375
|
curr_interrupt_thresh = self._vad_threshold(interaction.user.id)
|
|
373
376
|
curr_wake_sens = self._wake_threshold(interaction.user.id)
|
|
374
|
-
|
|
377
|
+
curr_lang = resolve_language()
|
|
378
|
+
curr_tts = self.bot_settings.get("tts_voice") or default_voice_for(curr_lang)
|
|
375
379
|
curr_tts_on = "ON" if self.bot_settings.get("tts_enabled", True) else "OFF"
|
|
376
380
|
curr_tts_speed = self.bot_settings.get("tts_speed", 1.0)
|
|
377
381
|
curr_required = self._wake_word_required(interaction.user.id)
|
|
@@ -382,6 +386,7 @@ class VoiceCog(commands.Cog):
|
|
|
382
386
|
embed.add_field(name="⏱️ Active Time", value=f"`{curr_timer}s`", inline=False)
|
|
383
387
|
embed.add_field(name="🎯 Wake Sensitivity", value=f"`{curr_wake_sens}`", inline=False)
|
|
384
388
|
embed.add_field(name="🔊 Interrupt Threshold", value=f"`{curr_interrupt_thresh}`", inline=False)
|
|
389
|
+
embed.add_field(name="🌐 Language", value=f"`{curr_lang}`", inline=False)
|
|
385
390
|
embed.add_field(name="🗣️ TTS Voice", value=f"`{curr_tts}`", inline=False)
|
|
386
391
|
embed.add_field(name="🔊 TTS Enabled", value=f"`{curr_tts_on}`", inline=False)
|
|
387
392
|
embed.add_field(name="⏩ TTS Speed", value=f"`{curr_tts_speed}x`", inline=False)
|
|
@@ -420,6 +425,13 @@ class VoiceCog(commands.Cog):
|
|
|
420
425
|
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
421
426
|
self.logger.warning(f"Node.js wake-threshold sync failed for {interaction.user.id}: {e}")
|
|
422
427
|
updated.append(f"(⚠️ Node.js Sync Failed: {e})")
|
|
428
|
+
if language is not None:
|
|
429
|
+
self.bot_settings["language"] = language
|
|
430
|
+
self.bot_settings["tts_voice"] = default_voice_for(language)
|
|
431
|
+
updated.append(
|
|
432
|
+
f"🌐 Language: `{language}` (voice set to `{self.bot_settings['tts_voice']}`; "
|
|
433
|
+
"run `lgy restart` to apply it to speech recognition)"
|
|
434
|
+
)
|
|
423
435
|
if tts_voice is not None:
|
|
424
436
|
self.bot_settings["tts_voice"] = tts_voice
|
|
425
437
|
updated.append(f"🗣️ TTS Voice: `{tts_voice}`")
|
|
@@ -532,7 +544,6 @@ class VoiceCog(commands.Cog):
|
|
|
532
544
|
return
|
|
533
545
|
|
|
534
546
|
import difflib
|
|
535
|
-
import re
|
|
536
547
|
|
|
537
548
|
# Wake detection is Node's Rustpotter detector's job - no text-similarity fallback.
|
|
538
549
|
is_waking_up = bool(data.get("wake_confirmed"))
|
|
@@ -627,7 +638,7 @@ class VoiceCog(commands.Cog):
|
|
|
627
638
|
if not text_to_ai:
|
|
628
639
|
self.logger.debug("STT: isolated wake word handled via direct TTS")
|
|
629
640
|
if self.bot_settings.get("tts_enabled", True):
|
|
630
|
-
audio_reply = await self.tts("Yes, I am listening.")
|
|
641
|
+
audio_reply = await self.tts("Yes, I am listening.", cache=True)
|
|
631
642
|
if audio_reply:
|
|
632
643
|
await self._play_audio(str(guild_id), audio_reply)
|
|
633
644
|
return
|
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": "",
|
|
@@ -162,8 +165,10 @@ session_manager = SessionManager(DATA_DIR)
|
|
|
162
165
|
|
|
163
166
|
def allowed(user_id, platform: str = "discord") -> bool:
|
|
164
167
|
if platform == "telegram":
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
# Anyone who knows a Telegram bot's username can DM it, and there is no channel scope to
|
|
169
|
+
# fall back on, so an empty list must mean nobody rather than everyone.
|
|
170
|
+
return user_id in TELEGRAM_ALLOWED_IDS
|
|
171
|
+
if platform == "slack":
|
|
167
172
|
ids = SLACK_ALLOWED_IDS
|
|
168
173
|
user_id = str(user_id) # Slack IDs are strings, not ints like Discord/Telegram
|
|
169
174
|
else:
|
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,
|
package/src/main_telegram.py
CHANGED
|
@@ -6,7 +6,15 @@ from pathlib import Path
|
|
|
6
6
|
from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, Update
|
|
7
7
|
from telegram.ext import Application, ApplicationBuilder, CallbackQueryHandler, CommandHandler, MessageHandler, filters
|
|
8
8
|
|
|
9
|
-
from config import
|
|
9
|
+
from config import (
|
|
10
|
+
TELEGRAM_ALLOWED_IDS,
|
|
11
|
+
TELEGRAM_TOKEN,
|
|
12
|
+
allowed,
|
|
13
|
+
bot_settings,
|
|
14
|
+
logger,
|
|
15
|
+
save_bot_settings,
|
|
16
|
+
session_manager,
|
|
17
|
+
)
|
|
10
18
|
from core.atomic_io import atomic_write_json, safe_load_json
|
|
11
19
|
from handlers.message_router import handle_message
|
|
12
20
|
from messengers.registry import register_adapter
|
|
@@ -252,6 +260,14 @@ async def run_telegram(stop_event: asyncio.Event) -> None:
|
|
|
252
260
|
logger.critical("Missing TELEGRAM_TOKEN - set telegram_token in lgy.json first.")
|
|
253
261
|
return
|
|
254
262
|
|
|
263
|
+
if not TELEGRAM_ALLOWED_IDS:
|
|
264
|
+
logger.critical(
|
|
265
|
+
"No telegram_allowed_user_ids configured - refusing to start. A Telegram bot is "
|
|
266
|
+
"reachable by anyone who knows its username, so run 'lgy setup' and register your "
|
|
267
|
+
"user ID (message @userinfobot to find it)."
|
|
268
|
+
)
|
|
269
|
+
return
|
|
270
|
+
|
|
255
271
|
app = build_application()
|
|
256
272
|
logger.info("✅ Telegram bot starting (polling mode)...")
|
|
257
273
|
await app.initialize()
|
|
@@ -7,7 +7,7 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
import discord
|
|
9
9
|
|
|
10
|
-
from config import logger
|
|
10
|
+
from config import allowed, logger
|
|
11
11
|
from messengers.base import (
|
|
12
12
|
IncomingAttachment,
|
|
13
13
|
IncomingMessage,
|
|
@@ -21,6 +21,14 @@ from messengers.base import (
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class _ErrorLoggingView(discord.ui.View):
|
|
24
|
+
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
|
25
|
+
# discord.py lets anyone who can see the message press the button, so the allow-list has
|
|
26
|
+
# to be applied here as well as on the inbound message path.
|
|
27
|
+
if allowed(interaction.user.id):
|
|
28
|
+
return True
|
|
29
|
+
await interaction.response.send_message("⛔ You are not allowed to use this bot.", ephemeral=True)
|
|
30
|
+
return False
|
|
31
|
+
|
|
24
32
|
async def on_error(self, interaction: discord.Interaction, error: Exception, item) -> None:
|
|
25
33
|
logger.exception(f"Discord button interaction error: {error}")
|
|
26
34
|
error_msg = "⚠️ **An error occurred while processing this button.** Please try again later or check the logs."
|
|
@@ -12,7 +12,7 @@ from slack_bolt.app.async_app import AsyncApp
|
|
|
12
12
|
from slack_sdk.errors import SlackApiError
|
|
13
13
|
from slack_sdk.web.async_client import AsyncWebClient
|
|
14
14
|
|
|
15
|
-
from config import logger, session_manager
|
|
15
|
+
from config import allowed, logger, session_manager
|
|
16
16
|
from messengers.base import (
|
|
17
17
|
IncomingAttachment,
|
|
18
18
|
IncomingMessage,
|
|
@@ -222,12 +222,28 @@ class SlackAdapter(MessengerAdapter):
|
|
|
222
222
|
actions = body.get("actions") or []
|
|
223
223
|
if not actions:
|
|
224
224
|
return
|
|
225
|
+
if not await self._reject_unauthorized(body):
|
|
226
|
+
return
|
|
225
227
|
action_id = actions[0].get("action_id")
|
|
226
228
|
handler = self._callbacks.pop(action_id, None)
|
|
227
229
|
if handler is None:
|
|
228
230
|
return # expired/unknown action - nothing to do, Bolt already acked
|
|
229
231
|
await handler(body, self.client)
|
|
230
232
|
|
|
233
|
+
async def _reject_unauthorized(self, body: dict) -> bool:
|
|
234
|
+
user_id = (body.get("user") or {}).get("id")
|
|
235
|
+
if allowed(user_id, "slack"):
|
|
236
|
+
return True
|
|
237
|
+
channel = (body.get("channel") or {}).get("id")
|
|
238
|
+
if channel and user_id:
|
|
239
|
+
try:
|
|
240
|
+
await self.client.chat_postEphemeral(
|
|
241
|
+
channel=channel, user=user_id, text="⛔ You are not allowed to use this bot."
|
|
242
|
+
)
|
|
243
|
+
except SlackApiError as e:
|
|
244
|
+
logger.warning(f"Failed to notify unauthorized Slack user {user_id}: {e}")
|
|
245
|
+
return False
|
|
246
|
+
|
|
231
247
|
async def handle_view_submission(self, body: dict) -> None:
|
|
232
248
|
callback_id = (body.get("view") or {}).get("callback_id")
|
|
233
249
|
handler = self._view_callbacks.pop(callback_id, None)
|
|
@@ -14,7 +14,7 @@ from telegram.constants import ChatAction
|
|
|
14
14
|
from telegram.error import TelegramError
|
|
15
15
|
from telegram.ext import ContextTypes
|
|
16
16
|
|
|
17
|
-
from config import logger
|
|
17
|
+
from config import allowed, logger
|
|
18
18
|
from messengers.base import (
|
|
19
19
|
IncomingAttachment,
|
|
20
20
|
IncomingMessage,
|
|
@@ -162,6 +162,9 @@ class TelegramAdapter(MessengerAdapter):
|
|
|
162
162
|
query = update.callback_query
|
|
163
163
|
if query is None or query.data is None:
|
|
164
164
|
return
|
|
165
|
+
if query.from_user is None or not allowed(query.from_user.id, "telegram"):
|
|
166
|
+
await query.answer("You are not allowed to use this bot.", show_alert=True)
|
|
167
|
+
return
|
|
165
168
|
handler = self._callbacks.pop(query.data, None)
|
|
166
169
|
if handler is None:
|
|
167
170
|
await query.answer("This action has expired.", show_alert=True)
|
|
@@ -6,9 +6,105 @@ 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
|
+
TTS_VOICES = [
|
|
11
|
+
"en-US-AriaNeural",
|
|
12
|
+
"en-US-GuyNeural",
|
|
13
|
+
"en-US-AnaNeural",
|
|
14
|
+
"en-US-ChristopherNeural",
|
|
15
|
+
"en-US-EricNeural",
|
|
16
|
+
"en-US-MichelleNeural",
|
|
17
|
+
"en-US-RogerNeural",
|
|
18
|
+
"en-GB-SoniaNeural",
|
|
19
|
+
"en-GB-RyanNeural",
|
|
20
|
+
"en-AU-NatashaNeural",
|
|
21
|
+
"en-AU-WilliamMultilingualNeural",
|
|
22
|
+
"ko-KR-SunHiNeural",
|
|
23
|
+
"ko-KR-InJoonNeural",
|
|
24
|
+
"ja-JP-NanamiNeural",
|
|
25
|
+
"ja-JP-KeitaNeural",
|
|
26
|
+
"zh-CN-XiaoxiaoNeural",
|
|
27
|
+
"zh-CN-YunxiNeural",
|
|
28
|
+
"fr-FR-DeniseNeural",
|
|
29
|
+
"de-DE-KatjaNeural",
|
|
30
|
+
"es-ES-ElviraNeural",
|
|
31
|
+
"it-IT-ElsaNeural",
|
|
32
|
+
"pt-BR-FranciscaNeural",
|
|
33
|
+
"ru-RU-SvetlanaNeural",
|
|
34
|
+
"hi-IN-SwaraNeural",
|
|
35
|
+
"id-ID-GadisNeural",
|
|
36
|
+
"vi-VN-HoaiMyNeural",
|
|
37
|
+
"th-TH-PremwadeeNeural",
|
|
38
|
+
"tr-TR-EmelNeural",
|
|
39
|
+
"pl-PL-ZofiaNeural",
|
|
40
|
+
"nl-NL-ColetteNeural",
|
|
41
|
+
"ar-SA-ZariyahNeural",
|
|
42
|
+
]
|
|
9
43
|
|
|
10
|
-
|
|
11
|
-
|
|
44
|
+
LANGUAGES = list(dict.fromkeys(v.rsplit("-", 1)[0] for v in TTS_VOICES))
|
|
45
|
+
|
|
46
|
+
DEFAULT_LANGUAGE = "en-US"
|
|
47
|
+
|
|
48
|
+
# Ordered: the first script reaching the share threshold wins, so Hangul beats Latin in mixed
|
|
49
|
+
# Korean/English text, and kana beats Han in Japanese (which is written with both).
|
|
50
|
+
SCRIPT_PATTERNS = [
|
|
51
|
+
("ko", re.compile(r"[가-힣]")),
|
|
52
|
+
("ja", re.compile(r"[ぁ-ゖァ-ヺ]")),
|
|
53
|
+
("ru", re.compile(r"[\u0400-\u04ff]")),
|
|
54
|
+
("ar", re.compile(r"[\u0600-\u06ff]")),
|
|
55
|
+
("th", re.compile(r"[\u0e00-\u0e7f]")),
|
|
56
|
+
("hi", re.compile(r"[\u0900-\u097f]")),
|
|
57
|
+
("zh", re.compile(r"[\u4e00-\u9fff]")),
|
|
58
|
+
("en", re.compile(r"[a-zA-Z]")),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
SCRIPT_SHARE = 0.3
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def detect_script(text: str) -> str | None:
|
|
65
|
+
counts = {tag: len(pattern.findall(text)) for tag, pattern in SCRIPT_PATTERNS}
|
|
66
|
+
total = sum(counts.values())
|
|
67
|
+
if not total:
|
|
68
|
+
return None
|
|
69
|
+
for tag, _ in SCRIPT_PATTERNS:
|
|
70
|
+
if counts[tag] / total >= SCRIPT_SHARE:
|
|
71
|
+
return tag
|
|
72
|
+
return max(counts, key=counts.get)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def default_voice_for(tag: str) -> str:
|
|
76
|
+
for voice in TTS_VOICES:
|
|
77
|
+
if voice.startswith(f"{tag}-"):
|
|
78
|
+
return voice
|
|
79
|
+
if "-" in tag:
|
|
80
|
+
return default_voice_for(tag.split("-")[0])
|
|
81
|
+
return TTS_VOICES[0]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_language() -> str:
|
|
85
|
+
configured = bot_settings.get("language")
|
|
86
|
+
if configured:
|
|
87
|
+
return configured
|
|
88
|
+
voice = bot_settings.get("tts_voice") or TTS_VOICE or ""
|
|
89
|
+
return voice.rsplit("-", 1)[0] if voice.count("-") >= 2 else DEFAULT_LANGUAGE
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def voice_for(text: str) -> str:
|
|
93
|
+
# Latin script covers dozens of languages, so a configured en-GB voice must survive a bare
|
|
94
|
+
# "en" detection instead of being reset to the en-US default.
|
|
95
|
+
configured = bot_settings.get("tts_voice") or TTS_VOICE
|
|
96
|
+
script = detect_script(text)
|
|
97
|
+
if script is None or configured.startswith(f"{script}-"):
|
|
98
|
+
return configured
|
|
99
|
+
return default_voice_for(script)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Synthesising a prompt costs a network round trip that lands directly in the wake-word
|
|
103
|
+
# response time, so the fixed ones are kept rather than rebuilt.
|
|
104
|
+
_fixed_phrase_cache: dict[tuple[str, str, str], bytes] = {}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def tts(text: str, voice: str = None, cache: bool = False) -> bytes | None:
|
|
12
108
|
try:
|
|
13
109
|
import edge_tts
|
|
14
110
|
|
|
@@ -17,29 +113,25 @@ async def tts(text: str, voice: str = None) -> bytes | None:
|
|
|
17
113
|
clean = re.sub(r"\n+", ". ", clean).strip()[:800]
|
|
18
114
|
if not clean:
|
|
19
115
|
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
116
|
|
|
33
117
|
speed = bot_settings.get("tts_speed", 1.0)
|
|
34
118
|
pct = round((speed - 1.0) * 100)
|
|
35
119
|
rate = f"{'+' if pct >= 0 else ''}{pct}%"
|
|
36
120
|
|
|
121
|
+
active_voice = voice or voice_for(clean)
|
|
122
|
+
|
|
123
|
+
key = (clean, active_voice, rate)
|
|
124
|
+
if cache and key in _fixed_phrase_cache:
|
|
125
|
+
return _fixed_phrase_cache[key]
|
|
126
|
+
|
|
37
127
|
communicate = edge_tts.Communicate(clean, active_voice, rate=rate)
|
|
38
128
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
|
39
129
|
tmp = f.name
|
|
40
130
|
await communicate.save(tmp)
|
|
41
131
|
data = Path(tmp).read_bytes()
|
|
42
132
|
os.unlink(tmp)
|
|
133
|
+
if cache:
|
|
134
|
+
_fixed_phrase_cache[key] = data
|
|
43
135
|
return data
|
|
44
136
|
except Exception as e:
|
|
45
137
|
logger.exception(f"TTS error: {e}")
|
|
@@ -58,7 +150,7 @@ async def stt(audio_bytes: bytes) -> str | None:
|
|
|
58
150
|
with sr.AudioFile(io.BytesIO(audio_bytes)) as source:
|
|
59
151
|
audio = r.record(source)
|
|
60
152
|
try:
|
|
61
|
-
return r.recognize_google(audio, language=
|
|
153
|
+
return r.recognize_google(audio, language=resolve_language())
|
|
62
154
|
except sr.UnknownValueError:
|
|
63
155
|
return None
|
|
64
156
|
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
|
+
// edge-tts voice names are <lang>-<REGION>-<Name>, so a config predating the language setting
|
|
9
|
+
// 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,8 @@ function flacEncode(wavBuffer) {
|
|
|
29
40
|
});
|
|
30
41
|
}
|
|
31
42
|
|
|
32
|
-
async function googleSTT(wavBuffer, lang =
|
|
43
|
+
async function googleSTT(wavBuffer, lang = LANGUAGE) {
|
|
44
|
+
const startedAt = Date.now();
|
|
33
45
|
let flacBuffer;
|
|
34
46
|
try {
|
|
35
47
|
flacBuffer = await flacEncode(wavBuffer);
|
|
@@ -38,6 +50,8 @@ async function googleSTT(wavBuffer, lang = 'ko-KR') {
|
|
|
38
50
|
return null;
|
|
39
51
|
}
|
|
40
52
|
|
|
53
|
+
const encodedAt = Date.now();
|
|
54
|
+
|
|
41
55
|
let res;
|
|
42
56
|
try {
|
|
43
57
|
res = await fetch(
|
|
@@ -54,6 +68,11 @@ async function googleSTT(wavBuffer, lang = 'ko-KR') {
|
|
|
54
68
|
}
|
|
55
69
|
|
|
56
70
|
const raw = await res.text();
|
|
71
|
+
console.debug(
|
|
72
|
+
`[STT] ${Math.round(wavBuffer.length / 192)}ms audio: flac ${encodedAt - startedAt}ms, ` +
|
|
73
|
+
`google ${Date.now() - encodedAt}ms`,
|
|
74
|
+
);
|
|
75
|
+
console.debug('[STT] raw:', raw.trim().replace(/\n/g, ' | '));
|
|
57
76
|
// Response is newline-delimited JSON, one object per line.
|
|
58
77
|
for (const line of raw.trim().split('\n')) {
|
|
59
78
|
if (!line) continue;
|