linkgravity 1.0.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/bin/cli.js +278 -0
  4. package/bin/setup.js +260 -0
  5. package/hooks/hook.py +60 -0
  6. package/hooks/stop_hook.py +64 -0
  7. package/npm-scripts/postinstall.js +62 -0
  8. package/npm-scripts/prepare.js +45 -0
  9. package/npm-scripts/register-hook.js +182 -0
  10. package/npm-scripts/run-dev.js +9 -0
  11. package/npm-scripts/venv-paths.js +45 -0
  12. package/package.json +59 -0
  13. package/requirements.txt +13 -0
  14. package/src/api/server.py +48 -0
  15. package/src/api/ui_routes.py +340 -0
  16. package/src/api/voice_routes.py +94 -0
  17. package/src/approval/command_parser.py +62 -0
  18. package/src/approval/tool_formatter.py +68 -0
  19. package/src/cogs/general_cog.py +287 -0
  20. package/src/cogs/voice/__init__.py +0 -0
  21. package/src/cogs/voice/enrollment.py +436 -0
  22. package/src/cogs/voice/stt_session.py +121 -0
  23. package/src/cogs/voice_cog.py +573 -0
  24. package/src/config.py +123 -0
  25. package/src/core/agy_runner.py +380 -0
  26. package/src/core/atomic_io.py +31 -0
  27. package/src/core/logger.py +28 -0
  28. package/src/core/session_manager.py +126 -0
  29. package/src/handlers/message_router.py +16 -0
  30. package/src/handlers/thread_reply.py +165 -0
  31. package/src/main.py +313 -0
  32. package/src/messengers/base.py +105 -0
  33. package/src/messengers/discord_adapter.py +240 -0
  34. package/src/messengers/registry.py +19 -0
  35. package/src/services/audio_service.py +67 -0
  36. package/src/services/discord_helpers.py +95 -0
  37. package/src/services/discord_mcp.py +50 -0
  38. package/src/services/response.py +51 -0
  39. package/src/services/streaming.py +199 -0
  40. package/src/utils/utils.py +40 -0
  41. package/voice-service/index.js +1048 -0
  42. package/voice-service/package-lock.json +1880 -0
  43. package/voice-service/package.json +24 -0
@@ -0,0 +1,1048 @@
1
+ const { Client, GatewayIntentBits, Events } = require('discord.js');
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');
27
+
28
+ 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
+
38
+ const aglJsonPath = path.join(os.homedir(), '.gemini', 'linkgravity', 'lgy.json');
39
+ let aglConfig = {};
40
+ try {
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;
50
+
51
+ process.env.http_proxy = '';
52
+ process.env.https_proxy = '';
53
+ process.env.HTTP_PROXY = '';
54
+ process.env.HTTPS_PROXY = '';
55
+ process.env.PYTHON_HOST = '';
56
+
57
+ const app = express();
58
+ app.use(express.json({ limit: '50mb' }));
59
+
60
+ let vadThreshold = 3000;
61
+ if (aglConfig.voice_threshold) {
62
+ vadThreshold = parseInt(aglConfig.voice_threshold) || 3000;
63
+ }
64
+
65
+ const client = new Client({
66
+ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
67
+ });
68
+
69
+ const connections = new Map();
70
+ const players = new Map();
71
+
72
+ function stereoToMono(buffer) {
73
+ // Discord voice receive is always 48kHz STEREO (see e.g. the
74
+ // official discordjs/voice-examples recorder, which decodes with
75
+ // channels: 2) - everything downstream here (WAV writing via
76
+ // createWavHeader, Rustpotter's config) was built assuming MONO
77
+ // input, so this converts the interleaved L/R stream to mono right
78
+ // after decoding, in one place, rather than decoding as channels: 1
79
+ // and having the native Opus decoder silently misinterpret an
80
+ // actually-stereo stream as mono.
81
+ const samples = buffer.length >> 2; // 2 bytes/sample * 2 channels
82
+ const mono = Buffer.alloc(samples * 2);
83
+ for (let i = 0; i < samples; i++) {
84
+ const l = buffer.readInt16LE(i * 4);
85
+ const r = buffer.readInt16LE(i * 4 + 2);
86
+ mono.writeInt16LE((l + r) >> 1, i * 2);
87
+ }
88
+ return mono;
89
+ }
90
+
91
+ function createWavHeader(dataLength, sampleRate = 48000, channels = 1, bitDepth = 16) {
92
+ const buffer = Buffer.alloc(44);
93
+ buffer.write('RIFF', 0);
94
+ buffer.writeUInt32LE(36 + dataLength, 4);
95
+ buffer.write('WAVE', 8);
96
+ buffer.write('fmt ', 12);
97
+ buffer.writeUInt32LE(16, 16);
98
+ buffer.writeUInt16LE(1, 20);
99
+ buffer.writeUInt16LE(channels, 22);
100
+ buffer.writeUInt32LE(sampleRate, 24);
101
+ buffer.writeUInt32LE(sampleRate * channels * (bitDepth / 8), 28);
102
+ buffer.writeUInt16LE(channels * (bitDepth / 8), 32);
103
+ buffer.writeUInt16LE(bitDepth, 34);
104
+ buffer.write('data', 36);
105
+ buffer.writeUInt32LE(dataLength, 40);
106
+ return buffer;
107
+ }
108
+
109
+ client.once(Events.ClientReady, () => {
110
+ console.log(`🎤 Node.js Voice Microservice is online as ${client.user.tag}`);
111
+ });
112
+
113
+ // --- STT, done directly in Node ---
114
+ // This is the same unofficial endpoint Python's SpeechRecognition
115
+ // library (recognize_google) has used for years: audio encoded as FLAC,
116
+ // POSTed to Google's v2 speech API with the "chromium" key that library
117
+ // ships as its default. It's not a secret - it's the same publicly
118
+ // documented key referenced all over SpeechRecognition's own source and
119
+ // years of writeups (search "AIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgw
120
+ // google speech api" if you want to verify that yourself). It's still an
121
+ // unofficial/reverse-engineered API that Google could change or rate-
122
+ // limit at any time - same risk the project already had via Python, just
123
+ // no longer duplicated in two languages.
124
+ //
125
+ // Requires ffmpeg to do the WAV -> FLAC conversion - already a
126
+ // dependency of this package (ffmpeg-static bundles the binary per
127
+ // platform), so nothing extra to install.
128
+ const GOOGLE_STT_KEY = 'AIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgw';
129
+
130
+ function flacEncode(wavBuffer) {
131
+ return new Promise((resolve, reject) => {
132
+ const ff = spawn(ffmpegPath, [
133
+ '-hide_banner',
134
+ '-loglevel',
135
+ 'error',
136
+ '-i',
137
+ 'pipe:0',
138
+ '-f',
139
+ 'flac',
140
+ 'pipe:1',
141
+ ]);
142
+ const out = [];
143
+ ff.stdout.on('data', (d) => out.push(d));
144
+ ff.stderr.on('data', () => {}); // -loglevel error already keeps this quiet in the normal case
145
+ ff.on('error', (err) => reject(new Error(`ffmpeg-static failed to run (${err.message})`)));
146
+ ff.on('close', (code) => {
147
+ if (code !== 0) return reject(new Error(`ffmpeg exited with code ${code}`));
148
+ resolve(Buffer.concat(out));
149
+ });
150
+ ff.stdin.write(wavBuffer);
151
+ ff.stdin.end();
152
+ });
153
+ }
154
+
155
+ async function googleSTT(wavBuffer, lang = 'ko-KR') {
156
+ let flacBuffer;
157
+ try {
158
+ flacBuffer = await flacEncode(wavBuffer);
159
+ } catch (err) {
160
+ console.error('[STT] FLAC encode failed:', err.message);
161
+ return null;
162
+ }
163
+
164
+ let res;
165
+ try {
166
+ res = await fetch(
167
+ `https://www.google.com/speech-api/v2/recognize?output=json&client=chromium&lang=${encodeURIComponent(lang)}&key=${GOOGLE_STT_KEY}`,
168
+ {
169
+ method: 'POST',
170
+ headers: { 'Content-Type': 'audio/x-flac; rate=48000' },
171
+ body: flacBuffer,
172
+ },
173
+ );
174
+ } catch (err) {
175
+ console.error('[STT] Request to Google STT failed:', err.message);
176
+ return null;
177
+ }
178
+
179
+ const raw = await res.text();
180
+ // Response is newline-delimited JSON, one object per line, e.g.:
181
+ // {"result":[]}
182
+ // {"result":[{"alternative":[{"transcript":"...","confidence":0.9}],"final":true}],"result_index":0}
183
+ for (const line of raw.trim().split('\n')) {
184
+ if (!line) continue;
185
+ try {
186
+ const obj = JSON.parse(line);
187
+ const transcript = obj.result?.[0]?.alternative?.[0]?.transcript;
188
+ if (transcript) return transcript.trim();
189
+ } catch (e) {
190
+ // not JSON / partial line - ignore
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+
196
+ const audioQueues = new Map();
197
+ const isPlaying = new Map();
198
+
199
+ function interruptTTS(guildId) {
200
+ const player = players.get(guildId);
201
+ let interrupted = false;
202
+
203
+ if (audioQueues.has(guildId)) {
204
+ // Queue now holds in-memory audio Buffers (see /play), not
205
+ // filepaths, so there's nothing on disk to clean up here.
206
+ audioQueues.set(guildId, []);
207
+ }
208
+
209
+ if (player && player.state.status !== AudioPlayerStatus.Idle) {
210
+ player.stop();
211
+ console.log(`[VAD] Interrupted TTS in guild ${guildId}`);
212
+ interrupted = true;
213
+ }
214
+
215
+ isPlaying.set(guildId, false);
216
+ return interrupted;
217
+ }
218
+
219
+ const activeStreams = new Map();
220
+
221
+ // user_id -> true, while that user is recording wake-word samples (see
222
+ // /enroll_start, /enroll_stop). Utterances from these users get routed
223
+ // to /enroll_sample instead of the normal wake-check/STT pipeline.
224
+ const enrollingUsers = new Set();
225
+
226
+ // guild_id -> ms epoch until which we're in the "awake, no need to repeat
227
+ // the wake word" window (set by Python via /set_active whenever it
228
+ // starts/renews that countdown - see VoiceCog._extend_active_window).
229
+ const activeUntil = new Map();
230
+
231
+ function isGuildActive(guildId) {
232
+ return Date.now() < (activeUntil.get(guildId) || 0);
233
+ }
234
+
235
+ // --- Wake-word detection (Rustpotter, in-process) ---
236
+ // Runs entirely in this Node process - no Python round trip. Each
237
+ // enrolled user gets their own Rustpotter instance (built from the .rpw
238
+ // reference file this same process produces via /build_wakeword, called
239
+ // by EnrollmentManager._commit_enrollment once all samples are
240
+ // confirmed), cached here and fed audio directly as it streams in from
241
+ // Discord.
242
+ const WAKE_REF_DIR = path.join(os.homedir(), '.gemini', 'linkgravity', 'wake_refs');
243
+
244
+ let rustpotterModPromise = null;
245
+ function loadRustpotterModule() {
246
+ if (!rustpotterModPromise) {
247
+ rustpotterModPromise = (async () => {
248
+ // Bare "rustpotter-web" doesn't resolve under Node's ESM
249
+ // loader (the package only declares a "module" field, which
250
+ // is a bundler-only convention Node doesn't read) - the
251
+ // actual entry file has to be named explicitly.
252
+ //
253
+ // Using the full "rustpotter-web" package here (not the
254
+ // "-slim" variant this used to be) because it's the only one
255
+ // of the two that also exposes WakewordRefCreator - the
256
+ // builder API used by /build_wakeword below to create a new
257
+ // .rpw reference directly from wav samples, in-process, with
258
+ // no external binary. The wasm binary is ~6% bigger
259
+ // (810KB vs 761KB) which is irrelevant for a Node backend
260
+ // (this distinction only matters for browser bundle size,
261
+ // which is the slim variant's actual purpose).
262
+ const mod = await import('rustpotter-web/rustpotter_wasm.js');
263
+ const wasmPath = require.resolve('rustpotter-web/rustpotter_wasm_bg.wasm');
264
+ mod.initSync(fs.readFileSync(wasmPath));
265
+ return mod;
266
+ })();
267
+ }
268
+ return rustpotterModPromise;
269
+ }
270
+
271
+ // userId -> { rustpotter, samplesPerFrame, residual: Int16Array }
272
+ const detectorCache = new Map();
273
+
274
+ // The real pass/fail cutoff for a wake-word match. This has to be a
275
+ // genuine, meaningful threshold, not a tuning knob to set near-zero:
276
+ // rustpotter's confirmation logic re-arms its countdown EVERY time a
277
+ // new "candidate" clears the threshold (detector.rs's run_detection:
278
+ // `self.detection_countdown = self.max_mfcc_frames / 2` runs again on
279
+ // every qualifying frame). With this set to 0.05 during earlier
280
+ // debugging, silence and noise cleared it just as easily as real
281
+ // speech, so the countdown never ran out and nothing was ever
282
+ // confirmed no matter how much silence padding was fed afterward.
283
+ // Verified against a native Rust reproduction of this exact detector
284
+ // before settling on 0.5 (matches rustpotter's own default, and what
285
+ // rustpotter-cli scored real captured audio at: 0.55-0.73).
286
+ const WAKE_MATCH_THRESHOLD = 0.5;
287
+
288
+ async function getDetectorForUser(userId) {
289
+ if (detectorCache.has(userId)) return detectorCache.get(userId);
290
+
291
+ const userDir = path.join(WAKE_REF_DIR, userId);
292
+ if (!fs.existsSync(userDir)) return null; // not enrolled
293
+
294
+ const rpwFile = fs.readdirSync(userDir).find((f) => f.endsWith('.rpw'));
295
+ if (!rpwFile) return null; // samples exist but .rpw build hasn't happened/failed - see _commit_enrollment
296
+
297
+ const mod = await loadRustpotterModule();
298
+ const config = mod.RustpotterConfig.new();
299
+ config.setSampleRate(48000);
300
+ config.setSampleFormat(mod.SampleFormat.i16);
301
+ config.setChannels(1);
302
+ // See WAKE_MATCH_THRESHOLD's comment above - this MUST be a real,
303
+ // meaningful cutoff (not near-zero) for rustpotter's internal
304
+ // confirm-after-N-more-frames logic to ever finalize a detection.
305
+ config.setThreshold(WAKE_MATCH_THRESHOLD);
306
+ config.setAveragedThreshold(0);
307
+ // Default is 1 - accepting a match the very first time it's ever
308
+ // the leading candidate, even for just one internal frame. Raised
309
+ // to 4 (was 3) so a candidate has to keep winning for a few frames
310
+ // running before it's trusted - this matters more now that
311
+ // scoreMode is back to Max (see below), which is more permissive
312
+ // per-frame than Median was, so this is the main compensating knob
313
+ // against one-off spurious spikes from unrelated speech.
314
+ config.setMinScores(4);
315
+ // Max: score is whichever of the 5 enrolled samples matches best.
316
+ // Median (tried between the two calibration notes below) requires
317
+ // the middle-ranked sample to also score well, which in practice
318
+ // means all 5 enrollment recordings need fairly consistent
319
+ // tone/pace/delivery - real speech isn't that consistent, so Median
320
+ // made real matches with naturally varied enrollment recordings
321
+ // score worse, not just false positives. Max lets a genuinely
322
+ // varied set of 5 recordings (calm, rushed, questioning, etc.) each
323
+ // cover a different real-world delivery, at the cost of also being
324
+ // easier to trip with something that merely resembles ONE of the 5
325
+ // by chance - minScores(4) above and the STT jamo cross-check in
326
+ // VoiceCog.handle_stt_input (tightened for short wake words
327
+ // specifically) are what compensate for that on the other two axes
328
+ // instead. Still needs field-tuning against real usage logs - see
329
+ // README's known-issues section.
330
+ config.setScoreMode(mod.ScoreMode.max);
331
+
332
+ const rustpotter = mod.Rustpotter.new(config);
333
+ rustpotter.addWakeword(rpwFile, fs.readFileSync(path.join(userDir, rpwFile)));
334
+
335
+ // Second, diagnostic-only instance fed the exact same audio in
336
+ // parallel, purely so "no match" cases still show a real number in
337
+ // the logs. It NEVER gates wake behavior - only entry.rustpotter
338
+ // above does that. Why a separate instance instead of reading a
339
+ // low score off the real one: rustpotter's own scoring
340
+ // (wakeword.run_detection in the Rust source) filters out any
341
+ // frame that doesn't already clear `threshold` before it's even
342
+ // tracked internally, so there is no bound API that exposes a
343
+ // sub-threshold score - process*() returns undefined for those,
344
+ // full stop. Naively lowering the REAL detector's threshold to see
345
+ // more doesn't work either (that's exactly the bug from the
346
+ // local-wake migration: near-zero threshold means background noise
347
+ // clears it on almost every frame, which keeps resetting
348
+ // detection_countdown before it can ever reach 0, so confirmation
349
+ // never completes for anyone, real wake word included).
350
+ //
351
+ // This instance sidesteps that by setting eager+minScores(1), so it
352
+ // finalizes and hands back a real score the very first time ANY
353
+ // frame clears its own (near-zero) threshold, instead of waiting on
354
+ // the countdown - the exact same escape hatch that would break
355
+ // gating on the real detector is safe to lean on here because nothing
356
+ // downstream ever treats this instance's output as a wake trigger.
357
+ // scoreMode is kept identical to the real detector so the number
358
+ // itself is a genuine, comparable similarity score - just captured
359
+ // more eagerly, not computed differently.
360
+ const diagConfig = mod.RustpotterConfig.new();
361
+ diagConfig.setSampleRate(48000);
362
+ diagConfig.setSampleFormat(mod.SampleFormat.i16);
363
+ diagConfig.setChannels(1);
364
+ diagConfig.setThreshold(0.01);
365
+ diagConfig.setAveragedThreshold(0);
366
+ diagConfig.setMinScores(1);
367
+ diagConfig.setEager(true);
368
+ diagConfig.setScoreMode(mod.ScoreMode.max);
369
+ const diagRustpotter = mod.Rustpotter.new(diagConfig);
370
+ diagRustpotter.addWakeword(rpwFile, fs.readFileSync(path.join(userDir, rpwFile)));
371
+
372
+ const entry = {
373
+ rustpotter,
374
+ samplesPerFrame: rustpotter.getSamplesPerFrame(),
375
+ residual: new Int16Array(0),
376
+ diag: {
377
+ rustpotter: diagRustpotter,
378
+ samplesPerFrame: diagRustpotter.getSamplesPerFrame(),
379
+ residual: new Int16Array(0),
380
+ },
381
+ };
382
+ console.log(
383
+ `[Wake] Loaded detector for ${userId} from ${rpwFile}: samplesPerFrame=${entry.samplesPerFrame}`,
384
+ );
385
+ detectorCache.set(userId, entry);
386
+ return entry;
387
+ }
388
+
389
+ // Feeds one Discord PCM chunk to a detector, exactly once per sample and
390
+ // frame-aligned via a residual carryover - NOT by periodically
391
+ // re-snapshotting a growing buffer, since Rustpotter's internal window
392
+ // expects a genuinely continuous stream. Returns a RustpotterDetection
393
+ // if any complete frame in this chunk triggered one, else null.
394
+ //
395
+ // Frames are fed back-to-back with NO external overlap - rustpotter
396
+ // internally extracts multiple overlapping 10ms-shifted MFCCs from each
397
+ // ~30ms buffer passed in (confirmed against its Rust source), so the
398
+ // caller just needs to keep the stream continuous, not overlap it.
399
+ function feedPCMToDetector(entry, chunk) {
400
+ const incoming = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.length / 2);
401
+ let combined = incoming;
402
+ if (entry.residual.length) {
403
+ combined = new Int16Array(entry.residual.length + incoming.length);
404
+ combined.set(entry.residual, 0);
405
+ combined.set(incoming, entry.residual.length);
406
+ }
407
+
408
+ let offset = 0;
409
+ let detection = null;
410
+ while (combined.length - offset >= entry.samplesPerFrame) {
411
+ const frame = combined.subarray(offset, offset + entry.samplesPerFrame);
412
+ const result = entry.rustpotter.processI16(frame);
413
+ if (result) detection = result;
414
+ offset += entry.samplesPerFrame;
415
+ }
416
+ entry.residual = combined.subarray(offset);
417
+ return detection;
418
+ }
419
+
420
+ function setupReceiver(connection, guildId) {
421
+ const receiver = connection.receiver;
422
+
423
+ receiver.speaking.removeAllListeners('start');
424
+
425
+ receiver.speaking.on('start', (userId) => {
426
+ if (client.user.id === userId) return;
427
+
428
+ if (activeStreams.get(userId)) {
429
+ return;
430
+ }
431
+ activeStreams.set(userId, true);
432
+
433
+ let hasInterrupted = false;
434
+
435
+ const opusStream = receiver.subscribe(userId, {
436
+ end: {
437
+ behavior: EndBehaviorType.Manual,
438
+ },
439
+ });
440
+ const pcmStream = opusStream.pipe(
441
+ new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 }),
442
+ );
443
+
444
+ // A single corrupted/dropped Opus packet (packet loss, a bad
445
+ // DAVE re-encrypt, whatever) throws inside prism-media's
446
+ // decoder. Streams turn a thrown _transform error into an
447
+ // 'error' event - with no listener here, Node's default for an
448
+ // unhandled 'error' event is to crash the ENTIRE process, not
449
+ // just this one user's utterance. Handling it here keeps voice
450
+ // alive for everyone else (and for this user's next utterance).
451
+ opusStream.on('error', (err) => {
452
+ console.error(`[Voice] Opus stream error for ${userId}:`, err.message);
453
+ forceEndStream();
454
+ });
455
+ pcmStream.on('error', (err) => {
456
+ console.error(
457
+ `[Voice] Opus decode error for ${userId} (bad/corrupted packet):`,
458
+ err.message,
459
+ );
460
+ forceEndStream();
461
+ });
462
+
463
+ const chunks = [];
464
+
465
+ let hasEnded = false;
466
+
467
+ const forceEndStream = () => {
468
+ if (hasEnded) return;
469
+ try {
470
+ opusStream.destroy();
471
+ } catch (e) {}
472
+ try {
473
+ pcmStream.destroy();
474
+ } catch (e) {}
475
+ pcmStream.emit('end');
476
+ };
477
+
478
+ const maxDurationTimer = setTimeout(forceEndStream, 30000);
479
+
480
+ // --- Wake-word gating (Rustpotter, in-process) ---
481
+ // Runs entirely inside this Node process now - no HTTP round
482
+ // trip, no Python involvement. Detector instances are cached per
483
+ // Discord user (see getDetectorForUser) and fed every PCM chunk
484
+ // as it streams in below, frame-aligned via feedPCMToDetector's
485
+ // residual carryover - not via periodic re-snapshotting like the
486
+ // old /wake_check design, since Rustpotter expects each sample
487
+ // fed exactly once, in order.
488
+ let wakeConfirmed = false;
489
+ let matchedWakeWord = null;
490
+ let detectorEntry = null;
491
+ let bestWakeScore = 0;
492
+ let bestWakeScoreName = null;
493
+ let bestDiagScore = 0;
494
+ let bestDiagScoreName = null;
495
+
496
+ if (!enrollingUsers.has(userId) && !isGuildActive(guildId)) {
497
+ getDetectorForUser(userId)
498
+ .then((entry) => {
499
+ if (entry) {
500
+ entry.rustpotter.reset();
501
+ entry.residual = new Int16Array(0);
502
+ entry.diag.rustpotter.reset();
503
+ entry.diag.residual = new Int16Array(0);
504
+ detectorEntry = entry;
505
+ }
506
+ })
507
+ .catch((err) =>
508
+ console.error(`[Wake] Failed to load detector for ${userId}:`, err.message),
509
+ );
510
+ }
511
+
512
+ // --- Live "listening..." indicator ---
513
+ // Only runs during the active/awake window (bounded, default
514
+ // 60s) - NOT for every VAD-detected sound like the old version,
515
+ // which is what made it expensive before. STT here reuses the
516
+ // same googleSTT() call that runs at utterance end anyway, just
517
+ // invoked earlier/more often on the growing buffer for live
518
+ // feedback.
519
+ const PARTIAL_INTERVAL_MS = 1500;
520
+ const PARTIAL_MIN_NEW_BYTES = 24000;
521
+ let lastPartialLength = 0;
522
+ let partialSent = false;
523
+ const partialTimer = setInterval(async () => {
524
+ if (hasEnded || !isSpeaking || enrollingUsers.has(userId)) return;
525
+ if (!isGuildActive(guildId)) return;
526
+
527
+ const currentLength = chunks.reduce((sum, c) => sum + c.length, 0);
528
+ if (currentLength - lastPartialLength < PARTIAL_MIN_NEW_BYTES) return;
529
+ lastPartialLength = currentLength;
530
+
531
+ const windowPcm = Buffer.concat(chunks);
532
+ const wavHeader = createWavHeader(windowPcm.length);
533
+ const wavBuffer = Buffer.concat([wavHeader, windowPcm]);
534
+
535
+ const text = await googleSTT(wavBuffer);
536
+ partialSent = true;
537
+ fetch('http://127.0.0.1:18080/stt_partial', {
538
+ method: 'POST',
539
+ headers: { 'Content-Type': 'application/json' },
540
+ body: JSON.stringify({ guild_id: guildId, text }),
541
+ }).catch((err) =>
542
+ console.error(`[STT] Failed to send partial text to Python:`, err.message),
543
+ );
544
+ }, PARTIAL_INTERVAL_MS);
545
+
546
+ let bgNoiseRMS = 500;
547
+ let isSpeaking = false;
548
+ let silenceBytes = 0;
549
+ let silenceTimer = null;
550
+
551
+ pcmStream.on('data', (rawChunk) => {
552
+ if (hasEnded) return;
553
+ const chunk = stereoToMono(rawChunk); // see stereoToMono's comment - decoder gives real stereo now
554
+
555
+ let sumSquare = 0;
556
+ for (let i = 0; i < chunk.length; i += 2) {
557
+ const sample = chunk.readInt16LE(i);
558
+ sumSquare += sample * sample;
559
+ }
560
+ const rms = Math.sqrt(sumSquare / (chunk.length / 2));
561
+
562
+ const isBotPlaying = isPlaying.get(guildId) || false;
563
+
564
+ if (!hasInterrupted) {
565
+ const dynamicThreshold = isBotPlaying ? vadThreshold * 3 : vadThreshold;
566
+ if (rms > dynamicThreshold) {
567
+ if (interruptTTS(guildId)) {
568
+ console.log(
569
+ `[VAD] Loud voice detected (${Math.round(rms)}), interrupting TTS (Threshold: ${dynamicThreshold})`,
570
+ );
571
+ hasInterrupted = true;
572
+ }
573
+ }
574
+ }
575
+
576
+ if (isPlaying.get(guildId)) {
577
+ return;
578
+ }
579
+
580
+ chunks.push(chunk);
581
+
582
+ if (detectorEntry) {
583
+ const detection = feedPCMToDetector(detectorEntry, chunk);
584
+ if (detection && detection.getScore() > bestWakeScore) {
585
+ bestWakeScore = detection.getScore();
586
+ bestWakeScoreName = detection.getName();
587
+ }
588
+ const diagDetection = feedPCMToDetector(detectorEntry.diag, chunk);
589
+ if (diagDetection && diagDetection.getScore() > bestDiagScore) {
590
+ bestDiagScore = diagDetection.getScore();
591
+ bestDiagScoreName = diagDetection.getName();
592
+ }
593
+ }
594
+
595
+ if (!isSpeaking) {
596
+ bgNoiseRMS = bgNoiseRMS * 0.98 + rms * 0.02;
597
+ bgNoiseRMS = Math.max(50, Math.min(bgNoiseRMS, 3000));
598
+ }
599
+
600
+ const threshold = Math.max(bgNoiseRMS * 2.0, 800);
601
+
602
+ if (rms > threshold) {
603
+ isSpeaking = true;
604
+ silenceBytes = 0;
605
+ } else {
606
+ if (isSpeaking) {
607
+ silenceBytes += chunk.length;
608
+ if (silenceBytes >= 76800) {
609
+ forceEndStream();
610
+ return;
611
+ }
612
+ }
613
+ }
614
+
615
+ if (isSpeaking) {
616
+ if (silenceTimer) clearTimeout(silenceTimer);
617
+ silenceTimer = setTimeout(() => forceEndStream(), 800);
618
+ }
619
+ });
620
+
621
+ pcmStream.on('end', async () => {
622
+ if (hasEnded) return;
623
+ hasEnded = true;
624
+ clearTimeout(maxDurationTimer);
625
+ clearInterval(partialTimer);
626
+ if (silenceTimer) clearTimeout(silenceTimer);
627
+
628
+ activeStreams.delete(userId);
629
+
630
+ if (detectorEntry) {
631
+ // rustpotter needs MORE frames fed after a candidate
632
+ // match before it finalizes one (detection_countdown
633
+ // counts down from max_mfcc_frames/2 before confirming
634
+ // or discarding a partial match - see the Rust source's
635
+ // detector.rs). Live capture stops the instant the user
636
+ // stops talking, so without this, a genuine match
637
+ // candidate never gets the chance to finish counting
638
+ // down and is silently discarded - this is exactly why
639
+ // rustpotter-cli's own `test` command pads its input
640
+ // with 100 extra silent frames before processing (see
641
+ // its test.rs), and why testing the identical captured
642
+ // audio through the CLI scored well while this always
643
+ // failed live. Same fix here: flush the residual plus
644
+ // a few seconds of silence through the same detector
645
+ // before deciding pass/fail.
646
+ const paddingBuffer = Buffer.alloc(detectorEntry.samplesPerFrame * 100 * 2);
647
+ const paddingDetection = feedPCMToDetector(detectorEntry, paddingBuffer);
648
+ if (paddingDetection && paddingDetection.getScore() > bestWakeScore) {
649
+ bestWakeScore = paddingDetection.getScore();
650
+ bestWakeScoreName = paddingDetection.getName();
651
+ }
652
+ const diagPaddingBuffer = Buffer.alloc(
653
+ detectorEntry.diag.samplesPerFrame * 100 * 2,
654
+ );
655
+ const diagPaddingDetection = feedPCMToDetector(
656
+ detectorEntry.diag,
657
+ diagPaddingBuffer,
658
+ );
659
+ if (diagPaddingDetection && diagPaddingDetection.getScore() > bestDiagScore) {
660
+ bestDiagScore = diagPaddingDetection.getScore();
661
+ bestDiagScoreName = diagPaddingDetection.getName();
662
+ }
663
+
664
+ // The real pass/fail decision - see WAKE_MATCH_THRESHOLD's
665
+ // comment for why this has to be a meaningful cutoff.
666
+ // bestDiagScore comes from the separate diagnostic
667
+ // instance above (see its comment in getDetectorForUser)
668
+ // so a "no match" line still shows the real closest
669
+ // score instead of a meaningless flat 0.000.
670
+ wakeConfirmed = bestWakeScore >= WAKE_MATCH_THRESHOLD;
671
+ matchedWakeWord = wakeConfirmed ? bestWakeScoreName : null;
672
+ console.log(
673
+ wakeConfirmed
674
+ ? `[Wake] ${userId}: CONFIRMED (score ${bestWakeScore.toFixed(3)} for ` +
675
+ `"${bestWakeScoreName}", threshold ${WAKE_MATCH_THRESHOLD})`
676
+ : `[Wake] ${userId}: no match (closest score ${bestDiagScore.toFixed(3)} for ` +
677
+ `"${bestDiagScoreName ?? 'n/a'}", needed ${WAKE_MATCH_THRESHOLD})`,
678
+ );
679
+ }
680
+
681
+ const pcmBuffer = Buffer.concat(chunks);
682
+
683
+ // Enrollment mode: this utterance is a wake-word reference
684
+ // sample, not a command - hand it straight to Python and skip
685
+ // wake-check/STT/active-window logic entirely.
686
+ if (enrollingUsers.has(userId)) {
687
+ if (pcmBuffer.length < 4000) return; // too short to be a real sample
688
+ const wavHeader = createWavHeader(pcmBuffer.length);
689
+ const wavBuffer = Buffer.concat([wavHeader, pcmBuffer]);
690
+ try {
691
+ await fetch(
692
+ `http://127.0.0.1:18080/enroll_sample?user_id=${encodeURIComponent(userId)}`,
693
+ {
694
+ method: 'POST',
695
+ headers: { 'Content-Type': 'application/octet-stream' },
696
+ body: wavBuffer,
697
+ },
698
+ );
699
+ } catch (err) {
700
+ console.error(`[Enroll] Failed to send sample to Python:`, err.message);
701
+ }
702
+ return;
703
+ }
704
+
705
+ if (pcmBuffer.length < 24000) {
706
+ if (partialSent) {
707
+ fetch('http://127.0.0.1:18080/stt_partial_cancel', {
708
+ method: 'POST',
709
+ headers: { 'Content-Type': 'application/json' },
710
+ body: JSON.stringify({ guild_id: guildId }),
711
+ }).catch(() => {});
712
+ }
713
+ return;
714
+ }
715
+
716
+ // Whether this utterance is worth transcribing at all:
717
+ // - isGuildActive(): already awake, no need to repeat the
718
+ // wake word (this is also what makes the live "listening"
719
+ // indicator above meaningful - same window).
720
+ // - wakeConfirmed: our in-process Rustpotter detector matched this
721
+ // user's voice against their enrolled samples.
722
+ // There is deliberately no "unenrolled users always get
723
+ // transcribed" fallback anymore - that existed only to feed
724
+ // the Python side's old text-similarity wake-word matching,
725
+ // which has been removed (it was exactly the always-on
726
+ // recognition overhead this Rustpotter migration was meant to
727
+ // get rid of). An unenrolled user simply can't wake the bot
728
+ // by voice until they run /sound.
729
+ const shouldTranscribe = isGuildActive(guildId) || wakeConfirmed;
730
+
731
+ if (!shouldTranscribe) {
732
+ if (partialSent) {
733
+ fetch('http://127.0.0.1:18080/stt_partial_cancel', {
734
+ method: 'POST',
735
+ headers: { 'Content-Type': 'application/json' },
736
+ body: JSON.stringify({ guild_id: guildId }),
737
+ }).catch(() => {});
738
+ }
739
+ return;
740
+ }
741
+
742
+ const wavHeader = createWavHeader(pcmBuffer.length);
743
+ const wavBuffer = Buffer.concat([wavHeader, pcmBuffer]);
744
+ const text = await googleSTT(wavBuffer);
745
+
746
+ try {
747
+ await fetch('http://127.0.0.1:18080/stt_input', {
748
+ method: 'POST',
749
+ headers: { 'Content-Type': 'application/json' },
750
+ body: JSON.stringify({
751
+ user_id: userId,
752
+ guild_id: guildId,
753
+ text,
754
+ wake_confirmed: wakeConfirmed,
755
+ matched_wake_word: matchedWakeWord,
756
+ }),
757
+ });
758
+ } catch (err) {
759
+ console.error(`[STT] Failed to send recognized text to Python:`, err.message);
760
+ }
761
+ });
762
+ });
763
+ }
764
+
765
+ app.get('/health', (req, res) => {
766
+ res.json({ ready: client.isReady() });
767
+ });
768
+
769
+ app.post('/join', async (req, res) => {
770
+ const { guild_id, channel_id } = req.body;
771
+ try {
772
+ const guild = client.guilds.cache.get(guild_id);
773
+ if (!guild) return res.status(404).json({ error: 'Guild not found' });
774
+
775
+ let connection = joinVoiceChannel({
776
+ channelId: channel_id,
777
+ guildId: guild_id,
778
+ adapterCreator: guild.voiceAdapterCreator,
779
+ selfDeaf: false,
780
+ selfMute: false,
781
+ });
782
+
783
+ connections.set(guild_id, connection);
784
+
785
+ setupReceiver(connection, guild_id);
786
+
787
+ connection.removeAllListeners(VoiceConnectionStatus.Ready);
788
+ connection.on(VoiceConnectionStatus.Ready, () => {
789
+ console.log(`[Voice] Connected to ${channel_id} in ${guild_id}`);
790
+ });
791
+
792
+ res.json({ success: true });
793
+ } catch (e) {
794
+ console.error(e);
795
+ res.status(500).json({ error: e.message });
796
+ }
797
+ });
798
+
799
+ app.post('/leave', (req, res) => {
800
+ const { guild_id } = req.body;
801
+ const connection = connections.get(guild_id);
802
+ if (!connection) {
803
+ return res.status(404).json({ error: 'Not connected' });
804
+ }
805
+
806
+ const player = players.get(guild_id);
807
+ if (player) {
808
+ try {
809
+ player.stop(true);
810
+ } catch (e) {
811
+ // already stopped/destroyed - fine
812
+ }
813
+ }
814
+ connection.destroy();
815
+
816
+ // Every one of these is per-guild state that used to survive a
817
+ // /leave untouched. Most critically: if isPlaying was still true
818
+ // (e.g. TTS got cut off mid-playback by the destroy() above, or
819
+ // was never cleanly resolved), the NEXT /join's audio would hit
820
+ // `if (isPlaying.get(guildId)) return;` at the very top of the PCM
821
+ // data handler and get silently dropped forever - STT and wake
822
+ // detection both stop working, with no error, until the whole bot
823
+ // restarts. Same idea for a stale `players` entry: playNextInQueue
824
+ // reuses whatever's cached instead of creating a fresh one, so a
825
+ // leftover player from the destroyed connection could end up
826
+ // "subscribed" to nothing and never reach Idle, which is exactly
827
+ // what a permanently-on "speaking" indicator on the next join looks
828
+ // like from the outside.
829
+ connections.delete(guild_id);
830
+ players.delete(guild_id);
831
+ audioQueues.delete(guild_id);
832
+ isPlaying.delete(guild_id);
833
+ activeUntil.delete(guild_id);
834
+ suppressNotifyMap.delete(guild_id);
835
+
836
+ res.json({ success: true });
837
+ });
838
+
839
+ // Tracks whether the audio that just finished playing for a guild
840
+ // should be treated as a real conversational turn (extends the "stay
841
+ // awake" window) or not (e.g. wake-word enrollment sample playback -
842
+ // see EnrollmentManager._play_audio's suppress_active_window). Set
843
+ // whenever an item is shifted off the queue to play; read once the
844
+ // queue drains and playback is fully idle again.
845
+ const suppressNotifyMap = new Map();
846
+
847
+ async function notifyTtsFinished(guild_id) {
848
+ try {
849
+ await fetch('http://127.0.0.1:18080/tts_finished', {
850
+ method: 'POST',
851
+ headers: { 'Content-Type': 'application/json' },
852
+ body: JSON.stringify({ guild_id }),
853
+ });
854
+ } catch (err) {
855
+ console.error(`[TTS] Failed to notify Python of playback completion:`, err.message);
856
+ }
857
+ }
858
+
859
+ function playNextInQueue(guild_id) {
860
+ const queue = audioQueues.get(guild_id) || [];
861
+ if (queue.length === 0) {
862
+ isPlaying.set(guild_id, false);
863
+ if (!suppressNotifyMap.get(guild_id)) {
864
+ notifyTtsFinished(guild_id);
865
+ }
866
+ return;
867
+ }
868
+
869
+ const connection = connections.get(guild_id);
870
+ if (!connection) {
871
+ isPlaying.set(guild_id, false);
872
+ return;
873
+ }
874
+
875
+ isPlaying.set(guild_id, true);
876
+ const item = queue.shift(); // { buffer, suppressActiveWindow }
877
+ suppressNotifyMap.set(guild_id, item.suppressActiveWindow);
878
+
879
+ try {
880
+ let player = players.get(guild_id);
881
+ if (!player) {
882
+ player = createAudioPlayer();
883
+ players.set(guild_id, player);
884
+ connection.subscribe(player);
885
+
886
+ player.on(AudioPlayerStatus.Idle, () => {
887
+ playNextInQueue(guild_id);
888
+ });
889
+
890
+ player.on('error', (error) => {
891
+ console.error(`[TTS] AudioPlayer Error:`, error.message);
892
+ playNextInQueue(guild_id);
893
+ });
894
+ }
895
+
896
+ const resource = createAudioResource(Readable.from(item.buffer));
897
+ player.play(resource);
898
+ } catch (e) {
899
+ console.error(`[TTS] Error playing queued audio:`, e);
900
+ playNextInQueue(guild_id);
901
+ }
902
+ }
903
+
904
+ app.post('/play', express.raw({ type: 'application/octet-stream', limit: '20mb' }), (req, res) => {
905
+ const guild_id = req.query.guild_id;
906
+ const connection = connections.get(guild_id);
907
+ if (!connection) return res.status(404).json({ error: 'Not connected' });
908
+
909
+ if (!audioQueues.has(guild_id)) {
910
+ audioQueues.set(guild_id, []);
911
+ }
912
+
913
+ audioQueues.get(guild_id).push({
914
+ buffer: req.body, // req.body is a Buffer here
915
+ suppressActiveWindow: req.query.suppress_active_window === 'true',
916
+ });
917
+
918
+ if (!isPlaying.get(guild_id)) {
919
+ playNextInQueue(guild_id);
920
+ }
921
+
922
+ res.json({ success: true, queued: true });
923
+ });
924
+
925
+ app.post('/interrupt', (req, res) => {
926
+ const { guild_id } = req.body;
927
+ // Same mechanism the VAD loud-voice check uses (interruptTTS) - this
928
+ // just gives Python a way to trigger it directly, for the case where
929
+ // a new recognized utterance should cut off whatever's currently
930
+ // playing regardless of how loud it was (see VoiceCog.handle_stt_input,
931
+ // which calls this before starting a new turn whenever a previous
932
+ // one was still in flight).
933
+ interruptTTS(guild_id);
934
+ res.json({ success: true });
935
+ });
936
+
937
+ app.post('/invalidate_detector', (req, res) => {
938
+ // Called by EnrollmentManager._commit_enrollment right after a
939
+ // NEW .rpw is successfully built. Without this, detectorCache (keyed
940
+ // only by user_id, loaded once and cached forever) keeps serving
941
+ // whatever detector - built from an OLDER recording, possibly for a
942
+ // completely different word - was cached the first time this user
943
+ // was ever checked, no matter how many times they re-enroll. That's
944
+ // enough on its own to make every wake attempt score exactly 0
945
+ // forever: it's not comparing against the word that was just said.
946
+ const { user_id } = req.body;
947
+ const deleted = detectorCache.delete(user_id);
948
+ console.log(`[Wake] Invalidated cached detector for ${user_id} (was cached: ${deleted})`);
949
+ res.json({ success: true, was_cached: deleted });
950
+ });
951
+
952
+ app.post('/build_wakeword', async (req, res) => {
953
+ // Builds a .rpw wake-word reference directly from the accepted
954
+ // enrollment wav samples, entirely in-process via rustpotter-web's
955
+ // WakewordRefCreator - see EnrollmentManager._build_rustpotter_reference
956
+ // in cogs/voice/enrollment.py, which used to shell out to a
957
+ // separately-downloaded rustpotter-cli binary for this exact step.
958
+ // That meant an extra install-time download (GitHub Releases API,
959
+ // OS/arch guessing, no checksum verification) just to run a build
960
+ // command whose only real job was calling the same builder API this
961
+ // now calls directly. Nothing else about .rpw files changes: the
962
+ // hot-path detector (getDetectorForUser) still just loads the bytes
963
+ // this returns, the same as it always did.
964
+ try {
965
+ const { name, samples } = req.body;
966
+ if (!name || !Array.isArray(samples) || samples.length === 0) {
967
+ return res
968
+ .status(400)
969
+ .json({ error: 'name and at least one sample (wav bytes) are required' });
970
+ }
971
+
972
+ const mod = await loadRustpotterModule();
973
+ const creator = mod.WakewordRefCreator.new(name);
974
+ try {
975
+ for (const sample of samples) {
976
+ const buf = Buffer.from(sample.data_base64, 'base64');
977
+ creator.addFile(sample.filename || `${name}.wav`, buf);
978
+ }
979
+ const rpwBytes = creator.saveToBytes();
980
+ console.log(
981
+ `[Wake] Built .rpw for '${name}' from ${samples.length} sample(s) via WakewordRefCreator`,
982
+ );
983
+ res.json({ success: true, rpw_base64: Buffer.from(rpwBytes).toString('base64') });
984
+ } finally {
985
+ creator.free();
986
+ }
987
+ } catch (e) {
988
+ console.error(`[Wake] Failed to build wakeword reference:`, e);
989
+ res.status(500).json({ error: e.message || String(e) });
990
+ }
991
+ });
992
+
993
+ app.post('/set_config', (req, res) => {
994
+ const { voice_threshold } = req.body;
995
+ if (voice_threshold) {
996
+ vadThreshold = voice_threshold;
997
+ console.log(`[Config] Updated VAD threshold to ${vadThreshold}`);
998
+ }
999
+ res.json({ success: true });
1000
+ });
1001
+
1002
+ app.post('/enroll_start', (req, res) => {
1003
+ const { user_id } = req.body;
1004
+ if (!user_id) return res.status(400).json({ error: 'user_id required' });
1005
+ enrollingUsers.add(user_id);
1006
+ res.json({ success: true });
1007
+ });
1008
+
1009
+ app.post('/enroll_stop', (req, res) => {
1010
+ const { user_id } = req.body;
1011
+ if (!user_id) return res.status(400).json({ error: 'user_id required' });
1012
+ enrollingUsers.delete(user_id);
1013
+ res.json({ success: true });
1014
+ });
1015
+
1016
+ app.post('/set_active', (req, res) => {
1017
+ const { guild_id, active_until } = req.body;
1018
+ if (!guild_id || !active_until)
1019
+ return res.status(400).json({ error: 'guild_id and active_until required' });
1020
+ activeUntil.set(guild_id, active_until);
1021
+ res.json({ success: true });
1022
+ });
1023
+
1024
+ process.on('unhandledRejection', (reason) => {
1025
+ console.error('Unhandled promise rejection (voice service stays alive):', reason);
1026
+ });
1027
+
1028
+ process.on('uncaughtException', (err) => {
1029
+ // Without this handler, an uncaught synchronous error kills the
1030
+ // process with no trace of why - which is what made the previous
1031
+ // "voice service just disappeared mid-enrollment" reports
1032
+ // undiagnosable. Node still exits after this (an uncaughtException
1033
+ // means something is in an unknown state - continuing risks worse
1034
+ // corruption than restarting), but now the cause is on record.
1035
+ console.error('Uncaught exception - voice service is exiting:', err);
1036
+ process.exit(1);
1037
+ });
1038
+
1039
+ const PORT = 18081;
1040
+ app.listen(PORT, '0.0.0.0', () => {
1041
+ console.log(`Node.js Voice API listening on port ${PORT}`);
1042
+ client.login(process.env.DISCORD_TOKEN).catch((err) => {
1043
+ console.error('Failed to log in to Discord:', err.message);
1044
+ console.error(
1045
+ 'Voice features will be unavailable until this is fixed - check your Discord token (run `lgy setup`).',
1046
+ );
1047
+ });
1048
+ });