linkgravity 1.5.0 → 1.5.1
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/voice_cog.py +33 -3
- package/src/handlers/thread_reply.py +1 -1
- package/voice-service/index.js +14 -851
package/package.json
CHANGED
package/src/cogs/voice_cog.py
CHANGED
|
@@ -153,6 +153,33 @@ class VoiceCog(commands.Cog):
|
|
|
153
153
|
opts.append(app_commands.Choice(name=other_val, value=other_val.lower()))
|
|
154
154
|
return opts
|
|
155
155
|
|
|
156
|
+
async def require_wake_word_autocomplete(
|
|
157
|
+
self, interaction: discord.Interaction, current: str
|
|
158
|
+
) -> list[app_commands.Choice[str]]:
|
|
159
|
+
is_on = self._wake_word_required(interaction.user.id)
|
|
160
|
+
current_val = "ON" if is_on else "OFF"
|
|
161
|
+
other_val = "OFF" if is_on else "ON"
|
|
162
|
+
|
|
163
|
+
opts = []
|
|
164
|
+
if current.lower() in current_val.lower() or not current:
|
|
165
|
+
opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val.lower()))
|
|
166
|
+
if current.lower() in other_val.lower():
|
|
167
|
+
opts.append(app_commands.Choice(name=other_val, value=other_val.lower()))
|
|
168
|
+
return opts
|
|
169
|
+
|
|
170
|
+
async def tts_speed_autocomplete(
|
|
171
|
+
self, interaction: discord.Interaction, current: str
|
|
172
|
+
) -> list[app_commands.Choice[float]]:
|
|
173
|
+
current_val = float(self.bot_settings.get("tts_speed", 1.0))
|
|
174
|
+
opts = []
|
|
175
|
+
if str(current_val) in current or not current:
|
|
176
|
+
opts.append(app_commands.Choice(name=f"{current_val}x (current)", value=current_val))
|
|
177
|
+
|
|
178
|
+
for v in [0.75, 1.0, 1.25, 1.3, 1.5, 1.75, 2.0]:
|
|
179
|
+
if v != current_val and len(opts) < 25:
|
|
180
|
+
opts.append(app_commands.Choice(name=f"{v}x", value=v))
|
|
181
|
+
return opts
|
|
182
|
+
|
|
156
183
|
@app_commands.command(name="join", description="Summon the bot to your current voice channel")
|
|
157
184
|
async def cmd_join(self, interaction: discord.Interaction):
|
|
158
185
|
if not allowed(interaction.user.id):
|
|
@@ -268,6 +295,8 @@ class VoiceCog(commands.Cog):
|
|
|
268
295
|
threshold=threshold_autocomplete,
|
|
269
296
|
tts_voice=tts_voice_autocomplete,
|
|
270
297
|
tts_enabled=tts_enabled_autocomplete,
|
|
298
|
+
tts_speed=tts_speed_autocomplete,
|
|
299
|
+
require_wake_word=require_wake_word_autocomplete,
|
|
271
300
|
)
|
|
272
301
|
async def cmd_voice(
|
|
273
302
|
self,
|
|
@@ -591,9 +620,10 @@ class VoiceCog(commands.Cog):
|
|
|
591
620
|
|
|
592
621
|
from utils.utils import generate_thread_title, update_agy_conversation_title
|
|
593
622
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
623
|
+
if thread.name.startswith("Session-"):
|
|
624
|
+
new_title = await generate_thread_title(text_to_ai, raw_ans)
|
|
625
|
+
await get_adapter_for_platform("discord").rename_conversation(thread, new_title)
|
|
626
|
+
await update_agy_conversation_title(new_conv_id, new_title)
|
|
597
627
|
else:
|
|
598
628
|
logger.debug("Voice: calling agy_send...")
|
|
599
629
|
raw_ans = await self.agy_send(
|
|
@@ -74,7 +74,7 @@ async def handle_pending_session(
|
|
|
74
74
|
await stream_task
|
|
75
75
|
|
|
76
76
|
response_text = result_text
|
|
77
|
-
if adapter.can_rename(thread):
|
|
77
|
+
if adapter.can_rename(thread) and thread.name.startswith("Session-"):
|
|
78
78
|
new_title = await generate_thread_title(content, response_text)
|
|
79
79
|
await adapter.rename_conversation(thread, new_title)
|
|
80
80
|
await update_agy_conversation_title(new_conv_id, new_title)
|
package/voice-service/index.js
CHANGED
|
@@ -1,882 +1,45 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const originalLog = console.log;
|
|
4
|
-
const originalError = console.error;
|
|
5
|
-
|
|
6
|
-
function getTimestamp() {
|
|
7
|
-
const now = new Date();
|
|
8
|
-
const offset = now.getTimezoneOffset() * 60000;
|
|
9
|
-
const localTime = new Date(now.getTime() - offset);
|
|
10
|
-
return localTime.toISOString().replace('T', ' ').substring(0, 19);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
console.log = function (...args) {
|
|
14
|
-
originalLog(`${getTimestamp()} INFO Voice:`, ...args);
|
|
15
|
-
};
|
|
16
|
-
console.error = function (...args) {
|
|
17
|
-
originalError(`${getTimestamp()} ERROR Voice:`, ...args);
|
|
18
|
-
};
|
|
19
|
-
const {
|
|
20
|
-
joinVoiceChannel,
|
|
21
|
-
createAudioPlayer,
|
|
22
|
-
createAudioResource,
|
|
23
|
-
AudioPlayerStatus,
|
|
24
|
-
EndBehaviorType,
|
|
25
|
-
VoiceConnectionStatus,
|
|
26
|
-
} = require('@discordjs/voice');
|
|
1
|
+
require('./logger'); // side-effect: timestamps console.log/error - must load before anything else logs
|
|
27
2
|
|
|
3
|
+
const { Client, GatewayIntentBits, Events } = require('discord.js');
|
|
28
4
|
const express = require('express');
|
|
29
|
-
const axios = require('axios');
|
|
30
|
-
const os = require('os');
|
|
31
|
-
const prism = require('prism-media');
|
|
32
|
-
const fs = require('fs');
|
|
33
|
-
const path = require('path');
|
|
34
|
-
const { Readable } = require('stream');
|
|
35
|
-
const { spawn } = require('child_process');
|
|
36
|
-
const ffmpegPath = require('ffmpeg-static');
|
|
37
5
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (fs.existsSync(aglJsonPath)) {
|
|
42
|
-
aglConfig = JSON.parse(fs.readFileSync(aglJsonPath, 'utf8'));
|
|
43
|
-
}
|
|
44
|
-
} catch (e) {
|
|
45
|
-
console.error('Failed to load lgy.json:', e.message);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
process.env.DISCORD_TOKEN =
|
|
49
|
-
aglConfig.discord_token || aglConfig.DISCORD_TOKEN || process.env.DISCORD_TOKEN;
|
|
6
|
+
const { aglConfig } = require('./config');
|
|
7
|
+
const state = require('./state');
|
|
8
|
+
const { registerRoutes } = require('./routes');
|
|
50
9
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
process.env.HTTPS_PROXY = '';
|
|
55
|
-
process.env.PYTHON_HOST = '';
|
|
10
|
+
if (aglConfig.voice_threshold) {
|
|
11
|
+
state.runtime.vadThreshold = parseInt(aglConfig.voice_threshold) || 3000;
|
|
12
|
+
}
|
|
56
13
|
|
|
57
14
|
const app = express();
|
|
58
15
|
app.use(express.json({ limit: '50mb' }));
|
|
59
16
|
|
|
60
|
-
let vadThreshold = 3000;
|
|
61
|
-
if (aglConfig.voice_threshold) {
|
|
62
|
-
vadThreshold = parseInt(aglConfig.voice_threshold) || 3000;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
17
|
const client = new Client({
|
|
66
18
|
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
|
|
67
19
|
});
|
|
68
20
|
|
|
69
|
-
const connections = new Map();
|
|
70
|
-
const players = new Map();
|
|
71
|
-
|
|
72
|
-
function stereoToMono(buffer) {
|
|
73
|
-
// Discord voice receive is 48kHz stereo; everything downstream (WAV, Rustpotter) expects mono.
|
|
74
|
-
const samples = buffer.length >> 2; // 2 bytes/sample * 2 channels
|
|
75
|
-
const mono = Buffer.alloc(samples * 2);
|
|
76
|
-
for (let i = 0; i < samples; i++) {
|
|
77
|
-
const l = buffer.readInt16LE(i * 4);
|
|
78
|
-
const r = buffer.readInt16LE(i * 4 + 2);
|
|
79
|
-
mono.writeInt16LE((l + r) >> 1, i * 2);
|
|
80
|
-
}
|
|
81
|
-
return mono;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function createWavHeader(dataLength, sampleRate = 48000, channels = 1, bitDepth = 16) {
|
|
85
|
-
const buffer = Buffer.alloc(44);
|
|
86
|
-
buffer.write('RIFF', 0);
|
|
87
|
-
buffer.writeUInt32LE(36 + dataLength, 4);
|
|
88
|
-
buffer.write('WAVE', 8);
|
|
89
|
-
buffer.write('fmt ', 12);
|
|
90
|
-
buffer.writeUInt32LE(16, 16);
|
|
91
|
-
buffer.writeUInt16LE(1, 20);
|
|
92
|
-
buffer.writeUInt16LE(channels, 22);
|
|
93
|
-
buffer.writeUInt32LE(sampleRate, 24);
|
|
94
|
-
buffer.writeUInt32LE(sampleRate * channels * (bitDepth / 8), 28);
|
|
95
|
-
buffer.writeUInt16LE(channels * (bitDepth / 8), 32);
|
|
96
|
-
buffer.writeUInt16LE(bitDepth, 34);
|
|
97
|
-
buffer.write('data', 36);
|
|
98
|
-
buffer.writeUInt32LE(dataLength, 40);
|
|
99
|
-
return buffer;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
21
|
client.once(Events.ClientReady, () => {
|
|
103
22
|
console.log(`🎤 Node.js Voice Microservice is online as ${client.user.tag}`);
|
|
104
23
|
});
|
|
105
24
|
|
|
106
|
-
|
|
107
|
-
// (recognize_google) ships as its default; publicly known but could be rate-limited/changed anytime.
|
|
108
|
-
const GOOGLE_STT_KEY = 'AIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgw';
|
|
109
|
-
|
|
110
|
-
function flacEncode(wavBuffer) {
|
|
111
|
-
return new Promise((resolve, reject) => {
|
|
112
|
-
const ff = spawn(ffmpegPath, [
|
|
113
|
-
'-hide_banner',
|
|
114
|
-
'-loglevel',
|
|
115
|
-
'error',
|
|
116
|
-
'-i',
|
|
117
|
-
'pipe:0',
|
|
118
|
-
'-f',
|
|
119
|
-
'flac',
|
|
120
|
-
'pipe:1',
|
|
121
|
-
]);
|
|
122
|
-
const out = [];
|
|
123
|
-
ff.stdout.on('data', (d) => out.push(d));
|
|
124
|
-
ff.stderr.on('data', () => {}); // -loglevel error already keeps this quiet in the normal case
|
|
125
|
-
ff.on('error', (err) => reject(new Error(`ffmpeg-static failed to run (${err.message})`)));
|
|
126
|
-
ff.on('close', (code) => {
|
|
127
|
-
if (code !== 0) return reject(new Error(`ffmpeg exited with code ${code}`));
|
|
128
|
-
resolve(Buffer.concat(out));
|
|
129
|
-
});
|
|
130
|
-
ff.stdin.write(wavBuffer);
|
|
131
|
-
ff.stdin.end();
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async function googleSTT(wavBuffer, lang = 'ko-KR') {
|
|
136
|
-
let flacBuffer;
|
|
137
|
-
try {
|
|
138
|
-
flacBuffer = await flacEncode(wavBuffer);
|
|
139
|
-
} catch (err) {
|
|
140
|
-
console.error('[STT] FLAC encode failed:', err.message);
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
let res;
|
|
145
|
-
try {
|
|
146
|
-
res = await fetch(
|
|
147
|
-
`https://www.google.com/speech-api/v2/recognize?output=json&client=chromium&lang=${encodeURIComponent(lang)}&key=${GOOGLE_STT_KEY}`,
|
|
148
|
-
{
|
|
149
|
-
method: 'POST',
|
|
150
|
-
headers: { 'Content-Type': 'audio/x-flac; rate=48000' },
|
|
151
|
-
body: flacBuffer,
|
|
152
|
-
},
|
|
153
|
-
);
|
|
154
|
-
} catch (err) {
|
|
155
|
-
console.error('[STT] Request to Google STT failed:', err.message);
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const raw = await res.text();
|
|
160
|
-
// Response is newline-delimited JSON, one object per line.
|
|
161
|
-
for (const line of raw.trim().split('\n')) {
|
|
162
|
-
if (!line) continue;
|
|
163
|
-
try {
|
|
164
|
-
const obj = JSON.parse(line);
|
|
165
|
-
const transcript = obj.result?.[0]?.alternative?.[0]?.transcript;
|
|
166
|
-
if (transcript) return transcript.trim();
|
|
167
|
-
} catch (e) {
|
|
168
|
-
// not JSON / partial line - ignore
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return null;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const audioQueues = new Map();
|
|
175
|
-
const isPlaying = new Map();
|
|
176
|
-
|
|
177
|
-
function interruptTTS(guildId) {
|
|
178
|
-
const player = players.get(guildId);
|
|
179
|
-
let interrupted = false;
|
|
180
|
-
|
|
181
|
-
if (audioQueues.has(guildId)) {
|
|
182
|
-
// Queue now holds in-memory audio Buffers (see /play), not
|
|
183
|
-
// filepaths, so there's nothing on disk to clean up here.
|
|
184
|
-
audioQueues.set(guildId, []);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (player && player.state.status !== AudioPlayerStatus.Idle) {
|
|
188
|
-
player.stop();
|
|
189
|
-
console.log(`[VAD] Interrupted TTS in guild ${guildId}`);
|
|
190
|
-
interrupted = true;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
isPlaying.set(guildId, false);
|
|
194
|
-
return interrupted;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const activeStreams = new Map();
|
|
198
|
-
|
|
199
|
-
// user_id -> recording wake-word samples right now; routes to /enroll_sample instead of STT.
|
|
200
|
-
const enrollingUsers = new Set();
|
|
201
|
-
|
|
202
|
-
// guild_id -> ms epoch until the "awake, skip wake word" window closes (set via /set_active).
|
|
203
|
-
const activeUntil = new Map();
|
|
204
|
-
|
|
205
|
-
// user_id -> opted out of wake-word gating via /sound - scoped per-user, unlike activeUntil.
|
|
206
|
-
const wakeWordOptedOut = new Set();
|
|
207
|
-
|
|
208
|
-
function isGuildActive(guildId) {
|
|
209
|
-
return Date.now() < (activeUntil.get(guildId) || 0);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Rustpotter wake-word detection runs entirely in-process here, no Python round trip.
|
|
213
|
-
const WAKE_REF_DIR = path.join(os.homedir(), '.gemini', 'linkgravity', 'wake_refs');
|
|
214
|
-
|
|
215
|
-
let rustpotterModPromise = null;
|
|
216
|
-
function loadRustpotterModule() {
|
|
217
|
-
if (!rustpotterModPromise) {
|
|
218
|
-
rustpotterModPromise = (async () => {
|
|
219
|
-
// Node's ESM loader needs the explicit entry file; "rustpotter-web" (not "-slim")
|
|
220
|
-
// is used because it also exposes WakewordRefCreator, used by /build_wakeword below.
|
|
221
|
-
const mod = await import('rustpotter-web/rustpotter_wasm.js');
|
|
222
|
-
const wasmPath = require.resolve('rustpotter-web/rustpotter_wasm_bg.wasm');
|
|
223
|
-
mod.initSync(fs.readFileSync(wasmPath));
|
|
224
|
-
return mod;
|
|
225
|
-
})();
|
|
226
|
-
}
|
|
227
|
-
return rustpotterModPromise;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// userId -> { rustpotter, samplesPerFrame, residual: Int16Array }
|
|
231
|
-
const detectorCache = new Map();
|
|
232
|
-
|
|
233
|
-
// Wake-word confirm cutoff - must stay well above ~0.05 (rustpotter's countdown never finalizes if noise/silence clears it too); 0.4 chosen after live use kept narrowly missing genuine hits just under 0.5.
|
|
234
|
-
const WAKE_MATCH_THRESHOLD = 0.4;
|
|
235
|
-
|
|
236
|
-
async function getDetectorForUser(userId) {
|
|
237
|
-
if (detectorCache.has(userId)) return detectorCache.get(userId);
|
|
238
|
-
|
|
239
|
-
const userDir = path.join(WAKE_REF_DIR, userId);
|
|
240
|
-
if (!fs.existsSync(userDir)) return null; // not enrolled
|
|
241
|
-
|
|
242
|
-
const rpwFile = fs.readdirSync(userDir).find((f) => f.endsWith('.rpw'));
|
|
243
|
-
if (!rpwFile) return null; // samples exist but .rpw build hasn't happened/failed - see _commit_enrollment
|
|
244
|
-
|
|
245
|
-
const mod = await loadRustpotterModule();
|
|
246
|
-
const config = mod.RustpotterConfig.new();
|
|
247
|
-
config.setSampleRate(48000);
|
|
248
|
-
config.setSampleFormat(mod.SampleFormat.i16);
|
|
249
|
-
config.setChannels(1);
|
|
250
|
-
// Must stay a real cutoff (not near-zero) for rustpotter's confirm-after-N-frames logic to finalize.
|
|
251
|
-
config.setThreshold(WAKE_MATCH_THRESHOLD);
|
|
252
|
-
config.setAveragedThreshold(0);
|
|
253
|
-
// Raised from default 1 so a candidate has to keep winning for a few frames before it's trusted.
|
|
254
|
-
config.setMinScores(4);
|
|
255
|
-
// Max (best of the 5 enrolled samples) beats Median here - real speech isn't consistent enough
|
|
256
|
-
// for Median's "middle sample must also score well" requirement; minScores(4) compensates.
|
|
257
|
-
config.setScoreMode(mod.ScoreMode.max);
|
|
258
|
-
|
|
259
|
-
const rustpotter = mod.Rustpotter.new(config);
|
|
260
|
-
rustpotter.addWakeword(rpwFile, fs.readFileSync(path.join(userDir, rpwFile)));
|
|
261
|
-
|
|
262
|
-
// Diagnostic-only twin, fed the same audio, purely so "no match" logs show a real closeness
|
|
263
|
-
// score - the real detector's own threshold hides sub-threshold scores entirely, and lowering
|
|
264
|
-
// its threshold isn't safe (near-zero means noise keeps resetting the confirm countdown).
|
|
265
|
-
// Never gates wake behavior; only entry.rustpotter above does.
|
|
266
|
-
const diagConfig = mod.RustpotterConfig.new();
|
|
267
|
-
diagConfig.setSampleRate(48000);
|
|
268
|
-
diagConfig.setSampleFormat(mod.SampleFormat.i16);
|
|
269
|
-
diagConfig.setChannels(1);
|
|
270
|
-
diagConfig.setThreshold(0.01);
|
|
271
|
-
diagConfig.setAveragedThreshold(0);
|
|
272
|
-
diagConfig.setMinScores(1);
|
|
273
|
-
diagConfig.setEager(true);
|
|
274
|
-
diagConfig.setScoreMode(mod.ScoreMode.max);
|
|
275
|
-
const diagRustpotter = mod.Rustpotter.new(diagConfig);
|
|
276
|
-
diagRustpotter.addWakeword(rpwFile, fs.readFileSync(path.join(userDir, rpwFile)));
|
|
277
|
-
|
|
278
|
-
const entry = {
|
|
279
|
-
rustpotter,
|
|
280
|
-
samplesPerFrame: rustpotter.getSamplesPerFrame(),
|
|
281
|
-
residual: new Int16Array(0),
|
|
282
|
-
diag: {
|
|
283
|
-
rustpotter: diagRustpotter,
|
|
284
|
-
samplesPerFrame: diagRustpotter.getSamplesPerFrame(),
|
|
285
|
-
residual: new Int16Array(0),
|
|
286
|
-
},
|
|
287
|
-
};
|
|
288
|
-
console.log(
|
|
289
|
-
`[Wake] Loaded detector for ${userId} from ${rpwFile}: samplesPerFrame=${entry.samplesPerFrame}`,
|
|
290
|
-
);
|
|
291
|
-
detectorCache.set(userId, entry);
|
|
292
|
-
return entry;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Feeds a PCM chunk to a detector frame-aligned via residual carryover (Rustpotter needs a
|
|
296
|
-
// genuinely continuous stream). Returns a detection if a complete frame in this chunk triggered one.
|
|
297
|
-
function feedPCMToDetector(entry, chunk) {
|
|
298
|
-
const incoming = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.length / 2);
|
|
299
|
-
let combined = incoming;
|
|
300
|
-
if (entry.residual.length) {
|
|
301
|
-
combined = new Int16Array(entry.residual.length + incoming.length);
|
|
302
|
-
combined.set(entry.residual, 0);
|
|
303
|
-
combined.set(incoming, entry.residual.length);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
let offset = 0;
|
|
307
|
-
let detection = null;
|
|
308
|
-
while (combined.length - offset >= entry.samplesPerFrame) {
|
|
309
|
-
const frame = combined.subarray(offset, offset + entry.samplesPerFrame);
|
|
310
|
-
const result = entry.rustpotter.processI16(frame);
|
|
311
|
-
if (result) detection = result;
|
|
312
|
-
offset += entry.samplesPerFrame;
|
|
313
|
-
}
|
|
314
|
-
entry.residual = combined.subarray(offset);
|
|
315
|
-
return detection;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function setupReceiver(connection, guildId) {
|
|
319
|
-
const receiver = connection.receiver;
|
|
320
|
-
|
|
321
|
-
receiver.speaking.removeAllListeners('start');
|
|
322
|
-
|
|
323
|
-
receiver.speaking.on('start', (userId) => {
|
|
324
|
-
if (client.user.id === userId) return;
|
|
325
|
-
|
|
326
|
-
if (activeStreams.get(userId)) {
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
activeStreams.set(userId, true);
|
|
330
|
-
|
|
331
|
-
let hasInterrupted = false;
|
|
332
|
-
|
|
333
|
-
const opusStream = receiver.subscribe(userId, {
|
|
334
|
-
end: {
|
|
335
|
-
behavior: EndBehaviorType.Manual,
|
|
336
|
-
},
|
|
337
|
-
});
|
|
338
|
-
const pcmStream = opusStream.pipe(
|
|
339
|
-
new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 }),
|
|
340
|
-
);
|
|
341
|
-
|
|
342
|
-
// Without a listener, an unhandled 'error' event here crashes the ENTIRE process on one bad packet.
|
|
343
|
-
opusStream.on('error', (err) => {
|
|
344
|
-
console.error(`[Voice] Opus stream error for ${userId}:`, err.message);
|
|
345
|
-
forceEndStream();
|
|
346
|
-
});
|
|
347
|
-
pcmStream.on('error', (err) => {
|
|
348
|
-
console.error(
|
|
349
|
-
`[Voice] Opus decode error for ${userId} (bad/corrupted packet):`,
|
|
350
|
-
err.message,
|
|
351
|
-
);
|
|
352
|
-
forceEndStream();
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
const chunks = [];
|
|
356
|
-
|
|
357
|
-
let hasEnded = false;
|
|
358
|
-
|
|
359
|
-
const forceEndStream = () => {
|
|
360
|
-
if (hasEnded) return;
|
|
361
|
-
try {
|
|
362
|
-
opusStream.destroy();
|
|
363
|
-
} catch (e) {}
|
|
364
|
-
try {
|
|
365
|
-
pcmStream.destroy();
|
|
366
|
-
} catch (e) {}
|
|
367
|
-
pcmStream.emit('end');
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
const maxDurationTimer = setTimeout(forceEndStream, 30000);
|
|
371
|
-
|
|
372
|
-
// Rustpotter runs in-process here, no Python round trip - detectors cached per user.
|
|
373
|
-
let wakeConfirmed = false;
|
|
374
|
-
let matchedWakeWord = null;
|
|
375
|
-
let detectorEntry = null;
|
|
376
|
-
let bestWakeScore = 0;
|
|
377
|
-
let bestWakeScoreName = null;
|
|
378
|
-
let bestDiagScore = 0;
|
|
379
|
-
let bestDiagScoreName = null;
|
|
380
|
-
|
|
381
|
-
if (!enrollingUsers.has(userId) && !isGuildActive(guildId)) {
|
|
382
|
-
getDetectorForUser(userId)
|
|
383
|
-
.then((entry) => {
|
|
384
|
-
if (entry) {
|
|
385
|
-
entry.rustpotter.reset();
|
|
386
|
-
entry.residual = new Int16Array(0);
|
|
387
|
-
entry.diag.rustpotter.reset();
|
|
388
|
-
entry.diag.residual = new Int16Array(0);
|
|
389
|
-
detectorEntry = entry;
|
|
390
|
-
}
|
|
391
|
-
})
|
|
392
|
-
.catch((err) =>
|
|
393
|
-
console.error(`[Wake] Failed to load detector for ${userId}:`, err.message),
|
|
394
|
-
);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Only runs during the active/awake window, not on every VAD sound - reuses the same
|
|
398
|
-
// googleSTT() call utterance-end uses anyway, just invoked earlier for live feedback.
|
|
399
|
-
const PARTIAL_INTERVAL_MS = 1500;
|
|
400
|
-
const PARTIAL_MIN_NEW_BYTES = 24000;
|
|
401
|
-
let lastPartialLength = 0;
|
|
402
|
-
let partialSent = false;
|
|
403
|
-
const partialTimer = setInterval(async () => {
|
|
404
|
-
if (hasEnded || !isSpeaking || enrollingUsers.has(userId)) return;
|
|
405
|
-
if (!isGuildActive(guildId)) return;
|
|
406
|
-
|
|
407
|
-
const currentLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
408
|
-
if (currentLength - lastPartialLength < PARTIAL_MIN_NEW_BYTES) return;
|
|
409
|
-
lastPartialLength = currentLength;
|
|
410
|
-
|
|
411
|
-
const windowPcm = Buffer.concat(chunks);
|
|
412
|
-
const wavHeader = createWavHeader(windowPcm.length);
|
|
413
|
-
const wavBuffer = Buffer.concat([wavHeader, windowPcm]);
|
|
414
|
-
|
|
415
|
-
const text = await googleSTT(wavBuffer);
|
|
416
|
-
partialSent = true;
|
|
417
|
-
fetch('http://127.0.0.1:18080/stt_partial', {
|
|
418
|
-
method: 'POST',
|
|
419
|
-
headers: { 'Content-Type': 'application/json' },
|
|
420
|
-
body: JSON.stringify({ guild_id: guildId, text }),
|
|
421
|
-
}).catch((err) =>
|
|
422
|
-
console.error(`[STT] Failed to send partial text to Python:`, err.message),
|
|
423
|
-
);
|
|
424
|
-
}, PARTIAL_INTERVAL_MS);
|
|
425
|
-
|
|
426
|
-
let bgNoiseRMS = 500;
|
|
427
|
-
let isSpeaking = false;
|
|
428
|
-
let silenceBytes = 0;
|
|
429
|
-
let silenceTimer = null;
|
|
430
|
-
|
|
431
|
-
pcmStream.on('data', (rawChunk) => {
|
|
432
|
-
if (hasEnded) return;
|
|
433
|
-
const chunk = stereoToMono(rawChunk); // see stereoToMono's comment - decoder gives real stereo now
|
|
434
|
-
|
|
435
|
-
let sumSquare = 0;
|
|
436
|
-
for (let i = 0; i < chunk.length; i += 2) {
|
|
437
|
-
const sample = chunk.readInt16LE(i);
|
|
438
|
-
sumSquare += sample * sample;
|
|
439
|
-
}
|
|
440
|
-
const rms = Math.sqrt(sumSquare / (chunk.length / 2));
|
|
441
|
-
|
|
442
|
-
const isBotPlaying = isPlaying.get(guildId) || false;
|
|
443
|
-
|
|
444
|
-
if (!hasInterrupted) {
|
|
445
|
-
const dynamicThreshold = isBotPlaying ? vadThreshold * 3 : vadThreshold;
|
|
446
|
-
if (rms > dynamicThreshold) {
|
|
447
|
-
if (interruptTTS(guildId)) {
|
|
448
|
-
console.log(
|
|
449
|
-
`[VAD] Loud voice detected (${Math.round(rms)}), interrupting TTS (Threshold: ${dynamicThreshold})`,
|
|
450
|
-
);
|
|
451
|
-
hasInterrupted = true;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
if (isPlaying.get(guildId)) {
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
chunks.push(chunk);
|
|
461
|
-
|
|
462
|
-
if (detectorEntry) {
|
|
463
|
-
const detection = feedPCMToDetector(detectorEntry, chunk);
|
|
464
|
-
if (detection && detection.getScore() > bestWakeScore) {
|
|
465
|
-
bestWakeScore = detection.getScore();
|
|
466
|
-
bestWakeScoreName = detection.getName();
|
|
467
|
-
}
|
|
468
|
-
const diagDetection = feedPCMToDetector(detectorEntry.diag, chunk);
|
|
469
|
-
if (diagDetection && diagDetection.getScore() > bestDiagScore) {
|
|
470
|
-
bestDiagScore = diagDetection.getScore();
|
|
471
|
-
bestDiagScoreName = diagDetection.getName();
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
if (!isSpeaking) {
|
|
476
|
-
bgNoiseRMS = bgNoiseRMS * 0.98 + rms * 0.02;
|
|
477
|
-
bgNoiseRMS = Math.max(50, Math.min(bgNoiseRMS, 3000));
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const threshold = Math.max(bgNoiseRMS * 2.0, 800);
|
|
481
|
-
|
|
482
|
-
if (rms > threshold) {
|
|
483
|
-
isSpeaking = true;
|
|
484
|
-
silenceBytes = 0;
|
|
485
|
-
} else {
|
|
486
|
-
if (isSpeaking) {
|
|
487
|
-
silenceBytes += chunk.length;
|
|
488
|
-
if (silenceBytes >= 76800) {
|
|
489
|
-
forceEndStream();
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
if (isSpeaking) {
|
|
496
|
-
if (silenceTimer) clearTimeout(silenceTimer);
|
|
497
|
-
silenceTimer = setTimeout(() => forceEndStream(), 800);
|
|
498
|
-
}
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
pcmStream.on('end', async () => {
|
|
502
|
-
if (hasEnded) return;
|
|
503
|
-
hasEnded = true;
|
|
504
|
-
clearTimeout(maxDurationTimer);
|
|
505
|
-
clearInterval(partialTimer);
|
|
506
|
-
if (silenceTimer) clearTimeout(silenceTimer);
|
|
507
|
-
|
|
508
|
-
activeStreams.delete(userId);
|
|
509
|
-
|
|
510
|
-
if (detectorEntry) {
|
|
511
|
-
// rustpotter needs MORE frames fed after a candidate
|
|
512
|
-
// match before it finalizes one (detection_countdown
|
|
513
|
-
// counts down from max_mfcc_frames/2 before confirming
|
|
514
|
-
// or discarding a partial match - see the Rust source's
|
|
515
|
-
// detector.rs). Live capture stops the instant the user
|
|
516
|
-
// stops talking, so without this, a genuine match
|
|
517
|
-
// candidate never gets the chance to finish counting
|
|
518
|
-
// down and is silently discarded - this is exactly why
|
|
519
|
-
// rustpotter-cli's own `test` command pads its input
|
|
520
|
-
// with 100 extra silent frames before processing (see
|
|
521
|
-
// its test.rs), and why testing the identical captured
|
|
522
|
-
// audio through the CLI scored well while this always
|
|
523
|
-
// failed live. Same fix here: flush the residual plus
|
|
524
|
-
// a few seconds of silence through the same detector
|
|
525
|
-
// before deciding pass/fail.
|
|
526
|
-
const paddingBuffer = Buffer.alloc(detectorEntry.samplesPerFrame * 100 * 2);
|
|
527
|
-
const paddingDetection = feedPCMToDetector(detectorEntry, paddingBuffer);
|
|
528
|
-
if (paddingDetection && paddingDetection.getScore() > bestWakeScore) {
|
|
529
|
-
bestWakeScore = paddingDetection.getScore();
|
|
530
|
-
bestWakeScoreName = paddingDetection.getName();
|
|
531
|
-
}
|
|
532
|
-
const diagPaddingBuffer = Buffer.alloc(
|
|
533
|
-
detectorEntry.diag.samplesPerFrame * 100 * 2,
|
|
534
|
-
);
|
|
535
|
-
const diagPaddingDetection = feedPCMToDetector(
|
|
536
|
-
detectorEntry.diag,
|
|
537
|
-
diagPaddingBuffer,
|
|
538
|
-
);
|
|
539
|
-
if (diagPaddingDetection && diagPaddingDetection.getScore() > bestDiagScore) {
|
|
540
|
-
bestDiagScore = diagPaddingDetection.getScore();
|
|
541
|
-
bestDiagScoreName = diagPaddingDetection.getName();
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// Real pass/fail uses bestWakeScore; bestDiagScore is a separate, much looser detector shown only for "how close" - not on the same scale, not comparable to WAKE_MATCH_THRESHOLD.
|
|
545
|
-
wakeConfirmed = bestWakeScore >= WAKE_MATCH_THRESHOLD;
|
|
546
|
-
matchedWakeWord = wakeConfirmed ? bestWakeScoreName : null;
|
|
547
|
-
console.log(
|
|
548
|
-
wakeConfirmed
|
|
549
|
-
? `[Wake] ${userId}: CONFIRMED (score ${bestWakeScore.toFixed(3)} for ` +
|
|
550
|
-
`"${bestWakeScoreName}", threshold ${WAKE_MATCH_THRESHOLD})`
|
|
551
|
-
: `[Wake] ${userId}: no match (score ${bestWakeScore.toFixed(3)}, ` +
|
|
552
|
-
`threshold ${WAKE_MATCH_THRESHOLD}; diagnostic-only closeness ` +
|
|
553
|
-
`${bestDiagScore.toFixed(3)} for "${bestDiagScoreName ?? 'n/a'}" - ` +
|
|
554
|
-
`different scoring config, not directly comparable to the threshold)`,
|
|
555
|
-
);
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
const pcmBuffer = Buffer.concat(chunks);
|
|
559
|
-
|
|
560
|
-
// Enrollment mode: this utterance is a wake-word reference
|
|
561
|
-
// sample, not a command - hand it straight to Python and skip
|
|
562
|
-
// wake-check/STT/active-window logic entirely.
|
|
563
|
-
if (enrollingUsers.has(userId)) {
|
|
564
|
-
if (pcmBuffer.length < 4000) return; // too short to be a real sample
|
|
565
|
-
const wavHeader = createWavHeader(pcmBuffer.length);
|
|
566
|
-
const wavBuffer = Buffer.concat([wavHeader, pcmBuffer]);
|
|
567
|
-
try {
|
|
568
|
-
await fetch(
|
|
569
|
-
`http://127.0.0.1:18080/enroll_sample?user_id=${encodeURIComponent(userId)}`,
|
|
570
|
-
{
|
|
571
|
-
method: 'POST',
|
|
572
|
-
headers: { 'Content-Type': 'application/octet-stream' },
|
|
573
|
-
body: wavBuffer,
|
|
574
|
-
},
|
|
575
|
-
);
|
|
576
|
-
} catch (err) {
|
|
577
|
-
console.error(`[Enroll] Failed to send sample to Python:`, err.message);
|
|
578
|
-
}
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// Was 24000 (250ms) - cut off short Korean replies ("네"/"어"/"응"); noise is filtered upstream by isSpeaking's RMS/sustain check, not by duration.
|
|
583
|
-
if (pcmBuffer.length < 9600) {
|
|
584
|
-
if (partialSent) {
|
|
585
|
-
fetch('http://127.0.0.1:18080/stt_partial_cancel', {
|
|
586
|
-
method: 'POST',
|
|
587
|
-
headers: { 'Content-Type': 'application/json' },
|
|
588
|
-
body: JSON.stringify({ guild_id: guildId }),
|
|
589
|
-
}).catch(() => {});
|
|
590
|
-
}
|
|
591
|
-
return;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
const shouldTranscribe =
|
|
595
|
-
isGuildActive(guildId) || wakeConfirmed || wakeWordOptedOut.has(userId);
|
|
596
|
-
|
|
597
|
-
if (!shouldTranscribe) {
|
|
598
|
-
if (partialSent) {
|
|
599
|
-
fetch('http://127.0.0.1:18080/stt_partial_cancel', {
|
|
600
|
-
method: 'POST',
|
|
601
|
-
headers: { 'Content-Type': 'application/json' },
|
|
602
|
-
body: JSON.stringify({ guild_id: guildId }),
|
|
603
|
-
}).catch(() => {});
|
|
604
|
-
}
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
const wavHeader = createWavHeader(pcmBuffer.length);
|
|
609
|
-
const wavBuffer = Buffer.concat([wavHeader, pcmBuffer]);
|
|
610
|
-
const text = await googleSTT(wavBuffer);
|
|
611
|
-
|
|
612
|
-
try {
|
|
613
|
-
await fetch('http://127.0.0.1:18080/stt_input', {
|
|
614
|
-
method: 'POST',
|
|
615
|
-
headers: { 'Content-Type': 'application/json' },
|
|
616
|
-
body: JSON.stringify({
|
|
617
|
-
user_id: userId,
|
|
618
|
-
guild_id: guildId,
|
|
619
|
-
text,
|
|
620
|
-
wake_confirmed: wakeConfirmed,
|
|
621
|
-
matched_wake_word: matchedWakeWord,
|
|
622
|
-
}),
|
|
623
|
-
});
|
|
624
|
-
} catch (err) {
|
|
625
|
-
console.error(`[STT] Failed to send recognized text to Python:`, err.message);
|
|
626
|
-
}
|
|
627
|
-
});
|
|
628
|
-
});
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
app.get('/health', (req, res) => {
|
|
632
|
-
res.json({ ready: client.isReady() });
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
app.post('/join', async (req, res) => {
|
|
636
|
-
const { guild_id, channel_id } = req.body;
|
|
637
|
-
try {
|
|
638
|
-
const guild = client.guilds.cache.get(guild_id);
|
|
639
|
-
if (!guild) return res.status(404).json({ error: 'Guild not found' });
|
|
640
|
-
|
|
641
|
-
let connection = joinVoiceChannel({
|
|
642
|
-
channelId: channel_id,
|
|
643
|
-
guildId: guild_id,
|
|
644
|
-
adapterCreator: guild.voiceAdapterCreator,
|
|
645
|
-
selfDeaf: false,
|
|
646
|
-
selfMute: false,
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
connections.set(guild_id, connection);
|
|
650
|
-
|
|
651
|
-
setupReceiver(connection, guild_id);
|
|
652
|
-
|
|
653
|
-
connection.removeAllListeners(VoiceConnectionStatus.Ready);
|
|
654
|
-
connection.on(VoiceConnectionStatus.Ready, () => {
|
|
655
|
-
console.log(`[Voice] Connected to ${channel_id} in ${guild_id}`);
|
|
656
|
-
});
|
|
657
|
-
|
|
658
|
-
res.json({ success: true });
|
|
659
|
-
} catch (e) {
|
|
660
|
-
console.error(e);
|
|
661
|
-
res.status(500).json({ error: e.message });
|
|
662
|
-
}
|
|
663
|
-
});
|
|
664
|
-
|
|
665
|
-
app.post('/leave', (req, res) => {
|
|
666
|
-
const { guild_id } = req.body;
|
|
667
|
-
const connection = connections.get(guild_id);
|
|
668
|
-
if (!connection) {
|
|
669
|
-
return res.status(404).json({ error: 'Not connected' });
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
const player = players.get(guild_id);
|
|
673
|
-
if (player) {
|
|
674
|
-
try {
|
|
675
|
-
player.stop(true);
|
|
676
|
-
} catch (e) {
|
|
677
|
-
// already stopped/destroyed - fine
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
connection.destroy();
|
|
681
|
-
|
|
682
|
-
// Without this, a stale isPlaying/players entry silently breaks STT/wake detection on the next /join.
|
|
683
|
-
connections.delete(guild_id);
|
|
684
|
-
players.delete(guild_id);
|
|
685
|
-
audioQueues.delete(guild_id);
|
|
686
|
-
isPlaying.delete(guild_id);
|
|
687
|
-
activeUntil.delete(guild_id);
|
|
688
|
-
suppressNotifyMap.delete(guild_id);
|
|
689
|
-
|
|
690
|
-
res.json({ success: true });
|
|
691
|
-
});
|
|
692
|
-
|
|
693
|
-
// Whether the audio that just finished playing should extend the "stay awake" window
|
|
694
|
-
// (false for wake-word enrollment sample playback - see suppress_active_window).
|
|
695
|
-
const suppressNotifyMap = new Map();
|
|
696
|
-
|
|
697
|
-
async function notifyTtsFinished(guild_id) {
|
|
698
|
-
try {
|
|
699
|
-
await fetch('http://127.0.0.1:18080/tts_finished', {
|
|
700
|
-
method: 'POST',
|
|
701
|
-
headers: { 'Content-Type': 'application/json' },
|
|
702
|
-
body: JSON.stringify({ guild_id }),
|
|
703
|
-
});
|
|
704
|
-
} catch (err) {
|
|
705
|
-
console.error(`[TTS] Failed to notify Python of playback completion:`, err.message);
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
function playNextInQueue(guild_id) {
|
|
710
|
-
const queue = audioQueues.get(guild_id) || [];
|
|
711
|
-
if (queue.length === 0) {
|
|
712
|
-
isPlaying.set(guild_id, false);
|
|
713
|
-
if (!suppressNotifyMap.get(guild_id)) {
|
|
714
|
-
notifyTtsFinished(guild_id);
|
|
715
|
-
}
|
|
716
|
-
return;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
const connection = connections.get(guild_id);
|
|
720
|
-
if (!connection) {
|
|
721
|
-
isPlaying.set(guild_id, false);
|
|
722
|
-
return;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
isPlaying.set(guild_id, true);
|
|
726
|
-
const item = queue.shift(); // { buffer, suppressActiveWindow }
|
|
727
|
-
suppressNotifyMap.set(guild_id, item.suppressActiveWindow);
|
|
728
|
-
|
|
729
|
-
try {
|
|
730
|
-
let player = players.get(guild_id);
|
|
731
|
-
if (!player) {
|
|
732
|
-
player = createAudioPlayer();
|
|
733
|
-
players.set(guild_id, player);
|
|
734
|
-
connection.subscribe(player);
|
|
735
|
-
|
|
736
|
-
player.on(AudioPlayerStatus.Idle, () => {
|
|
737
|
-
playNextInQueue(guild_id);
|
|
738
|
-
});
|
|
739
|
-
|
|
740
|
-
player.on('error', (error) => {
|
|
741
|
-
console.error(`[TTS] AudioPlayer Error:`, error.message);
|
|
742
|
-
playNextInQueue(guild_id);
|
|
743
|
-
});
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
const resource = createAudioResource(Readable.from(item.buffer));
|
|
747
|
-
player.play(resource);
|
|
748
|
-
} catch (e) {
|
|
749
|
-
console.error(`[TTS] Error playing queued audio:`, e);
|
|
750
|
-
playNextInQueue(guild_id);
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
app.post('/play', express.raw({ type: 'application/octet-stream', limit: '20mb' }), (req, res) => {
|
|
755
|
-
const guild_id = req.query.guild_id;
|
|
756
|
-
const connection = connections.get(guild_id);
|
|
757
|
-
if (!connection) return res.status(404).json({ error: 'Not connected' });
|
|
758
|
-
|
|
759
|
-
if (!audioQueues.has(guild_id)) {
|
|
760
|
-
audioQueues.set(guild_id, []);
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
audioQueues.get(guild_id).push({
|
|
764
|
-
buffer: req.body, // req.body is a Buffer here
|
|
765
|
-
suppressActiveWindow: req.query.suppress_active_window === 'true',
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
if (!isPlaying.get(guild_id)) {
|
|
769
|
-
playNextInQueue(guild_id);
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
res.json({ success: true, queued: true });
|
|
773
|
-
});
|
|
774
|
-
|
|
775
|
-
app.post('/interrupt', (req, res) => {
|
|
776
|
-
const { guild_id } = req.body;
|
|
777
|
-
// Lets Python trigger the same cutoff the VAD loud-voice check uses, regardless of volume.
|
|
778
|
-
interruptTTS(guild_id);
|
|
779
|
-
res.json({ success: true });
|
|
780
|
-
});
|
|
781
|
-
|
|
782
|
-
app.post('/invalidate_detector', (req, res) => {
|
|
783
|
-
// Without this, detectorCache keeps serving the OLD .rpw after a user re-enrolls.
|
|
784
|
-
const { user_id } = req.body;
|
|
785
|
-
const deleted = detectorCache.delete(user_id);
|
|
786
|
-
console.log(`[Wake] Invalidated cached detector for ${user_id} (was cached: ${deleted})`);
|
|
787
|
-
res.json({ success: true, was_cached: deleted });
|
|
788
|
-
});
|
|
789
|
-
|
|
790
|
-
app.post('/build_wakeword', async (req, res) => {
|
|
791
|
-
// Builds a .rpw reference in-process via rustpotter-web's WakewordRefCreator, instead of
|
|
792
|
-
// shelling out to a separately-downloaded rustpotter-cli binary like this used to.
|
|
793
|
-
try {
|
|
794
|
-
const { name, samples } = req.body;
|
|
795
|
-
if (!name || !Array.isArray(samples) || samples.length === 0) {
|
|
796
|
-
return res
|
|
797
|
-
.status(400)
|
|
798
|
-
.json({ error: 'name and at least one sample (wav bytes) are required' });
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
const mod = await loadRustpotterModule();
|
|
802
|
-
const creator = mod.WakewordRefCreator.new(name);
|
|
803
|
-
try {
|
|
804
|
-
for (const sample of samples) {
|
|
805
|
-
const buf = Buffer.from(sample.data_base64, 'base64');
|
|
806
|
-
creator.addFile(sample.filename || `${name}.wav`, buf);
|
|
807
|
-
}
|
|
808
|
-
const rpwBytes = creator.saveToBytes();
|
|
809
|
-
console.log(
|
|
810
|
-
`[Wake] Built .rpw for '${name}' from ${samples.length} sample(s) via WakewordRefCreator`,
|
|
811
|
-
);
|
|
812
|
-
res.json({ success: true, rpw_base64: Buffer.from(rpwBytes).toString('base64') });
|
|
813
|
-
} finally {
|
|
814
|
-
creator.free();
|
|
815
|
-
}
|
|
816
|
-
} catch (e) {
|
|
817
|
-
console.error(`[Wake] Failed to build wakeword reference:`, e);
|
|
818
|
-
res.status(500).json({ error: e.message || String(e) });
|
|
819
|
-
}
|
|
820
|
-
});
|
|
821
|
-
|
|
822
|
-
app.post('/set_config', (req, res) => {
|
|
823
|
-
const { voice_threshold } = req.body;
|
|
824
|
-
if (voice_threshold) {
|
|
825
|
-
vadThreshold = voice_threshold;
|
|
826
|
-
console.log(`[Config] Updated VAD threshold to ${vadThreshold}`);
|
|
827
|
-
}
|
|
828
|
-
res.json({ success: true });
|
|
829
|
-
});
|
|
830
|
-
|
|
831
|
-
app.post('/enroll_start', (req, res) => {
|
|
832
|
-
const { user_id } = req.body;
|
|
833
|
-
if (!user_id) return res.status(400).json({ error: 'user_id required' });
|
|
834
|
-
enrollingUsers.add(user_id);
|
|
835
|
-
res.json({ success: true });
|
|
836
|
-
});
|
|
837
|
-
|
|
838
|
-
app.post('/enroll_stop', (req, res) => {
|
|
839
|
-
const { user_id } = req.body;
|
|
840
|
-
if (!user_id) return res.status(400).json({ error: 'user_id required' });
|
|
841
|
-
enrollingUsers.delete(user_id);
|
|
842
|
-
res.json({ success: true });
|
|
843
|
-
});
|
|
844
|
-
|
|
845
|
-
app.post('/set_active', (req, res) => {
|
|
846
|
-
const { guild_id, active_until } = req.body;
|
|
847
|
-
if (!guild_id || !active_until)
|
|
848
|
-
return res.status(400).json({ error: 'guild_id and active_until required' });
|
|
849
|
-
activeUntil.set(guild_id, active_until);
|
|
850
|
-
res.json({ success: true });
|
|
851
|
-
});
|
|
852
|
-
|
|
853
|
-
app.post('/set_wake_word_required', (req, res) => {
|
|
854
|
-
const { user_id, required } = req.body;
|
|
855
|
-
if (!user_id) return res.status(400).json({ error: 'user_id required' });
|
|
856
|
-
if (required) wakeWordOptedOut.delete(user_id);
|
|
857
|
-
else wakeWordOptedOut.add(user_id);
|
|
858
|
-
res.json({ success: true });
|
|
859
|
-
});
|
|
25
|
+
registerRoutes(app, client);
|
|
860
26
|
|
|
861
27
|
process.on('unhandledRejection', (reason) => {
|
|
862
28
|
console.error('Unhandled promise rejection (voice service stays alive):', reason);
|
|
863
29
|
});
|
|
864
30
|
|
|
865
31
|
process.on('uncaughtException', (err) => {
|
|
866
|
-
//
|
|
867
|
-
// process with no trace of why - which is what made the previous
|
|
868
|
-
// "voice service just disappeared mid-enrollment" reports
|
|
869
|
-
// undiagnosable. Node still exits after this (an uncaughtException
|
|
870
|
-
// means something is in an unknown state - continuing risks worse
|
|
871
|
-
// corruption than restarting), but now the cause is on record.
|
|
32
|
+
// Logs the cause before exiting - an unhandled sync error used to kill the process with no trace.
|
|
872
33
|
console.error('Uncaught exception - voice service is exiting:', err);
|
|
873
34
|
process.exit(1);
|
|
874
35
|
});
|
|
875
36
|
|
|
876
37
|
// Without this, SIGTERM (lgy stop/restart) kills the process mid-connection and Discord never gets a clean leave.
|
|
877
38
|
function shutdownGracefully() {
|
|
878
|
-
console.log(
|
|
879
|
-
|
|
39
|
+
console.log(
|
|
40
|
+
`[Shutdown] Disconnecting from ${state.connections.size} active voice connection(s)...`,
|
|
41
|
+
);
|
|
42
|
+
for (const connection of state.connections.values()) {
|
|
880
43
|
try {
|
|
881
44
|
connection.destroy();
|
|
882
45
|
} catch (e) {
|