kimaki 0.0.3 → 0.1.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.
- package/README.md +7 -0
- package/bin.sh +28 -0
- package/dist/ai-tool-to-genai.js +207 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/cli.js +357 -0
- package/dist/directVoiceStreaming.js +102 -0
- package/dist/discordBot.js +1740 -0
- package/dist/genai-worker-wrapper.js +104 -0
- package/dist/genai-worker.js +293 -0
- package/dist/genai.js +224 -0
- package/dist/logger.js +10 -0
- package/dist/markdown.js +199 -0
- package/dist/markdown.test.js +232 -0
- package/dist/openai-realtime.js +228 -0
- package/dist/plugin.js +1414 -0
- package/dist/tools.js +352 -0
- package/dist/utils.js +52 -0
- package/dist/voice.js +28 -0
- package/dist/worker-types.js +1 -0
- package/dist/xml.js +85 -0
- package/package.json +37 -56
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +251 -0
- package/src/cli.ts +551 -0
- package/src/discordBot.ts +2350 -0
- package/src/genai-worker-wrapper.ts +152 -0
- package/src/genai-worker.ts +361 -0
- package/src/genai.ts +308 -0
- package/src/logger.ts +16 -0
- package/src/markdown.test.ts +314 -0
- package/src/markdown.ts +225 -0
- package/src/openai-realtime.ts +363 -0
- package/src/tools.ts +421 -0
- package/src/utils.ts +73 -0
- package/src/voice.ts +42 -0
- package/src/worker-types.ts +60 -0
- package/src/xml.ts +112 -0
- package/bin.js +0 -3
- package/dist/bin.d.ts +0 -3
- package/dist/bin.d.ts.map +0 -1
- package/dist/bin.js +0 -4
- package/dist/bin.js.map +0 -1
- package/dist/bundle.js +0 -3124
- package/dist/cli.d.ts.map +0 -1
package/dist/cli.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cac } from 'cac';
|
|
3
|
+
import { intro, outro, text, password, note, cancel, isCancel, log, multiselect, spinner, } from '@clack/prompts';
|
|
4
|
+
import { generateBotInstallUrl } from './utils.js';
|
|
5
|
+
import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, } from './discordBot.js';
|
|
6
|
+
import { Events, ChannelType, REST, Routes, SlashCommandBuilder, } from 'discord.js';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import { createLogger } from './logger.js';
|
|
10
|
+
const cliLogger = createLogger('CLI');
|
|
11
|
+
const cli = cac('kimaki');
|
|
12
|
+
process.title = 'kimaki';
|
|
13
|
+
const EXIT_NO_RESTART = 64;
|
|
14
|
+
async function registerCommands(token, appId) {
|
|
15
|
+
const commands = [
|
|
16
|
+
new SlashCommandBuilder()
|
|
17
|
+
.setName('resume')
|
|
18
|
+
.setDescription('Resume an existing OpenCode session')
|
|
19
|
+
.addStringOption((option) => {
|
|
20
|
+
option
|
|
21
|
+
.setName('session')
|
|
22
|
+
.setDescription('The session to resume')
|
|
23
|
+
.setRequired(true)
|
|
24
|
+
.setAutocomplete(true);
|
|
25
|
+
return option;
|
|
26
|
+
})
|
|
27
|
+
.toJSON(),
|
|
28
|
+
new SlashCommandBuilder()
|
|
29
|
+
.setName('session')
|
|
30
|
+
.setDescription('Start a new OpenCode session')
|
|
31
|
+
.addStringOption((option) => {
|
|
32
|
+
option
|
|
33
|
+
.setName('prompt')
|
|
34
|
+
.setDescription('Prompt content for the session')
|
|
35
|
+
.setRequired(true);
|
|
36
|
+
return option;
|
|
37
|
+
})
|
|
38
|
+
.addStringOption((option) => {
|
|
39
|
+
option
|
|
40
|
+
.setName('files')
|
|
41
|
+
.setDescription('Files to mention (comma or space separated; autocomplete)')
|
|
42
|
+
.setAutocomplete(true)
|
|
43
|
+
.setMaxLength(6000);
|
|
44
|
+
return option;
|
|
45
|
+
})
|
|
46
|
+
.toJSON(),
|
|
47
|
+
];
|
|
48
|
+
const rest = new REST().setToken(token);
|
|
49
|
+
try {
|
|
50
|
+
const data = (await rest.put(Routes.applicationCommands(appId), {
|
|
51
|
+
body: commands,
|
|
52
|
+
}));
|
|
53
|
+
cliLogger.info(`COMMANDS: Successfully registered ${data.length} slash commands`);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
cliLogger.error('COMMANDS: Failed to register slash commands: ' + String(error));
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function ensureKimakiCategory(guild) {
|
|
61
|
+
const existingCategory = guild.channels.cache.find((channel) => {
|
|
62
|
+
if (channel.type !== ChannelType.GuildCategory) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return channel.name.toLowerCase() === 'kimaki';
|
|
66
|
+
});
|
|
67
|
+
if (existingCategory) {
|
|
68
|
+
return existingCategory;
|
|
69
|
+
}
|
|
70
|
+
return guild.channels.create({
|
|
71
|
+
name: 'Kimaki',
|
|
72
|
+
type: ChannelType.GuildCategory,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async function run({ restart, addChannels }) {
|
|
76
|
+
const forceSetup = Boolean(restart);
|
|
77
|
+
const shouldAddChannels = Boolean(addChannels);
|
|
78
|
+
intro('🤖 Discord Bot Setup');
|
|
79
|
+
const db = getDatabase();
|
|
80
|
+
let appId;
|
|
81
|
+
let token;
|
|
82
|
+
const existingBot = db
|
|
83
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
84
|
+
.get();
|
|
85
|
+
if (existingBot && !forceSetup) {
|
|
86
|
+
appId = existingBot.app_id;
|
|
87
|
+
token = existingBot.token;
|
|
88
|
+
note(`Using saved bot credentials:\nApp ID: ${appId}\n\nTo use different credentials, run with --restart`, 'Existing Bot Found');
|
|
89
|
+
note(`Bot install URL (in case you need to add it to another server):\n${generateBotInstallUrl({ clientId: appId })}`, 'Install URL');
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
if (forceSetup && existingBot) {
|
|
93
|
+
note('Ignoring saved credentials due to --restart flag', 'Restart Setup');
|
|
94
|
+
}
|
|
95
|
+
note('1. Go to https://discord.com/developers/applications\n' +
|
|
96
|
+
'2. Click "New Application"\n' +
|
|
97
|
+
'3. Give your application a name\n' +
|
|
98
|
+
'4. Copy the Application ID from the "General Information" section', 'Step 1: Create Discord Application');
|
|
99
|
+
const appIdInput = await text({
|
|
100
|
+
message: 'Enter your Discord Application ID:',
|
|
101
|
+
placeholder: 'e.g., 1234567890123456789',
|
|
102
|
+
validate(value) {
|
|
103
|
+
if (!value)
|
|
104
|
+
return 'Application ID is required';
|
|
105
|
+
if (!/^\d{17,20}$/.test(value))
|
|
106
|
+
return 'Invalid Application ID format (should be 17-20 digits)';
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
if (isCancel(appIdInput)) {
|
|
110
|
+
cancel('Setup cancelled');
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
appId = appIdInput;
|
|
114
|
+
note('1. Go to the "Bot" section in the left sidebar\n' +
|
|
115
|
+
'2. Click "Reset Token" to generate a new bot token\n' +
|
|
116
|
+
"3. Copy the token (you won't be able to see it again!)", 'Step 2: Get Bot Token');
|
|
117
|
+
const tokenInput = await password({
|
|
118
|
+
message: 'Enter your Discord Bot Token (will be hidden):',
|
|
119
|
+
validate(value) {
|
|
120
|
+
if (!value)
|
|
121
|
+
return 'Bot token is required';
|
|
122
|
+
if (value.length < 50)
|
|
123
|
+
return 'Invalid token format (too short)';
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
if (isCancel(tokenInput)) {
|
|
127
|
+
cancel('Setup cancelled');
|
|
128
|
+
process.exit(0);
|
|
129
|
+
}
|
|
130
|
+
token = tokenInput;
|
|
131
|
+
db.prepare('INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)').run(appId, token);
|
|
132
|
+
note('Token saved to database', 'Credentials Stored');
|
|
133
|
+
note(`Bot install URL:\n${generateBotInstallUrl({ clientId: appId })}\n\nYou MUST install the bot in your Discord server before continuing.`, 'Step 3: Install Bot to Server');
|
|
134
|
+
const installed = await text({
|
|
135
|
+
message: 'Press Enter AFTER you have installed the bot in your server:',
|
|
136
|
+
placeholder: 'Press Enter to continue',
|
|
137
|
+
validate() {
|
|
138
|
+
return undefined;
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
if (isCancel(installed)) {
|
|
142
|
+
cancel('Setup cancelled');
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const s = spinner();
|
|
147
|
+
s.start('Creating Discord client and connecting...');
|
|
148
|
+
const discordClient = await createDiscordClient();
|
|
149
|
+
const guilds = [];
|
|
150
|
+
const kimakiChannels = [];
|
|
151
|
+
const createdChannels = [];
|
|
152
|
+
try {
|
|
153
|
+
await new Promise((resolve, reject) => {
|
|
154
|
+
discordClient.once(Events.ClientReady, async (c) => {
|
|
155
|
+
guilds.push(...Array.from(c.guilds.cache.values()));
|
|
156
|
+
for (const guild of guilds) {
|
|
157
|
+
const channels = await getChannelsWithDescriptions(guild);
|
|
158
|
+
const kimakiChans = channels.filter((ch) => ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId));
|
|
159
|
+
if (kimakiChans.length > 0) {
|
|
160
|
+
kimakiChannels.push({ guild, channels: kimakiChans });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
resolve(null);
|
|
164
|
+
});
|
|
165
|
+
discordClient.once(Events.Error, reject);
|
|
166
|
+
discordClient.login(token).catch(reject);
|
|
167
|
+
});
|
|
168
|
+
s.stop('Connected to Discord!');
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
s.stop('Failed to connect to Discord');
|
|
172
|
+
cliLogger.error('Error: ' + (error instanceof Error ? error.message : String(error)));
|
|
173
|
+
process.exit(EXIT_NO_RESTART);
|
|
174
|
+
}
|
|
175
|
+
for (const { guild, channels } of kimakiChannels) {
|
|
176
|
+
for (const channel of channels) {
|
|
177
|
+
if (channel.kimakiDirectory) {
|
|
178
|
+
db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)').run(channel.id, channel.kimakiDirectory, 'text');
|
|
179
|
+
const voiceChannel = guild.channels.cache.find((ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name);
|
|
180
|
+
if (voiceChannel) {
|
|
181
|
+
db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)').run(voiceChannel.id, channel.kimakiDirectory, 'voice');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (kimakiChannels.length > 0) {
|
|
187
|
+
const channelList = kimakiChannels
|
|
188
|
+
.flatMap(({ guild, channels }) => channels.map((ch) => {
|
|
189
|
+
const appInfo = ch.kimakiApp === appId
|
|
190
|
+
? ' (this bot)'
|
|
191
|
+
: ch.kimakiApp
|
|
192
|
+
? ` (app: ${ch.kimakiApp})`
|
|
193
|
+
: '';
|
|
194
|
+
return `#${ch.name} in ${guild.name}: ${ch.kimakiDirectory}${appInfo}`;
|
|
195
|
+
}))
|
|
196
|
+
.join('\n');
|
|
197
|
+
note(channelList, 'Existing Kimaki Channels');
|
|
198
|
+
}
|
|
199
|
+
s.start('Starting OpenCode server...');
|
|
200
|
+
let client;
|
|
201
|
+
try {
|
|
202
|
+
const currentDir = process.cwd();
|
|
203
|
+
client = await initializeOpencodeForDirectory(currentDir);
|
|
204
|
+
s.stop('OpenCode server started!');
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
s.stop('Failed to start OpenCode');
|
|
208
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
209
|
+
discordClient.destroy();
|
|
210
|
+
process.exit(EXIT_NO_RESTART);
|
|
211
|
+
}
|
|
212
|
+
s.start('Fetching OpenCode projects...');
|
|
213
|
+
let projects = [];
|
|
214
|
+
try {
|
|
215
|
+
const projectsResponse = await client.project.list();
|
|
216
|
+
if (!projectsResponse.data) {
|
|
217
|
+
throw new Error('Failed to fetch projects');
|
|
218
|
+
}
|
|
219
|
+
projects = projectsResponse.data;
|
|
220
|
+
s.stop(`Found ${projects.length} OpenCode project(s)`);
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
s.stop('Failed to fetch projects');
|
|
224
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
225
|
+
discordClient.destroy();
|
|
226
|
+
process.exit(EXIT_NO_RESTART);
|
|
227
|
+
}
|
|
228
|
+
const existingDirs = kimakiChannels.flatMap(({ channels }) => channels.map((ch) => ch.kimakiDirectory).filter(Boolean));
|
|
229
|
+
const availableProjects = projects.filter((project) => !existingDirs.includes(project.worktree));
|
|
230
|
+
if (availableProjects.length === 0) {
|
|
231
|
+
note('All OpenCode projects already have Discord channels', 'No New Projects');
|
|
232
|
+
}
|
|
233
|
+
if (shouldAddChannels && availableProjects.length > 0) {
|
|
234
|
+
const selectedProjects = await multiselect({
|
|
235
|
+
message: 'Select projects to create Discord channels for:',
|
|
236
|
+
options: availableProjects.map((project) => ({
|
|
237
|
+
value: project.id,
|
|
238
|
+
label: `${path.basename(project.worktree)} (${project.worktree})`,
|
|
239
|
+
})),
|
|
240
|
+
required: false,
|
|
241
|
+
});
|
|
242
|
+
if (!isCancel(selectedProjects) && selectedProjects.length > 0) {
|
|
243
|
+
let targetGuild;
|
|
244
|
+
if (guilds.length === 0) {
|
|
245
|
+
cliLogger.error('No Discord servers found! The bot must be installed in at least one server.');
|
|
246
|
+
process.exit(EXIT_NO_RESTART);
|
|
247
|
+
}
|
|
248
|
+
if (guilds.length === 1) {
|
|
249
|
+
targetGuild = guilds[0];
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
const guildId = await text({
|
|
253
|
+
message: 'Enter the Discord server ID to create channels in:',
|
|
254
|
+
placeholder: guilds[0]?.id,
|
|
255
|
+
validate(value) {
|
|
256
|
+
if (!value)
|
|
257
|
+
return 'Server ID is required';
|
|
258
|
+
if (!guilds.find((g) => g.id === value))
|
|
259
|
+
return 'Invalid server ID';
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
if (isCancel(guildId)) {
|
|
263
|
+
cancel('Setup cancelled');
|
|
264
|
+
process.exit(0);
|
|
265
|
+
}
|
|
266
|
+
targetGuild = guilds.find((g) => g.id === guildId);
|
|
267
|
+
}
|
|
268
|
+
s.start('Creating Discord channels...');
|
|
269
|
+
for (const projectId of selectedProjects) {
|
|
270
|
+
const project = projects.find((p) => p.id === projectId);
|
|
271
|
+
if (!project)
|
|
272
|
+
continue;
|
|
273
|
+
const baseName = path.basename(project.worktree);
|
|
274
|
+
const channelName = `kimaki-${baseName}`
|
|
275
|
+
.toLowerCase()
|
|
276
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
277
|
+
.slice(0, 100);
|
|
278
|
+
try {
|
|
279
|
+
const kimakiCategory = await ensureKimakiCategory(targetGuild);
|
|
280
|
+
const textChannel = await targetGuild.channels.create({
|
|
281
|
+
name: channelName,
|
|
282
|
+
type: ChannelType.GuildText,
|
|
283
|
+
parent: kimakiCategory,
|
|
284
|
+
topic: `<kimaki><directory>${project.worktree}</directory><app>${appId}</app></kimaki>`,
|
|
285
|
+
});
|
|
286
|
+
const voiceChannel = await targetGuild.channels.create({
|
|
287
|
+
name: channelName,
|
|
288
|
+
type: ChannelType.GuildVoice,
|
|
289
|
+
parent: kimakiCategory,
|
|
290
|
+
});
|
|
291
|
+
db.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)').run(textChannel.id, project.worktree, 'text');
|
|
292
|
+
db.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)').run(voiceChannel.id, project.worktree, 'voice');
|
|
293
|
+
createdChannels.push({
|
|
294
|
+
name: textChannel.name,
|
|
295
|
+
id: textChannel.id,
|
|
296
|
+
guildId: targetGuild.id,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
cliLogger.error(`Failed to create channels for ${baseName}:`, error);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
s.stop(`Created ${createdChannels.length} channel(s)`);
|
|
304
|
+
if (createdChannels.length > 0) {
|
|
305
|
+
note(createdChannels.map((ch) => `#${ch.name}`).join('\n'), 'Created Channels');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
cliLogger.log('Registering slash commands asynchronously...');
|
|
310
|
+
void registerCommands(token, appId)
|
|
311
|
+
.then(() => {
|
|
312
|
+
cliLogger.log('Slash commands registered!');
|
|
313
|
+
})
|
|
314
|
+
.catch((error) => {
|
|
315
|
+
cliLogger.error('Failed to register slash commands:', error instanceof Error ? error.message : String(error));
|
|
316
|
+
});
|
|
317
|
+
s.start('Starting Discord bot...');
|
|
318
|
+
await startDiscordBot({ token, appId, discordClient });
|
|
319
|
+
s.stop('Discord bot is running!');
|
|
320
|
+
const allChannels = [];
|
|
321
|
+
allChannels.push(...createdChannels);
|
|
322
|
+
kimakiChannels.forEach(({ guild, channels }) => {
|
|
323
|
+
channels.forEach((ch) => {
|
|
324
|
+
allChannels.push({
|
|
325
|
+
name: ch.name,
|
|
326
|
+
id: ch.id,
|
|
327
|
+
guildId: guild.id,
|
|
328
|
+
directory: ch.kimakiDirectory,
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
if (allChannels.length > 0) {
|
|
333
|
+
const channelLinks = allChannels
|
|
334
|
+
.map((ch) => `• #${ch.name}: https://discord.com/channels/${ch.guildId}/${ch.id}`)
|
|
335
|
+
.join('\n');
|
|
336
|
+
note(`Your kimaki channels are ready! Click any link below to open in Discord:\n\n${channelLinks}\n\nSend a message in any channel to start using OpenCode!`, '🚀 Ready to Use');
|
|
337
|
+
}
|
|
338
|
+
outro('✨ Setup complete!');
|
|
339
|
+
}
|
|
340
|
+
cli
|
|
341
|
+
.command('', 'Set up and run the Kimaki Discord bot')
|
|
342
|
+
.option('--restart', 'Prompt for new credentials even if saved')
|
|
343
|
+
.option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
|
|
344
|
+
.action(async (options) => {
|
|
345
|
+
try {
|
|
346
|
+
await run({
|
|
347
|
+
restart: options.restart,
|
|
348
|
+
addChannels: options.addChannels,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
cliLogger.error('Unhandled error:', error instanceof Error ? error.message : String(error));
|
|
353
|
+
process.exit(EXIT_NO_RESTART);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
cli.help();
|
|
357
|
+
cli.parse();
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { PassThrough } from 'node:stream';
|
|
3
|
+
import { VoiceConnectionStatus } from '@discordjs/voice';
|
|
4
|
+
import * as prism from 'prism-media';
|
|
5
|
+
import { Resampler } from '@purinton/resampler';
|
|
6
|
+
// Discord expects Opus packets every 20ms
|
|
7
|
+
const FRAME_LENGTH = 20;
|
|
8
|
+
export function createDirectVoiceStreamer({ connection, inputSampleRate, inputChannels, onStop, }) {
|
|
9
|
+
let active = true;
|
|
10
|
+
const opusChannels = 2;
|
|
11
|
+
// Create resampler to convert input to 48kHz stereo (Discord's format)
|
|
12
|
+
const resampler = new Resampler({
|
|
13
|
+
inRate: inputSampleRate,
|
|
14
|
+
outRate: 48000,
|
|
15
|
+
inChannels: inputChannels,
|
|
16
|
+
volume: 1,
|
|
17
|
+
filterWindow: 8,
|
|
18
|
+
outChannels: opusChannels,
|
|
19
|
+
});
|
|
20
|
+
resampler.on('error', (error) => {
|
|
21
|
+
console.error('[DIRECT VOICE] Resampler error:', error);
|
|
22
|
+
});
|
|
23
|
+
resampler.on('end', () => {
|
|
24
|
+
console.error('[DIRECT VOICE] Resampler end');
|
|
25
|
+
});
|
|
26
|
+
const frameSize = 48000 * 20 / 1000; // 20ms at 48kHz = 960 samples per frame
|
|
27
|
+
// Create Opus encoder for Discord (48kHz stereo)
|
|
28
|
+
const encoder = new prism.opus.Encoder({
|
|
29
|
+
rate: 48000,
|
|
30
|
+
channels: opusChannels,
|
|
31
|
+
frameSize,
|
|
32
|
+
});
|
|
33
|
+
// Pipe resampler to encoder
|
|
34
|
+
resampler.pipe(encoder);
|
|
35
|
+
let packetInterval = null;
|
|
36
|
+
const opusPacketQueue = [];
|
|
37
|
+
encoder.on('data', (packet) => {
|
|
38
|
+
// Opus encoder outputs complete packets, don't buffer them
|
|
39
|
+
opusPacketQueue.push(packet);
|
|
40
|
+
});
|
|
41
|
+
// Send packets at 20ms intervals as Discord expects
|
|
42
|
+
packetInterval = setInterval(() => {
|
|
43
|
+
if (!active) {
|
|
44
|
+
if (packetInterval) {
|
|
45
|
+
clearInterval(packetInterval);
|
|
46
|
+
packetInterval = null;
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const packet = opusPacketQueue.shift();
|
|
51
|
+
if (!packet)
|
|
52
|
+
return;
|
|
53
|
+
if (connection.state.status !== VoiceConnectionStatus.Ready) {
|
|
54
|
+
console.log('[DIRECT VOICE] Skipping packet: connection not ready');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
connection.setSpeaking(true);
|
|
59
|
+
connection.playOpusPacket(packet);
|
|
60
|
+
console.log('[DIRECT VOICE] Sent Opus packet of size', packet.length);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
console.error('[DIRECT VOICE] Error sending packet:', error);
|
|
64
|
+
}
|
|
65
|
+
}, 20); // 20ms interval
|
|
66
|
+
encoder.on('error', (error) => {
|
|
67
|
+
console.error('[DIRECT VOICE] Encoder error:', error);
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
write(pcmData) {
|
|
71
|
+
if (!active) {
|
|
72
|
+
throw new Error('Voice streamer has been stopped');
|
|
73
|
+
}
|
|
74
|
+
resampler.write(pcmData);
|
|
75
|
+
},
|
|
76
|
+
interrupt() {
|
|
77
|
+
// Clear the Opus packet queue
|
|
78
|
+
opusPacketQueue.length = 0;
|
|
79
|
+
// Stop speaking immediately
|
|
80
|
+
connection.setSpeaking(false);
|
|
81
|
+
console.log('[DIRECT VOICE] Interrupted - cleared queue');
|
|
82
|
+
},
|
|
83
|
+
stop() {
|
|
84
|
+
if (!active)
|
|
85
|
+
return;
|
|
86
|
+
active = false;
|
|
87
|
+
// Stop speaking
|
|
88
|
+
connection.setSpeaking(false);
|
|
89
|
+
// Clear the packet interval
|
|
90
|
+
if (packetInterval) {
|
|
91
|
+
clearInterval(packetInterval);
|
|
92
|
+
packetInterval = null;
|
|
93
|
+
}
|
|
94
|
+
// Close the resampler input
|
|
95
|
+
resampler.end();
|
|
96
|
+
onStop?.();
|
|
97
|
+
},
|
|
98
|
+
get isActive() {
|
|
99
|
+
return active;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|