linkgravity 1.5.1 → 1.5.3

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.
@@ -0,0 +1,310 @@
1
+ const { EndBehaviorType } = require('@discordjs/voice');
2
+ const prism = require('prism-media');
3
+ const { stereoToMono, createWavHeader } = require('./audioUtils');
4
+ const { googleSTT } = require('./stt');
5
+ const { getDetectorForUser, feedPCMToDetector, WAKE_MATCH_THRESHOLD } = require('./wakeword');
6
+ const { interruptTTS } = require('./tts');
7
+ const state = require('./state');
8
+ const { activeStreams, enrollingUsers, isPlaying, wakeWordOptedOut, runtime, isGuildActive } =
9
+ state;
10
+
11
+ function setupReceiver(connection, guildId, client) {
12
+ const receiver = connection.receiver;
13
+
14
+ receiver.speaking.removeAllListeners('start');
15
+
16
+ receiver.speaking.on('start', (userId) => {
17
+ if (client.user.id === userId) return;
18
+
19
+ if (activeStreams.get(userId)) {
20
+ return;
21
+ }
22
+ activeStreams.set(userId, true);
23
+
24
+ let hasInterrupted = false;
25
+
26
+ const opusStream = receiver.subscribe(userId, {
27
+ end: {
28
+ behavior: EndBehaviorType.Manual,
29
+ },
30
+ });
31
+ const pcmStream = opusStream.pipe(
32
+ new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 }),
33
+ );
34
+
35
+ // Without a listener, an unhandled 'error' event here crashes the ENTIRE process on one bad packet.
36
+ opusStream.on('error', (err) => {
37
+ console.error(`[Voice] Opus stream error for ${userId}:`, err.message);
38
+ forceEndStream();
39
+ });
40
+ pcmStream.on('error', (err) => {
41
+ console.error(
42
+ `[Voice] Opus decode error for ${userId} (bad/corrupted packet):`,
43
+ err.message,
44
+ );
45
+ forceEndStream();
46
+ });
47
+
48
+ const chunks = [];
49
+
50
+ let hasEnded = false;
51
+
52
+ const forceEndStream = () => {
53
+ if (hasEnded) return;
54
+ try {
55
+ opusStream.destroy();
56
+ } catch (e) {}
57
+ try {
58
+ pcmStream.destroy();
59
+ } catch (e) {}
60
+ pcmStream.emit('end');
61
+ };
62
+
63
+ const maxDurationTimer = setTimeout(forceEndStream, 30000);
64
+
65
+ // Rustpotter runs in-process here, no Python round trip - detectors cached per user.
66
+ let wakeConfirmed = false;
67
+ let matchedWakeWord = null;
68
+ let detectorEntry = null;
69
+ let bestWakeScore = 0;
70
+ let bestWakeScoreName = null;
71
+ let bestDiagScore = 0;
72
+ let bestDiagScoreName = null;
73
+
74
+ if (!enrollingUsers.has(userId) && !isGuildActive(guildId)) {
75
+ getDetectorForUser(userId)
76
+ .then((entry) => {
77
+ if (entry) {
78
+ entry.rustpotter.reset();
79
+ entry.residual = new Int16Array(0);
80
+ entry.diag.rustpotter.reset();
81
+ entry.diag.residual = new Int16Array(0);
82
+ detectorEntry = entry;
83
+ }
84
+ })
85
+ .catch((err) =>
86
+ console.error(`[Wake] Failed to load detector for ${userId}:`, err.message),
87
+ );
88
+ }
89
+
90
+ // Only during the active/awake window - reuses the same googleSTT() call for earlier live feedback.
91
+ const PARTIAL_INTERVAL_MS = 1500;
92
+ const PARTIAL_MIN_NEW_BYTES = 24000;
93
+ let lastPartialLength = 0;
94
+ let partialSent = false;
95
+ const partialTimer = setInterval(async () => {
96
+ if (hasEnded || !isSpeaking || enrollingUsers.has(userId)) return;
97
+ if (!isGuildActive(guildId)) return;
98
+
99
+ const currentLength = chunks.reduce((sum, c) => sum + c.length, 0);
100
+ if (currentLength - lastPartialLength < PARTIAL_MIN_NEW_BYTES) return;
101
+ lastPartialLength = currentLength;
102
+
103
+ const windowPcm = Buffer.concat(chunks);
104
+ const wavHeader = createWavHeader(windowPcm.length);
105
+ const wavBuffer = Buffer.concat([wavHeader, windowPcm]);
106
+
107
+ const text = await googleSTT(wavBuffer);
108
+ partialSent = true;
109
+ fetch('http://127.0.0.1:18080/stt_partial', {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({ guild_id: guildId, text }),
113
+ }).catch((err) =>
114
+ console.error(`[STT] Failed to send partial text to Python:`, err.message),
115
+ );
116
+ }, PARTIAL_INTERVAL_MS);
117
+
118
+ let bgNoiseRMS = 500;
119
+ let isSpeaking = false;
120
+ let silenceBytes = 0;
121
+ let silenceTimer = null;
122
+
123
+ pcmStream.on('data', (rawChunk) => {
124
+ if (hasEnded) return;
125
+ const chunk = stereoToMono(rawChunk); // see stereoToMono's comment - decoder gives real stereo now
126
+
127
+ let sumSquare = 0;
128
+ for (let i = 0; i < chunk.length; i += 2) {
129
+ const sample = chunk.readInt16LE(i);
130
+ sumSquare += sample * sample;
131
+ }
132
+ const rms = Math.sqrt(sumSquare / (chunk.length / 2));
133
+
134
+ const isBotPlaying = isPlaying.get(guildId) || false;
135
+
136
+ if (!hasInterrupted) {
137
+ const dynamicThreshold = isBotPlaying
138
+ ? runtime.vadThreshold * 3
139
+ : runtime.vadThreshold;
140
+ if (rms > dynamicThreshold) {
141
+ if (interruptTTS(guildId)) {
142
+ console.log(
143
+ `[VAD] Loud voice detected (${Math.round(rms)}), interrupting TTS (Threshold: ${dynamicThreshold})`,
144
+ );
145
+ hasInterrupted = true;
146
+ }
147
+ }
148
+ }
149
+
150
+ if (detectorEntry) {
151
+ const detection = feedPCMToDetector(detectorEntry, chunk);
152
+ if (detection && detection.getScore() > bestWakeScore) {
153
+ bestWakeScore = detection.getScore();
154
+ bestWakeScoreName = detection.getName();
155
+ }
156
+ const diagDetection = feedPCMToDetector(detectorEntry.diag, chunk);
157
+ if (diagDetection && diagDetection.getScore() > bestDiagScore) {
158
+ bestDiagScore = diagDetection.getScore();
159
+ bestDiagScoreName = diagDetection.getName();
160
+ }
161
+ }
162
+
163
+ if (isPlaying.get(guildId)) {
164
+ return;
165
+ }
166
+
167
+ chunks.push(chunk);
168
+
169
+ if (!isSpeaking) {
170
+ bgNoiseRMS = bgNoiseRMS * 0.98 + rms * 0.02;
171
+ bgNoiseRMS = Math.max(50, Math.min(bgNoiseRMS, 3000));
172
+ }
173
+
174
+ const threshold = Math.max(bgNoiseRMS * 2.0, 800);
175
+
176
+ if (rms > threshold) {
177
+ isSpeaking = true;
178
+ silenceBytes = 0;
179
+ } else {
180
+ if (isSpeaking) {
181
+ silenceBytes += chunk.length;
182
+ if (silenceBytes >= 76800) {
183
+ forceEndStream();
184
+ return;
185
+ }
186
+ }
187
+ }
188
+
189
+ if (isSpeaking) {
190
+ if (silenceTimer) clearTimeout(silenceTimer);
191
+ silenceTimer = setTimeout(() => forceEndStream(), 800);
192
+ }
193
+ });
194
+
195
+ pcmStream.on('end', async () => {
196
+ if (hasEnded) return;
197
+ hasEnded = true;
198
+ clearTimeout(maxDurationTimer);
199
+ clearInterval(partialTimer);
200
+ if (silenceTimer) clearTimeout(silenceTimer);
201
+
202
+ activeStreams.delete(userId);
203
+
204
+ if (detectorEntry) {
205
+ // Live capture stops before rustpotter's confirm countdown finishes, so pad with
206
+ // silence to let a genuine match finalize instead of being silently discarded.
207
+ const paddingBuffer = Buffer.alloc(detectorEntry.samplesPerFrame * 100 * 2);
208
+ const paddingDetection = feedPCMToDetector(detectorEntry, paddingBuffer);
209
+ if (paddingDetection && paddingDetection.getScore() > bestWakeScore) {
210
+ bestWakeScore = paddingDetection.getScore();
211
+ bestWakeScoreName = paddingDetection.getName();
212
+ }
213
+ const diagPaddingBuffer = Buffer.alloc(
214
+ detectorEntry.diag.samplesPerFrame * 100 * 2,
215
+ );
216
+ const diagPaddingDetection = feedPCMToDetector(
217
+ detectorEntry.diag,
218
+ diagPaddingBuffer,
219
+ );
220
+ if (diagPaddingDetection && diagPaddingDetection.getScore() > bestDiagScore) {
221
+ bestDiagScore = diagPaddingDetection.getScore();
222
+ bestDiagScoreName = diagPaddingDetection.getName();
223
+ }
224
+
225
+ // 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.
226
+ wakeConfirmed = bestWakeScore >= WAKE_MATCH_THRESHOLD;
227
+ matchedWakeWord = wakeConfirmed ? bestWakeScoreName : null;
228
+ console.log(
229
+ wakeConfirmed
230
+ ? `[Wake] ${userId}: CONFIRMED (score ${bestWakeScore.toFixed(3)} for ` +
231
+ `"${bestWakeScoreName}", threshold ${WAKE_MATCH_THRESHOLD})`
232
+ : `[Wake] ${userId}: no match (score ${bestWakeScore.toFixed(3)}, ` +
233
+ `threshold ${WAKE_MATCH_THRESHOLD}; diagnostic-only closeness ` +
234
+ `${bestDiagScore.toFixed(3)} for "${bestDiagScoreName ?? 'n/a'}" - ` +
235
+ `different scoring config, not directly comparable to the threshold)`,
236
+ );
237
+ }
238
+
239
+ const pcmBuffer = Buffer.concat(chunks);
240
+
241
+ // Enrollment mode: this is a reference sample, not a command - skip wake-check/STT.
242
+ if (enrollingUsers.has(userId)) {
243
+ if (pcmBuffer.length < 4000) return; // too short to be a real sample
244
+ const wavHeader = createWavHeader(pcmBuffer.length);
245
+ const wavBuffer = Buffer.concat([wavHeader, pcmBuffer]);
246
+ try {
247
+ await fetch(
248
+ `http://127.0.0.1:18080/enroll_sample?user_id=${encodeURIComponent(userId)}`,
249
+ {
250
+ method: 'POST',
251
+ headers: { 'Content-Type': 'application/octet-stream' },
252
+ body: wavBuffer,
253
+ },
254
+ );
255
+ } catch (err) {
256
+ console.error(`[Enroll] Failed to send sample to Python:`, err.message);
257
+ }
258
+ return;
259
+ }
260
+
261
+ // Was 24000 (250ms) - cut off short Korean replies ("네"/"어"/"응"); noise is filtered upstream by isSpeaking's RMS/sustain check, not by duration.
262
+ if (pcmBuffer.length < 9600) {
263
+ if (partialSent) {
264
+ fetch('http://127.0.0.1:18080/stt_partial_cancel', {
265
+ method: 'POST',
266
+ headers: { 'Content-Type': 'application/json' },
267
+ body: JSON.stringify({ guild_id: guildId }),
268
+ }).catch(() => {});
269
+ }
270
+ return;
271
+ }
272
+
273
+ const shouldTranscribe =
274
+ isGuildActive(guildId) || wakeConfirmed || wakeWordOptedOut.has(userId);
275
+
276
+ if (!shouldTranscribe) {
277
+ if (partialSent) {
278
+ fetch('http://127.0.0.1:18080/stt_partial_cancel', {
279
+ method: 'POST',
280
+ headers: { 'Content-Type': 'application/json' },
281
+ body: JSON.stringify({ guild_id: guildId }),
282
+ }).catch(() => {});
283
+ }
284
+ return;
285
+ }
286
+
287
+ const wavHeader = createWavHeader(pcmBuffer.length);
288
+ const wavBuffer = Buffer.concat([wavHeader, pcmBuffer]);
289
+ const text = await googleSTT(wavBuffer);
290
+
291
+ try {
292
+ await fetch('http://127.0.0.1:18080/stt_input', {
293
+ method: 'POST',
294
+ headers: { 'Content-Type': 'application/json' },
295
+ body: JSON.stringify({
296
+ user_id: userId,
297
+ guild_id: guildId,
298
+ text,
299
+ wake_confirmed: wakeConfirmed,
300
+ matched_wake_word: matchedWakeWord,
301
+ }),
302
+ });
303
+ } catch (err) {
304
+ console.error(`[STT] Failed to send recognized text to Python:`, err.message);
305
+ }
306
+ });
307
+ });
308
+ }
309
+
310
+ module.exports = { setupReceiver };
@@ -0,0 +1,182 @@
1
+ const express = require('express');
2
+ const { joinVoiceChannel, VoiceConnectionStatus } = require('@discordjs/voice');
3
+ const state = require('./state');
4
+ const { setupReceiver } = require('./receiver');
5
+ const { interruptTTS, playNextInQueue } = require('./tts');
6
+ const { loadRustpotterModule } = require('./wakeword');
7
+
8
+ function registerRoutes(app, client) {
9
+ app.get('/health', (req, res) => {
10
+ res.json({ ready: client.isReady() });
11
+ });
12
+
13
+ app.post('/join', async (req, res) => {
14
+ const { guild_id, channel_id } = req.body;
15
+ try {
16
+ const guild = client.guilds.cache.get(guild_id);
17
+ if (!guild) return res.status(404).json({ error: 'Guild not found' });
18
+
19
+ let connection = joinVoiceChannel({
20
+ channelId: channel_id,
21
+ guildId: guild_id,
22
+ adapterCreator: guild.voiceAdapterCreator,
23
+ selfDeaf: false,
24
+ selfMute: false,
25
+ });
26
+
27
+ state.connections.set(guild_id, connection);
28
+
29
+ setupReceiver(connection, guild_id, client);
30
+
31
+ connection.removeAllListeners(VoiceConnectionStatus.Ready);
32
+ connection.on(VoiceConnectionStatus.Ready, () => {
33
+ console.log(`[Voice] Connected to ${channel_id} in ${guild_id}`);
34
+ });
35
+
36
+ res.json({ success: true });
37
+ } catch (e) {
38
+ console.error(e);
39
+ res.status(500).json({ error: e.message });
40
+ }
41
+ });
42
+
43
+ app.post('/leave', (req, res) => {
44
+ const { guild_id } = req.body;
45
+ const connection = state.connections.get(guild_id);
46
+ if (!connection) {
47
+ return res.status(404).json({ error: 'Not connected' });
48
+ }
49
+
50
+ const player = state.players.get(guild_id);
51
+ if (player) {
52
+ try {
53
+ player.stop(true);
54
+ } catch (e) {
55
+ // already stopped/destroyed - fine
56
+ }
57
+ }
58
+ connection.destroy();
59
+
60
+ // Without this, a stale isPlaying/players entry silently breaks STT/wake detection on the next /join.
61
+ state.connections.delete(guild_id);
62
+ state.players.delete(guild_id);
63
+ state.audioQueues.delete(guild_id);
64
+ state.isPlaying.delete(guild_id);
65
+ state.activeUntil.delete(guild_id);
66
+ state.suppressNotifyMap.delete(guild_id);
67
+
68
+ res.json({ success: true });
69
+ });
70
+
71
+ app.post(
72
+ '/play',
73
+ express.raw({ type: 'application/octet-stream', limit: '20mb' }),
74
+ (req, res) => {
75
+ const guild_id = req.query.guild_id;
76
+ const connection = state.connections.get(guild_id);
77
+ if (!connection) return res.status(404).json({ error: 'Not connected' });
78
+
79
+ if (!state.audioQueues.has(guild_id)) {
80
+ state.audioQueues.set(guild_id, []);
81
+ }
82
+
83
+ state.audioQueues.get(guild_id).push({
84
+ buffer: req.body, // req.body is a Buffer here
85
+ suppressActiveWindow: req.query.suppress_active_window === 'true',
86
+ });
87
+
88
+ if (!state.isPlaying.get(guild_id)) {
89
+ playNextInQueue(guild_id);
90
+ }
91
+
92
+ res.json({ success: true, queued: true });
93
+ },
94
+ );
95
+
96
+ app.post('/interrupt', (req, res) => {
97
+ const { guild_id } = req.body;
98
+ // Lets Python trigger the same cutoff the VAD loud-voice check uses, regardless of volume.
99
+ interruptTTS(guild_id);
100
+ res.json({ success: true });
101
+ });
102
+
103
+ app.post('/invalidate_detector', (req, res) => {
104
+ // Without this, detectorCache keeps serving the OLD .rpw after a user re-enrolls.
105
+ const { user_id } = req.body;
106
+ const deleted = state.detectorCache.delete(user_id);
107
+ console.log(`[Wake] Invalidated cached detector for ${user_id} (was cached: ${deleted})`);
108
+ res.json({ success: true, was_cached: deleted });
109
+ });
110
+
111
+ app.post('/build_wakeword', async (req, res) => {
112
+ // Builds a .rpw in-process via WakewordRefCreator, instead of shelling out to rustpotter-cli.
113
+ try {
114
+ const { name, samples } = req.body;
115
+ if (!name || !Array.isArray(samples) || samples.length === 0) {
116
+ return res
117
+ .status(400)
118
+ .json({ error: 'name and at least one sample (wav bytes) are required' });
119
+ }
120
+
121
+ const mod = await loadRustpotterModule();
122
+ const creator = mod.WakewordRefCreator.new(name);
123
+ try {
124
+ for (const sample of samples) {
125
+ const buf = Buffer.from(sample.data_base64, 'base64');
126
+ creator.addFile(sample.filename || `${name}.wav`, buf);
127
+ }
128
+ const rpwBytes = creator.saveToBytes();
129
+ console.log(
130
+ `[Wake] Built .rpw for '${name}' from ${samples.length} sample(s) via WakewordRefCreator`,
131
+ );
132
+ res.json({ success: true, rpw_base64: Buffer.from(rpwBytes).toString('base64') });
133
+ } finally {
134
+ creator.free();
135
+ }
136
+ } catch (e) {
137
+ console.error(`[Wake] Failed to build wakeword reference:`, e);
138
+ res.status(500).json({ error: e.message || String(e) });
139
+ }
140
+ });
141
+
142
+ app.post('/set_config', (req, res) => {
143
+ const { voice_threshold } = req.body;
144
+ if (voice_threshold) {
145
+ state.runtime.vadThreshold = voice_threshold;
146
+ console.log(`[Config] Updated VAD threshold to ${state.runtime.vadThreshold}`);
147
+ }
148
+ res.json({ success: true });
149
+ });
150
+
151
+ app.post('/enroll_start', (req, res) => {
152
+ const { user_id } = req.body;
153
+ if (!user_id) return res.status(400).json({ error: 'user_id required' });
154
+ state.enrollingUsers.add(user_id);
155
+ res.json({ success: true });
156
+ });
157
+
158
+ app.post('/enroll_stop', (req, res) => {
159
+ const { user_id } = req.body;
160
+ if (!user_id) return res.status(400).json({ error: 'user_id required' });
161
+ state.enrollingUsers.delete(user_id);
162
+ res.json({ success: true });
163
+ });
164
+
165
+ app.post('/set_active', (req, res) => {
166
+ const { guild_id, active_until } = req.body;
167
+ if (!guild_id || !active_until)
168
+ return res.status(400).json({ error: 'guild_id and active_until required' });
169
+ state.activeUntil.set(guild_id, active_until);
170
+ res.json({ success: true });
171
+ });
172
+
173
+ app.post('/set_wake_word_required', (req, res) => {
174
+ const { user_id, required } = req.body;
175
+ if (!user_id) return res.status(400).json({ error: 'user_id required' });
176
+ if (required) state.wakeWordOptedOut.delete(user_id);
177
+ else state.wakeWordOptedOut.add(user_id);
178
+ res.json({ success: true });
179
+ });
180
+ }
181
+
182
+ module.exports = { registerRoutes };
@@ -0,0 +1,42 @@
1
+ const connections = new Map();
2
+ const players = new Map();
3
+ const audioQueues = new Map();
4
+ const isPlaying = new Map();
5
+ const activeStreams = new Map();
6
+
7
+ // user_id -> recording wake-word samples right now; routes to /enroll_sample instead of STT.
8
+ const enrollingUsers = new Set();
9
+
10
+ // guild_id -> ms epoch until the "awake, skip wake word" window closes (set via /set_active).
11
+ const activeUntil = new Map();
12
+
13
+ // user_id -> opted out of wake-word gating via /sound - scoped per-user, unlike activeUntil.
14
+ const wakeWordOptedOut = new Set();
15
+
16
+ // Whether finished audio should extend the "stay awake" window (false for enrollment playback).
17
+ const suppressNotifyMap = new Map();
18
+
19
+ // userId -> { rustpotter, samplesPerFrame, residual: Int16Array }
20
+ const detectorCache = new Map();
21
+
22
+ // Object property, not a plain `let` - a `let` wouldn't propagate its reassignment across modules.
23
+ const runtime = { vadThreshold: 3000 };
24
+
25
+ function isGuildActive(guildId) {
26
+ return Date.now() < (activeUntil.get(guildId) || 0);
27
+ }
28
+
29
+ module.exports = {
30
+ connections,
31
+ players,
32
+ audioQueues,
33
+ isPlaying,
34
+ activeStreams,
35
+ enrollingUsers,
36
+ activeUntil,
37
+ wakeWordOptedOut,
38
+ suppressNotifyMap,
39
+ detectorCache,
40
+ runtime,
41
+ isGuildActive,
42
+ };
@@ -0,0 +1,71 @@
1
+ const { spawn } = require('child_process');
2
+ const ffmpegPath = require('ffmpeg-static');
3
+
4
+ // Unofficial Google STT key - same default Python's SpeechRecognition (recognize_google) ships with.
5
+ const GOOGLE_STT_KEY = 'AIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgw';
6
+
7
+ function flacEncode(wavBuffer) {
8
+ return new Promise((resolve, reject) => {
9
+ const ff = spawn(ffmpegPath, [
10
+ '-hide_banner',
11
+ '-loglevel',
12
+ 'error',
13
+ '-i',
14
+ 'pipe:0',
15
+ '-f',
16
+ 'flac',
17
+ 'pipe:1',
18
+ ]);
19
+ const out = [];
20
+ ff.stdout.on('data', (d) => out.push(d));
21
+ ff.stderr.on('data', () => {}); // -loglevel error already keeps this quiet in the normal case
22
+ ff.on('error', (err) => reject(new Error(`ffmpeg-static failed to run (${err.message})`)));
23
+ ff.on('close', (code) => {
24
+ if (code !== 0) return reject(new Error(`ffmpeg exited with code ${code}`));
25
+ resolve(Buffer.concat(out));
26
+ });
27
+ ff.stdin.write(wavBuffer);
28
+ ff.stdin.end();
29
+ });
30
+ }
31
+
32
+ async function googleSTT(wavBuffer, lang = 'ko-KR') {
33
+ let flacBuffer;
34
+ try {
35
+ flacBuffer = await flacEncode(wavBuffer);
36
+ } catch (err) {
37
+ console.error('[STT] FLAC encode failed:', err.message);
38
+ return null;
39
+ }
40
+
41
+ let res;
42
+ try {
43
+ res = await fetch(
44
+ `https://www.google.com/speech-api/v2/recognize?output=json&client=chromium&lang=${encodeURIComponent(lang)}&key=${GOOGLE_STT_KEY}`,
45
+ {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'audio/x-flac; rate=48000' },
48
+ body: flacBuffer,
49
+ },
50
+ );
51
+ } catch (err) {
52
+ console.error('[STT] Request to Google STT failed:', err.message);
53
+ return null;
54
+ }
55
+
56
+ const raw = await res.text();
57
+ // Response is newline-delimited JSON, one object per line.
58
+ for (const line of raw.trim().split('\n')) {
59
+ if (!line) continue;
60
+ try {
61
+ const obj = JSON.parse(line);
62
+ const transcript = obj.result?.[0]?.alternative?.[0]?.transcript;
63
+ if (transcript) return transcript.trim();
64
+ } catch (e) {
65
+ // not JSON / partial line - ignore
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ module.exports = { googleSTT };
@@ -0,0 +1,81 @@
1
+ const { Readable } = require('stream');
2
+ const { createAudioPlayer, createAudioResource, AudioPlayerStatus } = require('@discordjs/voice');
3
+ const { players, audioQueues, isPlaying, connections, suppressNotifyMap } = require('./state');
4
+
5
+ function interruptTTS(guildId) {
6
+ const player = players.get(guildId);
7
+ let interrupted = false;
8
+
9
+ if (audioQueues.has(guildId)) {
10
+ // Queue holds in-memory Buffers (see /play), not filepaths - nothing on disk to clean up.
11
+ audioQueues.set(guildId, []);
12
+ }
13
+
14
+ if (player && player.state.status !== AudioPlayerStatus.Idle) {
15
+ player.stop();
16
+ console.log(`[VAD] Interrupted TTS in guild ${guildId}`);
17
+ interrupted = true;
18
+ }
19
+
20
+ isPlaying.set(guildId, false);
21
+ return interrupted;
22
+ }
23
+
24
+ async function notifyTtsFinished(guild_id) {
25
+ try {
26
+ await fetch('http://127.0.0.1:18080/tts_finished', {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json' },
29
+ body: JSON.stringify({ guild_id }),
30
+ });
31
+ } catch (err) {
32
+ console.error(`[TTS] Failed to notify Python of playback completion:`, err.message);
33
+ }
34
+ }
35
+
36
+ function playNextInQueue(guild_id) {
37
+ const queue = audioQueues.get(guild_id) || [];
38
+ if (queue.length === 0) {
39
+ isPlaying.set(guild_id, false);
40
+ if (!suppressNotifyMap.get(guild_id)) {
41
+ notifyTtsFinished(guild_id);
42
+ }
43
+ return;
44
+ }
45
+
46
+ const connection = connections.get(guild_id);
47
+ if (!connection) {
48
+ isPlaying.set(guild_id, false);
49
+ return;
50
+ }
51
+
52
+ isPlaying.set(guild_id, true);
53
+ const item = queue.shift(); // { buffer, suppressActiveWindow }
54
+ suppressNotifyMap.set(guild_id, item.suppressActiveWindow);
55
+
56
+ try {
57
+ let player = players.get(guild_id);
58
+ if (!player) {
59
+ player = createAudioPlayer();
60
+ players.set(guild_id, player);
61
+ connection.subscribe(player);
62
+
63
+ player.on(AudioPlayerStatus.Idle, () => {
64
+ playNextInQueue(guild_id);
65
+ });
66
+
67
+ player.on('error', (error) => {
68
+ console.error(`[TTS] AudioPlayer Error:`, error.message);
69
+ playNextInQueue(guild_id);
70
+ });
71
+ }
72
+
73
+ const resource = createAudioResource(Readable.from(item.buffer));
74
+ player.play(resource);
75
+ } catch (e) {
76
+ console.error(`[TTS] Error playing queued audio:`, e);
77
+ playNextInQueue(guild_id);
78
+ }
79
+ }
80
+
81
+ module.exports = { interruptTTS, notifyTtsFinished, playNextInQueue };