linkgravity 1.5.2 → 1.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +76 -9
- package/bin/setup.js +49 -25
- package/package.json +2 -2
- package/src/config.py +8 -4
- package/voice-service/audioUtils.js +31 -0
- package/voice-service/config.js +24 -0
- package/voice-service/logger.js +16 -0
- package/voice-service/receiver.js +310 -0
- package/voice-service/routes.js +182 -0
- package/voice-service/state.js +42 -0
- package/voice-service/stt.js +71 -0
- package/voice-service/tts.js +81 -0
- package/voice-service/wakeword.js +114 -0
package/bin/cli.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { spawn, spawnSync } = require('child_process');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
6
7
|
const {
|
|
7
8
|
PLATFORMS,
|
|
8
9
|
getSettings,
|
|
@@ -236,10 +237,84 @@ function renderTable(headers, rows) {
|
|
|
236
237
|
return lines.join('\n');
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
// Mirrors src/config.py's AGY_BIN resolution (AGY_BIN_PATH env var, else ~/.local/bin/agy), plus a PATH fallback for installs that don't use the default location.
|
|
241
|
+
function findAgyBin() {
|
|
242
|
+
const envPath = process.env.AGY_BIN_PATH;
|
|
243
|
+
if (envPath && fs.existsSync(envPath)) return envPath;
|
|
244
|
+
|
|
245
|
+
const defaultPath = path.join(os.homedir(), '.local', 'bin', 'agy');
|
|
246
|
+
if (fs.existsSync(defaultPath)) return defaultPath;
|
|
247
|
+
|
|
248
|
+
const which = spawnSync(isWin ? 'where' : 'which', ['agy'], { stdio: 'pipe' });
|
|
249
|
+
if (which.status === 0) {
|
|
250
|
+
const out = which.stdout.toString().trim().split('\n')[0].trim();
|
|
251
|
+
if (out) return out;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function getPm2Proc() {
|
|
257
|
+
const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
|
|
258
|
+
if (jlist.status !== 0) return null;
|
|
259
|
+
|
|
260
|
+
// pm2 sometimes prints a version-mismatch banner ("In-memory PM2 is
|
|
261
|
+
// out-of-date...") before the JSON when the CLI version differs from
|
|
262
|
+
// the already-running daemon's - skip past it instead of choking on it.
|
|
263
|
+
const out = jlist.stdout.toString();
|
|
264
|
+
const jsonStart = out.indexOf('[');
|
|
265
|
+
if (jsonStart === -1) {
|
|
266
|
+
console.error(
|
|
267
|
+
`${color.yellow}⚠${color.reset} Couldn't read pm2 status (unexpected output, no JSON found). Raw output:\n${out.trim()}`,
|
|
268
|
+
);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const procs = JSON.parse(out.slice(jsonStart));
|
|
274
|
+
return procs.find((p) => p.name === LGY_PM2_NAME) || null;
|
|
275
|
+
} catch (e) {
|
|
276
|
+
console.error(
|
|
277
|
+
`${color.yellow}⚠${color.reset} Couldn't parse pm2 status output: ${e.message}. ` +
|
|
278
|
+
`If you saw a version-mismatch warning above, try ${color.cyan}npx pm2 update${color.reset}.`,
|
|
279
|
+
);
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
239
284
|
if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
240
285
|
const pkg = require('../package.json');
|
|
241
286
|
console.log(`linkgravity v${pkg.version}`);
|
|
242
287
|
} else if (cmd === 'start') {
|
|
288
|
+
const existing = getPm2Proc();
|
|
289
|
+
if (existing && existing.pm2_env.status === 'online') {
|
|
290
|
+
console.log(
|
|
291
|
+
`\n${color.yellow}⚠${color.reset} LinkGravity is already running. ` +
|
|
292
|
+
`Use ${color.cyan}lgy restart${color.reset} to apply changes, or ${color.cyan}lgy stop${color.reset} first.\n`,
|
|
293
|
+
);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const settings = getSettings();
|
|
298
|
+
const anyConfigured = Object.keys(PLATFORMS).some(
|
|
299
|
+
(key) => platformState(key, settings).configured,
|
|
300
|
+
);
|
|
301
|
+
if (!anyConfigured) {
|
|
302
|
+
console.log(
|
|
303
|
+
`\n${color.yellow}⚠${color.reset} No messenger is configured yet - ` +
|
|
304
|
+
`set up at least one of Discord, Telegram, or Slack first: ${color.cyan}lgy setup${color.reset}\n`,
|
|
305
|
+
);
|
|
306
|
+
process.exit(1);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!findAgyBin()) {
|
|
310
|
+
console.log(
|
|
311
|
+
`\n${color.yellow}⚠${color.reset} Couldn't find the agy CLI ` +
|
|
312
|
+
`(checked $AGY_BIN_PATH, ~/.local/bin/agy, and PATH). Install/configure agy first, ` +
|
|
313
|
+
`or set the AGY_BIN_PATH environment variable to its location.\n`,
|
|
314
|
+
);
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
|
|
243
318
|
info('Starting LinkGravity daemon...');
|
|
244
319
|
runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
|
|
245
320
|
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
@@ -294,15 +369,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
294
369
|
const sessions = getSessions();
|
|
295
370
|
const health = getPlatformHealth();
|
|
296
371
|
|
|
297
|
-
|
|
298
|
-
const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
|
|
299
|
-
if (jlist.status === 0) {
|
|
300
|
-
try {
|
|
301
|
-
pm2Procs = JSON.parse(jlist.stdout.toString());
|
|
302
|
-
} catch (e) {}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const proc = pm2Procs.find((p) => p.name === LGY_PM2_NAME);
|
|
372
|
+
const proc = getPm2Proc();
|
|
306
373
|
if (!proc) {
|
|
307
374
|
console.log(`\n${color.cyan}▶${color.reset} daemon: not running\n`);
|
|
308
375
|
} else {
|
package/bin/setup.js
CHANGED
|
@@ -33,25 +33,39 @@ async function collectSessionScopes(existingScopes) {
|
|
|
33
33
|
'Server / Channel Access',
|
|
34
34
|
);
|
|
35
35
|
|
|
36
|
+
if (hasExisting) {
|
|
37
|
+
const summary = existingScopes
|
|
38
|
+
.map(
|
|
39
|
+
(s) =>
|
|
40
|
+
`${s.guild_id}${s.channel_ids.length ? ` (channels: ${s.channel_ids.join(', ')})` : ' (whole server)'}`,
|
|
41
|
+
)
|
|
42
|
+
.join('; ');
|
|
43
|
+
p.note(`Current server/channel settings: ${summary}`, 'Current Setting');
|
|
44
|
+
|
|
45
|
+
const change = await p.confirm({
|
|
46
|
+
message: 'Change the server/channel access list?',
|
|
47
|
+
initialValue: false,
|
|
48
|
+
});
|
|
49
|
+
if (p.isCancel(change)) {
|
|
50
|
+
p.cancel('Setup cancelled.');
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
if (!change) return null; // keep existing config untouched
|
|
54
|
+
}
|
|
55
|
+
|
|
36
56
|
let isFirst = true;
|
|
37
57
|
while (true) {
|
|
38
|
-
const promptSuffix =
|
|
39
|
-
isFirst && hasExisting
|
|
40
|
-
? ' (leave empty to keep your current server/channel settings entirely unchanged)'
|
|
41
|
-
: ' (leave empty if you have no more servers to add)';
|
|
42
|
-
|
|
43
58
|
const guildId = await p.text({
|
|
44
|
-
message:
|
|
59
|
+
message: isFirst
|
|
60
|
+
? 'Server (Guild) ID to allow. Right-click the SERVER NAME (not a channel) → Copy Server ID ' +
|
|
61
|
+
'(leave empty if done - an empty list means /new works nowhere):'
|
|
62
|
+
: 'Another server ID to add (leave empty if done):',
|
|
45
63
|
});
|
|
46
64
|
if (p.isCancel(guildId)) {
|
|
47
65
|
p.cancel('Setup cancelled.');
|
|
48
66
|
process.exit(0);
|
|
49
67
|
}
|
|
50
|
-
|
|
51
|
-
if (!guildId) {
|
|
52
|
-
if (isFirst) return null; // signal: user wants to keep existing config untouched
|
|
53
|
-
break;
|
|
54
|
-
}
|
|
68
|
+
if (!guildId) break;
|
|
55
69
|
isFirst = false;
|
|
56
70
|
|
|
57
71
|
const channelIds = [];
|
|
@@ -103,30 +117,41 @@ async function collectUserIds(existingIds, platformLabel) {
|
|
|
103
117
|
const hasExisting = existingIds && existingIds.length > 0;
|
|
104
118
|
|
|
105
119
|
p.note(
|
|
106
|
-
'ONLY these users can use the bot (leave completely empty
|
|
120
|
+
'ONLY these users can use the bot (leave completely empty to allow EVERYONE). ' +
|
|
107
121
|
'Not related to DMs - this only gates the channel/threads configured above.',
|
|
108
122
|
`Allowed ${platformLabel} Users`,
|
|
109
123
|
);
|
|
110
124
|
|
|
125
|
+
if (hasExisting) {
|
|
126
|
+
p.note(
|
|
127
|
+
`Current allowed ${platformLabel} users: ${existingIds.join(', ')}`,
|
|
128
|
+
'Current Setting',
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const change = await p.confirm({
|
|
132
|
+
message: `Change the allowed-user list for ${platformLabel}?`,
|
|
133
|
+
initialValue: false,
|
|
134
|
+
});
|
|
135
|
+
if (p.isCancel(change)) {
|
|
136
|
+
p.cancel('Setup cancelled.');
|
|
137
|
+
process.exit(0);
|
|
138
|
+
}
|
|
139
|
+
if (!change) return null; // keep existing config untouched
|
|
140
|
+
}
|
|
141
|
+
|
|
111
142
|
let isFirst = true;
|
|
112
143
|
while (true) {
|
|
113
|
-
const promptSuffix =
|
|
114
|
-
isFirst && hasExisting
|
|
115
|
-
? ' (leave empty to keep your current allowed-user settings entirely unchanged)'
|
|
116
|
-
: ' (leave empty if you have no more users to add)';
|
|
117
|
-
|
|
118
144
|
const userId = await p.text({
|
|
119
|
-
message:
|
|
145
|
+
message: isFirst
|
|
146
|
+
? `${platformLabel} User ID to allow (leave empty to allow EVERYONE):`
|
|
147
|
+
: `Another ${platformLabel} user ID to allow (leave empty if done):`,
|
|
120
148
|
});
|
|
121
149
|
if (p.isCancel(userId)) {
|
|
122
150
|
p.cancel('Setup cancelled.');
|
|
123
151
|
process.exit(0);
|
|
124
152
|
}
|
|
125
153
|
|
|
126
|
-
if (!userId)
|
|
127
|
-
if (isFirst) return null; // signal: keep existing config untouched
|
|
128
|
-
break;
|
|
129
|
-
}
|
|
154
|
+
if (!userId) break;
|
|
130
155
|
isFirst = false;
|
|
131
156
|
|
|
132
157
|
ids.push(userId.trim());
|
|
@@ -390,13 +415,12 @@ async function platformMenu(key) {
|
|
|
390
415
|
);
|
|
391
416
|
if (configured)
|
|
392
417
|
options.push({ value: 'edit', label: 'Edit settings (token, access, etc.)' });
|
|
393
|
-
options.push({ value: 'back', label: '← Back' });
|
|
394
418
|
|
|
395
419
|
const action = await p.select({
|
|
396
|
-
message: `${def.label} — currently ${enabled ? 'ON' : 'OFF'}${configured ? '' : ' (not configured)'}`,
|
|
420
|
+
message: `${def.label} — currently ${enabled ? 'ON' : 'OFF'}${configured ? '' : ' (not configured)'} (Esc to go back)`,
|
|
397
421
|
options,
|
|
398
422
|
});
|
|
399
|
-
if (p.isCancel(action)
|
|
423
|
+
if (p.isCancel(action)) return;
|
|
400
424
|
|
|
401
425
|
if (action === 'off') {
|
|
402
426
|
updateSettings({ [`${key}_enabled`]: false });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linkgravity",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.4",
|
|
4
4
|
"description": "Discord/Telegram bot bridge for the Antigravity (agy) CLI, with voice interaction support",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"postinstall": "node npm-scripts/postinstall.js",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"npm-scripts",
|
|
41
41
|
"hooks",
|
|
42
42
|
"src",
|
|
43
|
-
"voice-service
|
|
43
|
+
"voice-service/*.js",
|
|
44
44
|
"voice-service/package.json",
|
|
45
45
|
"voice-service/package-lock.json",
|
|
46
46
|
"requirements.txt",
|
package/src/config.py
CHANGED
|
@@ -112,11 +112,15 @@ TTS_VOICE = bot_settings.get("tts_voice", "ko-KR-SunHiNeural")
|
|
|
112
112
|
|
|
113
113
|
def is_allowed_session_channel(channel) -> bool:
|
|
114
114
|
"""True if a new agy session may be started from this channel (via
|
|
115
|
-
/new).
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
/new). DMs have no guild to scope, so they're always allowed here -
|
|
116
|
+
gating for DMs is handled separately via ALLOWED_IDS. For a guild
|
|
117
|
+
channel, it's allowed if its server is in SESSION_SCOPES AND either
|
|
118
|
+
that server has no channel restriction (whole-server access) or this
|
|
119
|
+
specific channel is in its allowed list."""
|
|
118
120
|
guild = getattr(channel, "guild", None)
|
|
119
|
-
if not guild
|
|
121
|
+
if not guild:
|
|
122
|
+
return True
|
|
123
|
+
if guild.id not in SESSION_SCOPES:
|
|
120
124
|
return False
|
|
121
125
|
allowed_channels = SESSION_SCOPES[guild.id]
|
|
122
126
|
return allowed_channels is None or channel.id in allowed_channels
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
function stereoToMono(buffer) {
|
|
2
|
+
// Discord voice receive is 48kHz stereo; everything downstream (WAV, Rustpotter) expects mono.
|
|
3
|
+
const samples = buffer.length >> 2; // 2 bytes/sample * 2 channels
|
|
4
|
+
const mono = Buffer.alloc(samples * 2);
|
|
5
|
+
for (let i = 0; i < samples; i++) {
|
|
6
|
+
const l = buffer.readInt16LE(i * 4);
|
|
7
|
+
const r = buffer.readInt16LE(i * 4 + 2);
|
|
8
|
+
mono.writeInt16LE((l + r) >> 1, i * 2);
|
|
9
|
+
}
|
|
10
|
+
return mono;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function createWavHeader(dataLength, sampleRate = 48000, channels = 1, bitDepth = 16) {
|
|
14
|
+
const buffer = Buffer.alloc(44);
|
|
15
|
+
buffer.write('RIFF', 0);
|
|
16
|
+
buffer.writeUInt32LE(36 + dataLength, 4);
|
|
17
|
+
buffer.write('WAVE', 8);
|
|
18
|
+
buffer.write('fmt ', 12);
|
|
19
|
+
buffer.writeUInt32LE(16, 16);
|
|
20
|
+
buffer.writeUInt16LE(1, 20);
|
|
21
|
+
buffer.writeUInt16LE(channels, 22);
|
|
22
|
+
buffer.writeUInt32LE(sampleRate, 24);
|
|
23
|
+
buffer.writeUInt32LE(sampleRate * channels * (bitDepth / 8), 28);
|
|
24
|
+
buffer.writeUInt16LE(channels * (bitDepth / 8), 32);
|
|
25
|
+
buffer.writeUInt16LE(bitDepth, 34);
|
|
26
|
+
buffer.write('data', 36);
|
|
27
|
+
buffer.writeUInt32LE(dataLength, 40);
|
|
28
|
+
return buffer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { stereoToMono, createWavHeader };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const aglJsonPath = path.join(os.homedir(), '.gemini', 'linkgravity', 'lgy.json');
|
|
6
|
+
let aglConfig = {};
|
|
7
|
+
try {
|
|
8
|
+
if (fs.existsSync(aglJsonPath)) {
|
|
9
|
+
aglConfig = JSON.parse(fs.readFileSync(aglJsonPath, 'utf8'));
|
|
10
|
+
}
|
|
11
|
+
} catch (e) {
|
|
12
|
+
console.error('Failed to load lgy.json:', e.message);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
process.env.DISCORD_TOKEN =
|
|
16
|
+
aglConfig.discord_token || aglConfig.DISCORD_TOKEN || process.env.DISCORD_TOKEN;
|
|
17
|
+
|
|
18
|
+
process.env.http_proxy = '';
|
|
19
|
+
process.env.https_proxy = '';
|
|
20
|
+
process.env.HTTP_PROXY = '';
|
|
21
|
+
process.env.HTTPS_PROXY = '';
|
|
22
|
+
process.env.PYTHON_HOST = '';
|
|
23
|
+
|
|
24
|
+
module.exports = { aglConfig };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const originalLog = console.log;
|
|
2
|
+
const originalError = console.error;
|
|
3
|
+
|
|
4
|
+
function getTimestamp() {
|
|
5
|
+
const now = new Date();
|
|
6
|
+
const offset = now.getTimezoneOffset() * 60000;
|
|
7
|
+
const localTime = new Date(now.getTime() - offset);
|
|
8
|
+
return localTime.toISOString().replace('T', ' ').substring(0, 19);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
console.log = function (...args) {
|
|
12
|
+
originalLog(`${getTimestamp()} INFO Voice:`, ...args);
|
|
13
|
+
};
|
|
14
|
+
console.error = function (...args) {
|
|
15
|
+
originalError(`${getTimestamp()} ERROR Voice:`, ...args);
|
|
16
|
+
};
|
|
@@ -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 };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { detectorCache } = require('./state');
|
|
5
|
+
|
|
6
|
+
// Rustpotter wake-word detection runs entirely in-process here, no Python round trip.
|
|
7
|
+
const WAKE_REF_DIR = path.join(os.homedir(), '.gemini', 'linkgravity', 'wake_refs');
|
|
8
|
+
|
|
9
|
+
let rustpotterModPromise = null;
|
|
10
|
+
function loadRustpotterModule() {
|
|
11
|
+
if (!rustpotterModPromise) {
|
|
12
|
+
rustpotterModPromise = (async () => {
|
|
13
|
+
// "rustpotter-web" (not "-slim") - also exposes WakewordRefCreator for /build_wakeword.
|
|
14
|
+
const mod = await import('rustpotter-web/rustpotter_wasm.js');
|
|
15
|
+
const wasmPath = require.resolve('rustpotter-web/rustpotter_wasm_bg.wasm');
|
|
16
|
+
mod.initSync(fs.readFileSync(wasmPath));
|
|
17
|
+
return mod;
|
|
18
|
+
})();
|
|
19
|
+
}
|
|
20
|
+
return rustpotterModPromise;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 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.
|
|
24
|
+
const WAKE_MATCH_THRESHOLD = 0.4;
|
|
25
|
+
|
|
26
|
+
async function getDetectorForUser(userId) {
|
|
27
|
+
if (detectorCache.has(userId)) return detectorCache.get(userId);
|
|
28
|
+
|
|
29
|
+
const userDir = path.join(WAKE_REF_DIR, userId);
|
|
30
|
+
if (!fs.existsSync(userDir)) return null; // not enrolled
|
|
31
|
+
|
|
32
|
+
const rpwFile = fs.readdirSync(userDir).find((f) => f.endsWith('.rpw'));
|
|
33
|
+
if (!rpwFile) return null; // samples exist but .rpw build hasn't happened/failed - see _commit_enrollment
|
|
34
|
+
|
|
35
|
+
const mod = await loadRustpotterModule();
|
|
36
|
+
const config = mod.RustpotterConfig.new();
|
|
37
|
+
config.setSampleRate(48000);
|
|
38
|
+
config.setSampleFormat(mod.SampleFormat.i16);
|
|
39
|
+
config.setChannels(1);
|
|
40
|
+
config.setThreshold(WAKE_MATCH_THRESHOLD);
|
|
41
|
+
config.setAveragedThreshold(0);
|
|
42
|
+
// Live logs showed genuine attempts peaking above threshold but not sustaining 4 positive-scoring
|
|
43
|
+
// frames; lowered from 4. STT-side prefix-similarity check is the backstop against false wakes.
|
|
44
|
+
config.setMinScores(2);
|
|
45
|
+
// Max (best of the 5 enrolled samples) beats Median here - real speech isn't consistent enough
|
|
46
|
+
// for Median's "middle sample must also score well" requirement; minScores compensates.
|
|
47
|
+
config.setScoreMode(mod.ScoreMode.max);
|
|
48
|
+
// Enrollment and live-call volume rarely match (distance, speaking softly); without this, that
|
|
49
|
+
// mismatch alone can push a genuine match below threshold.
|
|
50
|
+
config.setGainNormalizerEnabled(true);
|
|
51
|
+
|
|
52
|
+
const rustpotter = mod.Rustpotter.new(config);
|
|
53
|
+
rustpotter.addWakeword(rpwFile, fs.readFileSync(path.join(userDir, rpwFile)));
|
|
54
|
+
|
|
55
|
+
// Diagnostic-only twin (same audio) so "no match" logs show a closeness score - never gates wake behavior.
|
|
56
|
+
const diagConfig = mod.RustpotterConfig.new();
|
|
57
|
+
diagConfig.setSampleRate(48000);
|
|
58
|
+
diagConfig.setSampleFormat(mod.SampleFormat.i16);
|
|
59
|
+
diagConfig.setChannels(1);
|
|
60
|
+
diagConfig.setThreshold(0.01);
|
|
61
|
+
diagConfig.setAveragedThreshold(0);
|
|
62
|
+
diagConfig.setMinScores(1);
|
|
63
|
+
diagConfig.setEager(true);
|
|
64
|
+
diagConfig.setScoreMode(mod.ScoreMode.max);
|
|
65
|
+
diagConfig.setGainNormalizerEnabled(true);
|
|
66
|
+
const diagRustpotter = mod.Rustpotter.new(diagConfig);
|
|
67
|
+
diagRustpotter.addWakeword(rpwFile, fs.readFileSync(path.join(userDir, rpwFile)));
|
|
68
|
+
|
|
69
|
+
const entry = {
|
|
70
|
+
rustpotter,
|
|
71
|
+
samplesPerFrame: rustpotter.getSamplesPerFrame(),
|
|
72
|
+
residual: new Int16Array(0),
|
|
73
|
+
diag: {
|
|
74
|
+
rustpotter: diagRustpotter,
|
|
75
|
+
samplesPerFrame: diagRustpotter.getSamplesPerFrame(),
|
|
76
|
+
residual: new Int16Array(0),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
console.log(
|
|
80
|
+
`[Wake] Loaded detector for ${userId} from ${rpwFile}: samplesPerFrame=${entry.samplesPerFrame}`,
|
|
81
|
+
);
|
|
82
|
+
detectorCache.set(userId, entry);
|
|
83
|
+
return entry;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Frame-aligns via residual carryover (Rustpotter needs a continuous stream); returns a detection if any.
|
|
87
|
+
function feedPCMToDetector(entry, chunk) {
|
|
88
|
+
const incoming = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.length / 2);
|
|
89
|
+
let combined = incoming;
|
|
90
|
+
if (entry.residual.length) {
|
|
91
|
+
combined = new Int16Array(entry.residual.length + incoming.length);
|
|
92
|
+
combined.set(entry.residual, 0);
|
|
93
|
+
combined.set(incoming, entry.residual.length);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let offset = 0;
|
|
97
|
+
let detection = null;
|
|
98
|
+
while (combined.length - offset >= entry.samplesPerFrame) {
|
|
99
|
+
const frame = combined.subarray(offset, offset + entry.samplesPerFrame);
|
|
100
|
+
const result = entry.rustpotter.processI16(frame);
|
|
101
|
+
if (result) detection = result;
|
|
102
|
+
offset += entry.samplesPerFrame;
|
|
103
|
+
}
|
|
104
|
+
entry.residual = combined.subarray(offset);
|
|
105
|
+
return detection;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
WAKE_REF_DIR,
|
|
110
|
+
WAKE_MATCH_THRESHOLD,
|
|
111
|
+
loadRustpotterModule,
|
|
112
|
+
getDetectorForUser,
|
|
113
|
+
feedPCMToDetector,
|
|
114
|
+
};
|