linkgravity 1.4.0 → 1.5.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.
@@ -70,14 +70,7 @@ const connections = new Map();
70
70
  const players = new Map();
71
71
 
72
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.
73
+ // Discord voice receive is 48kHz stereo; everything downstream (WAV, Rustpotter) expects mono.
81
74
  const samples = buffer.length >> 2; // 2 bytes/sample * 2 channels
82
75
  const mono = Buffer.alloc(samples * 2);
83
76
  for (let i = 0; i < samples; i++) {
@@ -110,21 +103,8 @@ client.once(Events.ClientReady, () => {
110
103
  console.log(`🎤 Node.js Voice Microservice is online as ${client.user.tag}`);
111
104
  });
112
105
 
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.
106
+ // Unofficial Google speech API endpoint/key - same one Python's SpeechRecognition library
107
+ // (recognize_google) ships as its default; publicly known but could be rate-limited/changed anytime.
128
108
  const GOOGLE_STT_KEY = 'AIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgw';
129
109
 
130
110
  function flacEncode(wavBuffer) {
@@ -177,9 +157,7 @@ async function googleSTT(wavBuffer, lang = 'ko-KR') {
177
157
  }
178
158
 
179
159
  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}
160
+ // Response is newline-delimited JSON, one object per line.
183
161
  for (const line of raw.trim().split('\n')) {
184
162
  if (!line) continue;
185
163
  try {
@@ -218,47 +196,28 @@ function interruptTTS(guildId) {
218
196
 
219
197
  const activeStreams = new Map();
220
198
 
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.
199
+ // user_id -> recording wake-word samples right now; routes to /enroll_sample instead of STT.
224
200
  const enrollingUsers = new Set();
225
201
 
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).
202
+ // guild_id -> ms epoch until the "awake, skip wake word" window closes (set via /set_active).
229
203
  const activeUntil = new Map();
230
204
 
205
+ // user_id -> opted out of wake-word gating via /sound - scoped per-user, unlike activeUntil.
206
+ const wakeWordOptedOut = new Set();
207
+
231
208
  function isGuildActive(guildId) {
232
209
  return Date.now() < (activeUntil.get(guildId) || 0);
233
210
  }
234
211
 
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.
212
+ // Rustpotter wake-word detection runs entirely in-process here, no Python round trip.
242
213
  const WAKE_REF_DIR = path.join(os.homedir(), '.gemini', 'linkgravity', 'wake_refs');
243
214
 
244
215
  let rustpotterModPromise = null;
245
216
  function loadRustpotterModule() {
246
217
  if (!rustpotterModPromise) {
247
218
  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).
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.
262
221
  const mod = await import('rustpotter-web/rustpotter_wasm.js');
263
222
  const wasmPath = require.resolve('rustpotter-web/rustpotter_wasm_bg.wasm');
264
223
  mod.initSync(fs.readFileSync(wasmPath));
@@ -288,64 +247,22 @@ async function getDetectorForUser(userId) {
288
247
  config.setSampleRate(48000);
289
248
  config.setSampleFormat(mod.SampleFormat.i16);
290
249
  config.setChannels(1);
291
- // See WAKE_MATCH_THRESHOLD's comment above - this MUST be a real,
292
- // meaningful cutoff (not near-zero) for rustpotter's internal
293
- // confirm-after-N-more-frames logic to ever finalize a detection.
250
+ // Must stay a real cutoff (not near-zero) for rustpotter's confirm-after-N-frames logic to finalize.
294
251
  config.setThreshold(WAKE_MATCH_THRESHOLD);
295
252
  config.setAveragedThreshold(0);
296
- // Default is 1 - accepting a match the very first time it's ever
297
- // the leading candidate, even for just one internal frame. Raised
298
- // to 4 (was 3) so a candidate has to keep winning for a few frames
299
- // running before it's trusted - this matters more now that
300
- // scoreMode is back to Max (see below), which is more permissive
301
- // per-frame than Median was, so this is the main compensating knob
302
- // against one-off spurious spikes from unrelated speech.
253
+ // Raised from default 1 so a candidate has to keep winning for a few frames before it's trusted.
303
254
  config.setMinScores(4);
304
- // Max: score is whichever of the 5 enrolled samples matches best.
305
- // Median (tried between the two calibration notes below) requires
306
- // the middle-ranked sample to also score well, which in practice
307
- // means all 5 enrollment recordings need fairly consistent
308
- // tone/pace/delivery - real speech isn't that consistent, so Median
309
- // made real matches with naturally varied enrollment recordings
310
- // score worse, not just false positives. Max lets a genuinely
311
- // varied set of 5 recordings (calm, rushed, questioning, etc.) each
312
- // cover a different real-world delivery, at the cost of also being
313
- // easier to trip with something that merely resembles ONE of the 5
314
- // by chance - minScores(4) above and the STT jamo cross-check in
315
- // VoiceCog.handle_stt_input (tightened for short wake words
316
- // specifically) are what compensate for that on the other two axes
317
- // instead. Still needs field-tuning against real usage logs - see
318
- // README's known-issues section.
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.
319
257
  config.setScoreMode(mod.ScoreMode.max);
320
258
 
321
259
  const rustpotter = mod.Rustpotter.new(config);
322
260
  rustpotter.addWakeword(rpwFile, fs.readFileSync(path.join(userDir, rpwFile)));
323
261
 
324
- // Second, diagnostic-only instance fed the exact same audio in
325
- // parallel, purely so "no match" cases still show a real number in
326
- // the logs. It NEVER gates wake behavior - only entry.rustpotter
327
- // above does that. Why a separate instance instead of reading a
328
- // low score off the real one: rustpotter's own scoring
329
- // (wakeword.run_detection in the Rust source) filters out any
330
- // frame that doesn't already clear `threshold` before it's even
331
- // tracked internally, so there is no bound API that exposes a
332
- // sub-threshold score - process*() returns undefined for those,
333
- // full stop. Naively lowering the REAL detector's threshold to see
334
- // more doesn't work either (that's exactly the bug from the
335
- // local-wake migration: near-zero threshold means background noise
336
- // clears it on almost every frame, which keeps resetting
337
- // detection_countdown before it can ever reach 0, so confirmation
338
- // never completes for anyone, real wake word included).
339
- //
340
- // This instance sidesteps that by setting eager+minScores(1), so it
341
- // finalizes and hands back a real score the very first time ANY
342
- // frame clears its own (near-zero) threshold, instead of waiting on
343
- // the countdown - the exact same escape hatch that would break
344
- // gating on the real detector is safe to lean on here because nothing
345
- // downstream ever treats this instance's output as a wake trigger.
346
- // scoreMode is kept identical to the real detector so the number
347
- // itself is a genuine, comparable similarity score - just captured
348
- // more eagerly, not computed differently.
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.
349
266
  const diagConfig = mod.RustpotterConfig.new();
350
267
  diagConfig.setSampleRate(48000);
351
268
  diagConfig.setSampleFormat(mod.SampleFormat.i16);
@@ -375,16 +292,8 @@ async function getDetectorForUser(userId) {
375
292
  return entry;
376
293
  }
377
294
 
378
- // Feeds one Discord PCM chunk to a detector, exactly once per sample and
379
- // frame-aligned via a residual carryover - NOT by periodically
380
- // re-snapshotting a growing buffer, since Rustpotter's internal window
381
- // expects a genuinely continuous stream. Returns a RustpotterDetection
382
- // if any complete frame in this chunk triggered one, else null.
383
- //
384
- // Frames are fed back-to-back with NO external overlap - rustpotter
385
- // internally extracts multiple overlapping 10ms-shifted MFCCs from each
386
- // ~30ms buffer passed in (confirmed against its Rust source), so the
387
- // caller just needs to keep the stream continuous, not overlap it.
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.
388
297
  function feedPCMToDetector(entry, chunk) {
389
298
  const incoming = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.length / 2);
390
299
  let combined = incoming;
@@ -430,13 +339,7 @@ function setupReceiver(connection, guildId) {
430
339
  new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 }),
431
340
  );
432
341
 
433
- // A single corrupted/dropped Opus packet (packet loss, a bad
434
- // DAVE re-encrypt, whatever) throws inside prism-media's
435
- // decoder. Streams turn a thrown _transform error into an
436
- // 'error' event - with no listener here, Node's default for an
437
- // unhandled 'error' event is to crash the ENTIRE process, not
438
- // just this one user's utterance. Handling it here keeps voice
439
- // alive for everyone else (and for this user's next utterance).
342
+ // Without a listener, an unhandled 'error' event here crashes the ENTIRE process on one bad packet.
440
343
  opusStream.on('error', (err) => {
441
344
  console.error(`[Voice] Opus stream error for ${userId}:`, err.message);
442
345
  forceEndStream();
@@ -466,14 +369,7 @@ function setupReceiver(connection, guildId) {
466
369
 
467
370
  const maxDurationTimer = setTimeout(forceEndStream, 30000);
468
371
 
469
- // --- Wake-word gating (Rustpotter, in-process) ---
470
- // Runs entirely inside this Node process now - no HTTP round
471
- // trip, no Python involvement. Detector instances are cached per
472
- // Discord user (see getDetectorForUser) and fed every PCM chunk
473
- // as it streams in below, frame-aligned via feedPCMToDetector's
474
- // residual carryover - not via periodic re-snapshotting like the
475
- // old /wake_check design, since Rustpotter expects each sample
476
- // fed exactly once, in order.
372
+ // Rustpotter runs in-process here, no Python round trip - detectors cached per user.
477
373
  let wakeConfirmed = false;
478
374
  let matchedWakeWord = null;
479
375
  let detectorEntry = null;
@@ -498,13 +394,8 @@ function setupReceiver(connection, guildId) {
498
394
  );
499
395
  }
500
396
 
501
- // --- Live "listening..." indicator ---
502
- // Only runs during the active/awake window (bounded, default
503
- // 60s) - NOT for every VAD-detected sound like the old version,
504
- // which is what made it expensive before. STT here reuses the
505
- // same googleSTT() call that runs at utterance end anyway, just
506
- // invoked earlier/more often on the growing buffer for live
507
- // feedback.
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.
508
399
  const PARTIAL_INTERVAL_MS = 1500;
509
400
  const PARTIAL_MIN_NEW_BYTES = 24000;
510
401
  let lastPartialLength = 0;
@@ -700,20 +591,8 @@ function setupReceiver(connection, guildId) {
700
591
  return;
701
592
  }
702
593
 
703
- // Whether this utterance is worth transcribing at all:
704
- // - isGuildActive(): already awake, no need to repeat the
705
- // wake word (this is also what makes the live "listening"
706
- // indicator above meaningful - same window).
707
- // - wakeConfirmed: our in-process Rustpotter detector matched this
708
- // user's voice against their enrolled samples.
709
- // There is deliberately no "unenrolled users always get
710
- // transcribed" fallback anymore - that existed only to feed
711
- // the Python side's old text-similarity wake-word matching,
712
- // which has been removed (it was exactly the always-on
713
- // recognition overhead this Rustpotter migration was meant to
714
- // get rid of). An unenrolled user simply can't wake the bot
715
- // by voice until they run /sound.
716
- const shouldTranscribe = isGuildActive(guildId) || wakeConfirmed;
594
+ const shouldTranscribe =
595
+ isGuildActive(guildId) || wakeConfirmed || wakeWordOptedOut.has(userId);
717
596
 
718
597
  if (!shouldTranscribe) {
719
598
  if (partialSent) {
@@ -800,19 +679,7 @@ app.post('/leave', (req, res) => {
800
679
  }
801
680
  connection.destroy();
802
681
 
803
- // Every one of these is per-guild state that used to survive a
804
- // /leave untouched. Most critically: if isPlaying was still true
805
- // (e.g. TTS got cut off mid-playback by the destroy() above, or
806
- // was never cleanly resolved), the NEXT /join's audio would hit
807
- // `if (isPlaying.get(guildId)) return;` at the very top of the PCM
808
- // data handler and get silently dropped forever - STT and wake
809
- // detection both stop working, with no error, until the whole bot
810
- // restarts. Same idea for a stale `players` entry: playNextInQueue
811
- // reuses whatever's cached instead of creating a fresh one, so a
812
- // leftover player from the destroyed connection could end up
813
- // "subscribed" to nothing and never reach Idle, which is exactly
814
- // what a permanently-on "speaking" indicator on the next join looks
815
- // like from the outside.
682
+ // Without this, a stale isPlaying/players entry silently breaks STT/wake detection on the next /join.
816
683
  connections.delete(guild_id);
817
684
  players.delete(guild_id);
818
685
  audioQueues.delete(guild_id);
@@ -823,12 +690,8 @@ app.post('/leave', (req, res) => {
823
690
  res.json({ success: true });
824
691
  });
825
692
 
826
- // Tracks whether the audio that just finished playing for a guild
827
- // should be treated as a real conversational turn (extends the "stay
828
- // awake" window) or not (e.g. wake-word enrollment sample playback -
829
- // see EnrollmentManager._play_audio's suppress_active_window). Set
830
- // whenever an item is shifted off the queue to play; read once the
831
- // queue drains and playback is fully idle again.
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).
832
695
  const suppressNotifyMap = new Map();
833
696
 
834
697
  async function notifyTtsFinished(guild_id) {
@@ -911,25 +774,13 @@ app.post('/play', express.raw({ type: 'application/octet-stream', limit: '20mb'
911
774
 
912
775
  app.post('/interrupt', (req, res) => {
913
776
  const { guild_id } = req.body;
914
- // Same mechanism the VAD loud-voice check uses (interruptTTS) - this
915
- // just gives Python a way to trigger it directly, for the case where
916
- // a new recognized utterance should cut off whatever's currently
917
- // playing regardless of how loud it was (see VoiceCog.handle_stt_input,
918
- // which calls this before starting a new turn whenever a previous
919
- // one was still in flight).
777
+ // Lets Python trigger the same cutoff the VAD loud-voice check uses, regardless of volume.
920
778
  interruptTTS(guild_id);
921
779
  res.json({ success: true });
922
780
  });
923
781
 
924
782
  app.post('/invalidate_detector', (req, res) => {
925
- // Called by EnrollmentManager._commit_enrollment right after a
926
- // NEW .rpw is successfully built. Without this, detectorCache (keyed
927
- // only by user_id, loaded once and cached forever) keeps serving
928
- // whatever detector - built from an OLDER recording, possibly for a
929
- // completely different word - was cached the first time this user
930
- // was ever checked, no matter how many times they re-enroll. That's
931
- // enough on its own to make every wake attempt score exactly 0
932
- // forever: it's not comparing against the word that was just said.
783
+ // Without this, detectorCache keeps serving the OLD .rpw after a user re-enrolls.
933
784
  const { user_id } = req.body;
934
785
  const deleted = detectorCache.delete(user_id);
935
786
  console.log(`[Wake] Invalidated cached detector for ${user_id} (was cached: ${deleted})`);
@@ -937,17 +788,8 @@ app.post('/invalidate_detector', (req, res) => {
937
788
  });
938
789
 
939
790
  app.post('/build_wakeword', async (req, res) => {
940
- // Builds a .rpw wake-word reference directly from the accepted
941
- // enrollment wav samples, entirely in-process via rustpotter-web's
942
- // WakewordRefCreator - see EnrollmentManager._build_rustpotter_reference
943
- // in cogs/voice/enrollment.py, which used to shell out to a
944
- // separately-downloaded rustpotter-cli binary for this exact step.
945
- // That meant an extra install-time download (GitHub Releases API,
946
- // OS/arch guessing, no checksum verification) just to run a build
947
- // command whose only real job was calling the same builder API this
948
- // now calls directly. Nothing else about .rpw files changes: the
949
- // hot-path detector (getDetectorForUser) still just loads the bytes
950
- // this returns, the same as it always did.
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.
951
793
  try {
952
794
  const { name, samples } = req.body;
953
795
  if (!name || !Array.isArray(samples) || samples.length === 0) {
@@ -1008,6 +850,14 @@ app.post('/set_active', (req, res) => {
1008
850
  res.json({ success: true });
1009
851
  });
1010
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
+ });
860
+
1011
861
  process.on('unhandledRejection', (reason) => {
1012
862
  console.error('Unhandled promise rejection (voice service stays alive):', reason);
1013
863
  });