disunday 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-tool-to-genai.js +208 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/channel-management.js +96 -0
- package/dist/cli.js +1674 -0
- package/dist/commands/abort.js +89 -0
- package/dist/commands/add-project.js +117 -0
- package/dist/commands/agent.js +250 -0
- package/dist/commands/ask-question.js +219 -0
- package/dist/commands/compact.js +126 -0
- package/dist/commands/context-menu.js +171 -0
- package/dist/commands/context.js +89 -0
- package/dist/commands/cost.js +93 -0
- package/dist/commands/create-new-project.js +111 -0
- package/dist/commands/diff.js +77 -0
- package/dist/commands/export.js +100 -0
- package/dist/commands/files.js +73 -0
- package/dist/commands/fork.js +199 -0
- package/dist/commands/help.js +54 -0
- package/dist/commands/login.js +488 -0
- package/dist/commands/merge-worktree.js +165 -0
- package/dist/commands/model.js +325 -0
- package/dist/commands/permissions.js +140 -0
- package/dist/commands/ping.js +13 -0
- package/dist/commands/queue.js +133 -0
- package/dist/commands/remove-project.js +119 -0
- package/dist/commands/rename.js +70 -0
- package/dist/commands/restart-opencode-server.js +77 -0
- package/dist/commands/resume.js +276 -0
- package/dist/commands/run-config.js +79 -0
- package/dist/commands/run.js +240 -0
- package/dist/commands/schedule.js +170 -0
- package/dist/commands/session-info.js +58 -0
- package/dist/commands/session.js +191 -0
- package/dist/commands/settings.js +84 -0
- package/dist/commands/share.js +89 -0
- package/dist/commands/status.js +79 -0
- package/dist/commands/sync.js +119 -0
- package/dist/commands/theme.js +53 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +170 -0
- package/dist/commands/user-command.js +135 -0
- package/dist/commands/verbosity.js +59 -0
- package/dist/commands/worktree-settings.js +50 -0
- package/dist/commands/worktree.js +288 -0
- package/dist/config.js +139 -0
- package/dist/database.js +585 -0
- package/dist/discord-bot.js +700 -0
- package/dist/discord-utils.js +336 -0
- package/dist/discord-utils.test.js +20 -0
- package/dist/errors.js +193 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/format-tables.js +96 -0
- package/dist/format-tables.test.js +418 -0
- package/dist/genai-worker-wrapper.js +109 -0
- package/dist/genai-worker.js +299 -0
- package/dist/genai.js +230 -0
- package/dist/image-utils.js +107 -0
- package/dist/interaction-handler.js +289 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +111 -0
- package/dist/markdown.js +323 -0
- package/dist/markdown.test.js +269 -0
- package/dist/message-formatting.js +447 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +226 -0
- package/dist/opencode.js +224 -0
- package/dist/reaction-handler.js +128 -0
- package/dist/scheduler.js +93 -0
- package/dist/security.js +200 -0
- package/dist/session-handler.js +1436 -0
- package/dist/system-message.js +138 -0
- package/dist/tools.js +354 -0
- package/dist/unnest-code-blocks.js +117 -0
- package/dist/unnest-code-blocks.test.js +432 -0
- package/dist/utils.js +95 -0
- package/dist/voice-handler.js +569 -0
- package/dist/voice.js +344 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-utils.js +134 -0
- package/dist/xml.js +90 -0
- package/dist/xml.test.js +32 -0
- package/package.json +84 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Worker thread for GenAI voice processing.
|
|
2
|
+
// Runs in a separate thread to handle audio encoding/decoding without blocking.
|
|
3
|
+
// Resamples 24kHz GenAI output to 48kHz stereo Opus packets for Discord.
|
|
4
|
+
import { parentPort, threadId } from 'node:worker_threads';
|
|
5
|
+
import { createWriteStream } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import * as errore from 'errore';
|
|
8
|
+
import { Resampler } from '@purinton/resampler';
|
|
9
|
+
import * as prism from 'prism-media';
|
|
10
|
+
import { startGenAiSession } from './genai.js';
|
|
11
|
+
import { getTools } from './tools.js';
|
|
12
|
+
import { mkdir } from 'node:fs/promises';
|
|
13
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
14
|
+
if (!parentPort) {
|
|
15
|
+
throw new Error('This module must be run as a worker thread');
|
|
16
|
+
}
|
|
17
|
+
const workerLogger = createLogger(`${LogPrefix.WORKER}_${threadId}`);
|
|
18
|
+
workerLogger.log('GenAI worker started');
|
|
19
|
+
// Define sendError early so it can be used by global handlers
|
|
20
|
+
function sendError(error) {
|
|
21
|
+
if (parentPort) {
|
|
22
|
+
parentPort.postMessage({
|
|
23
|
+
type: 'error',
|
|
24
|
+
error,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Add global error handlers for the worker thread
|
|
29
|
+
process.on('uncaughtException', (error) => {
|
|
30
|
+
workerLogger.error('Uncaught exception in worker:', error);
|
|
31
|
+
sendError(`Worker crashed: ${error.message}`);
|
|
32
|
+
// Exit immediately on uncaught exception
|
|
33
|
+
process.exit(1);
|
|
34
|
+
});
|
|
35
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
36
|
+
workerLogger.error('Unhandled rejection in worker:', reason, 'at promise:', promise);
|
|
37
|
+
sendError(`Worker unhandled rejection: ${reason}`);
|
|
38
|
+
});
|
|
39
|
+
// Audio configuration
|
|
40
|
+
const AUDIO_CONFIG = {
|
|
41
|
+
inputSampleRate: 24000, // GenAI output
|
|
42
|
+
inputChannels: 1,
|
|
43
|
+
outputSampleRate: 48000, // Discord expects
|
|
44
|
+
outputChannels: 2,
|
|
45
|
+
opusFrameSize: 960, // 20ms at 48kHz
|
|
46
|
+
};
|
|
47
|
+
// Initialize audio processing components
|
|
48
|
+
const resampler = new Resampler({
|
|
49
|
+
inRate: AUDIO_CONFIG.inputSampleRate,
|
|
50
|
+
outRate: AUDIO_CONFIG.outputSampleRate,
|
|
51
|
+
inChannels: AUDIO_CONFIG.inputChannels,
|
|
52
|
+
outChannels: AUDIO_CONFIG.outputChannels,
|
|
53
|
+
volume: 1,
|
|
54
|
+
filterWindow: 8,
|
|
55
|
+
});
|
|
56
|
+
const opusEncoder = new prism.opus.Encoder({
|
|
57
|
+
rate: AUDIO_CONFIG.outputSampleRate,
|
|
58
|
+
channels: AUDIO_CONFIG.outputChannels,
|
|
59
|
+
frameSize: AUDIO_CONFIG.opusFrameSize,
|
|
60
|
+
});
|
|
61
|
+
// Pipe resampler to encoder with error handling
|
|
62
|
+
resampler.pipe(opusEncoder).on('error', (error) => {
|
|
63
|
+
workerLogger.error('Pipe error between resampler and encoder:', error);
|
|
64
|
+
sendError(`Audio pipeline error: ${error.message}`);
|
|
65
|
+
});
|
|
66
|
+
// Opus packet queue and interval for 20ms packet sending
|
|
67
|
+
const opusPacketQueue = [];
|
|
68
|
+
let packetInterval = null;
|
|
69
|
+
// Send packets every 20ms
|
|
70
|
+
function startPacketSending() {
|
|
71
|
+
if (packetInterval)
|
|
72
|
+
return;
|
|
73
|
+
packetInterval = setInterval(() => {
|
|
74
|
+
const packet = opusPacketQueue.shift();
|
|
75
|
+
if (!packet)
|
|
76
|
+
return;
|
|
77
|
+
// Transfer packet as ArrayBuffer
|
|
78
|
+
const arrayBuffer = packet.buffer.slice(packet.byteOffset, packet.byteOffset + packet.byteLength);
|
|
79
|
+
parentPort.postMessage({
|
|
80
|
+
type: 'assistantOpusPacket',
|
|
81
|
+
packet: arrayBuffer,
|
|
82
|
+
}, [arrayBuffer]);
|
|
83
|
+
}, 20);
|
|
84
|
+
}
|
|
85
|
+
function stopPacketSending() {
|
|
86
|
+
if (packetInterval) {
|
|
87
|
+
clearInterval(packetInterval);
|
|
88
|
+
packetInterval = null;
|
|
89
|
+
}
|
|
90
|
+
opusPacketQueue.length = 0;
|
|
91
|
+
}
|
|
92
|
+
// Session state
|
|
93
|
+
let session = null;
|
|
94
|
+
// Audio log stream for assistant audio
|
|
95
|
+
let audioLogStream = null;
|
|
96
|
+
// Create assistant audio log stream for debugging
|
|
97
|
+
async function createAssistantAudioLogStream(guildId, channelId) {
|
|
98
|
+
if (!process.env.DEBUG)
|
|
99
|
+
return null;
|
|
100
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
101
|
+
const audioDir = path.join(process.cwd(), 'discord-audio-logs', guildId, channelId);
|
|
102
|
+
const mkdirError = await errore.tryAsync({
|
|
103
|
+
try: () => mkdir(audioDir, { recursive: true }),
|
|
104
|
+
catch: (e) => e,
|
|
105
|
+
});
|
|
106
|
+
if (mkdirError instanceof Error) {
|
|
107
|
+
workerLogger.error(`Failed to create audio log directory:`, mkdirError.message);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
// Create stream for assistant audio (24kHz mono s16le PCM)
|
|
111
|
+
const outputFileName = `assistant_${timestamp}.24.pcm`;
|
|
112
|
+
const outputFilePath = path.join(audioDir, outputFileName);
|
|
113
|
+
const outputAudioStream = createWriteStream(outputFilePath);
|
|
114
|
+
// Add error handler to prevent crashes
|
|
115
|
+
outputAudioStream.on('error', (error) => {
|
|
116
|
+
workerLogger.error(`Assistant audio log stream error:`, error);
|
|
117
|
+
});
|
|
118
|
+
workerLogger.log(`Created assistant audio log: ${outputFilePath}`);
|
|
119
|
+
return outputAudioStream;
|
|
120
|
+
}
|
|
121
|
+
// Handle encoded Opus packets
|
|
122
|
+
opusEncoder.on('data', (packet) => {
|
|
123
|
+
opusPacketQueue.push(packet);
|
|
124
|
+
});
|
|
125
|
+
// Handle stream end events
|
|
126
|
+
opusEncoder.on('end', () => {
|
|
127
|
+
workerLogger.log('Opus encoder stream ended');
|
|
128
|
+
});
|
|
129
|
+
resampler.on('end', () => {
|
|
130
|
+
workerLogger.log('Resampler stream ended');
|
|
131
|
+
});
|
|
132
|
+
// Handle errors
|
|
133
|
+
resampler.on('error', (error) => {
|
|
134
|
+
workerLogger.error(`Resampler error:`, error);
|
|
135
|
+
sendError(`Resampler error: ${error.message}`);
|
|
136
|
+
});
|
|
137
|
+
opusEncoder.on('error', (error) => {
|
|
138
|
+
workerLogger.error(`Encoder error:`, error);
|
|
139
|
+
// Check for specific corrupted data errors
|
|
140
|
+
if (error.message?.includes('The compressed data passed is corrupted')) {
|
|
141
|
+
workerLogger.warn('Received corrupted audio data in opus encoder');
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
sendError(`Encoder error: ${error.message}`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
async function cleanupAsync() {
|
|
148
|
+
workerLogger.log(`Starting async cleanup`);
|
|
149
|
+
stopPacketSending();
|
|
150
|
+
if (session) {
|
|
151
|
+
workerLogger.log(`Stopping GenAI session`);
|
|
152
|
+
session.stop();
|
|
153
|
+
session = null;
|
|
154
|
+
}
|
|
155
|
+
// Wait for audio log stream to finish writing
|
|
156
|
+
if (audioLogStream) {
|
|
157
|
+
workerLogger.log(`Closing assistant audio log stream`);
|
|
158
|
+
await new Promise((resolve, reject) => {
|
|
159
|
+
audioLogStream.end(() => {
|
|
160
|
+
workerLogger.log(`Assistant audio log stream closed`);
|
|
161
|
+
resolve();
|
|
162
|
+
});
|
|
163
|
+
audioLogStream.on('error', reject);
|
|
164
|
+
// Add timeout to prevent hanging
|
|
165
|
+
setTimeout(() => {
|
|
166
|
+
workerLogger.log(`Audio stream close timeout, continuing`);
|
|
167
|
+
resolve();
|
|
168
|
+
}, 3000);
|
|
169
|
+
});
|
|
170
|
+
audioLogStream = null;
|
|
171
|
+
}
|
|
172
|
+
// Unpipe and end the encoder first
|
|
173
|
+
resampler.unpipe(opusEncoder);
|
|
174
|
+
// End the encoder stream
|
|
175
|
+
await new Promise((resolve) => {
|
|
176
|
+
opusEncoder.end(() => {
|
|
177
|
+
workerLogger.log(`Opus encoder ended`);
|
|
178
|
+
resolve();
|
|
179
|
+
});
|
|
180
|
+
// Add timeout
|
|
181
|
+
setTimeout(resolve, 1000);
|
|
182
|
+
});
|
|
183
|
+
// End the resampler stream
|
|
184
|
+
await new Promise((resolve) => {
|
|
185
|
+
resampler.end(() => {
|
|
186
|
+
workerLogger.log(`Resampler ended`);
|
|
187
|
+
resolve();
|
|
188
|
+
});
|
|
189
|
+
// Add timeout
|
|
190
|
+
setTimeout(resolve, 1000);
|
|
191
|
+
});
|
|
192
|
+
workerLogger.log(`Async cleanup complete`);
|
|
193
|
+
}
|
|
194
|
+
// Handle messages from main thread
|
|
195
|
+
parentPort.on('message', async (message) => {
|
|
196
|
+
try {
|
|
197
|
+
switch (message.type) {
|
|
198
|
+
case 'init': {
|
|
199
|
+
workerLogger.log(`Initializing with directory:`, message.directory);
|
|
200
|
+
// Create audio log stream for assistant audio
|
|
201
|
+
audioLogStream = await createAssistantAudioLogStream(message.guildId, message.channelId);
|
|
202
|
+
// Start packet sending interval
|
|
203
|
+
startPacketSending();
|
|
204
|
+
// Get tools for the directory
|
|
205
|
+
const { tools } = await getTools({
|
|
206
|
+
directory: message.directory,
|
|
207
|
+
onMessageCompleted: (params) => {
|
|
208
|
+
parentPort.postMessage({
|
|
209
|
+
type: 'toolCallCompleted',
|
|
210
|
+
...params,
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
// Start GenAI session
|
|
215
|
+
session = await startGenAiSession({
|
|
216
|
+
tools,
|
|
217
|
+
systemMessage: message.systemMessage,
|
|
218
|
+
geminiApiKey: message.geminiApiKey,
|
|
219
|
+
onAssistantAudioChunk({ data }) {
|
|
220
|
+
// Write to audio log if enabled
|
|
221
|
+
if (audioLogStream && !audioLogStream.destroyed) {
|
|
222
|
+
audioLogStream.write(data, (err) => {
|
|
223
|
+
if (err) {
|
|
224
|
+
workerLogger.error('Error writing to audio log:', err);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
// Write PCM data to resampler which will output Opus packets
|
|
229
|
+
if (!resampler.destroyed) {
|
|
230
|
+
resampler.write(data, (err) => {
|
|
231
|
+
if (err) {
|
|
232
|
+
workerLogger.error('Error writing to resampler:', err);
|
|
233
|
+
sendError(`Failed to process audio: ${err.message}`);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
onAssistantStartSpeaking() {
|
|
239
|
+
parentPort.postMessage({
|
|
240
|
+
type: 'assistantStartSpeaking',
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
onAssistantStopSpeaking() {
|
|
244
|
+
parentPort.postMessage({
|
|
245
|
+
type: 'assistantStopSpeaking',
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
onAssistantInterruptSpeaking() {
|
|
249
|
+
parentPort.postMessage({
|
|
250
|
+
type: 'assistantInterruptSpeaking',
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
// Notify main thread we're ready
|
|
255
|
+
parentPort.postMessage({
|
|
256
|
+
type: 'ready',
|
|
257
|
+
});
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case 'sendRealtimeInput': {
|
|
261
|
+
if (!session) {
|
|
262
|
+
sendError('Session not initialized');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
session.session.sendRealtimeInput({
|
|
266
|
+
audio: message.audio,
|
|
267
|
+
audioStreamEnd: message.audioStreamEnd,
|
|
268
|
+
});
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case 'sendTextInput': {
|
|
272
|
+
if (!session) {
|
|
273
|
+
sendError('Session not initialized');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
session.session.sendRealtimeInput({
|
|
277
|
+
text: message.text,
|
|
278
|
+
});
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
case 'interrupt': {
|
|
282
|
+
workerLogger.log(`Interrupting playback`);
|
|
283
|
+
// Clear the opus packet queue
|
|
284
|
+
opusPacketQueue.length = 0;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
case 'stop': {
|
|
288
|
+
workerLogger.log(`Stopping worker`);
|
|
289
|
+
await cleanupAsync();
|
|
290
|
+
// process.exit(0)
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
workerLogger.error(`Error handling message:`, error);
|
|
297
|
+
sendError(error instanceof Error ? error.message : 'Unknown error in worker');
|
|
298
|
+
}
|
|
299
|
+
});
|
package/dist/genai.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Google GenAI Live session manager for real-time voice interactions.
|
|
2
|
+
// Establishes bidirectional audio streaming with Gemini, handles tool calls,
|
|
3
|
+
// and manages the assistant's audio output for Discord voice channels.
|
|
4
|
+
import { GoogleGenAI, LiveServerMessage, MediaResolution, Modality, Session } from '@google/genai';
|
|
5
|
+
import { writeFile } from 'fs';
|
|
6
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
7
|
+
import { aiToolToCallableTool } from './ai-tool-to-genai.js';
|
|
8
|
+
const genaiLogger = createLogger(LogPrefix.GENAI);
|
|
9
|
+
const audioParts = [];
|
|
10
|
+
function saveBinaryFile(fileName, content) {
|
|
11
|
+
writeFile(fileName, content, 'utf8', (err) => {
|
|
12
|
+
if (err) {
|
|
13
|
+
genaiLogger.error(`Error writing file ${fileName}:`, err);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
genaiLogger.log(`Appending stream content to file ${fileName}.`);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function convertToWav(rawData, mimeType) {
|
|
20
|
+
const options = parseMimeType(mimeType);
|
|
21
|
+
const dataLength = rawData.reduce((a, b) => a + b.length, 0);
|
|
22
|
+
const wavHeader = createWavHeader(dataLength, options);
|
|
23
|
+
const buffer = Buffer.concat(rawData);
|
|
24
|
+
return Buffer.concat([wavHeader, buffer]);
|
|
25
|
+
}
|
|
26
|
+
function parseMimeType(mimeType) {
|
|
27
|
+
const [fileType, ...params] = mimeType.split(';').map((s) => s.trim());
|
|
28
|
+
const [_, format] = fileType?.split('/') || [];
|
|
29
|
+
const options = {
|
|
30
|
+
numChannels: 1,
|
|
31
|
+
bitsPerSample: 16,
|
|
32
|
+
};
|
|
33
|
+
if (format && format.startsWith('L')) {
|
|
34
|
+
const bits = parseInt(format.slice(1), 10);
|
|
35
|
+
if (!isNaN(bits)) {
|
|
36
|
+
options.bitsPerSample = bits;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const param of params) {
|
|
40
|
+
const [key, value] = param.split('=').map((s) => s.trim());
|
|
41
|
+
if (key === 'rate') {
|
|
42
|
+
options.sampleRate = parseInt(value || '', 10);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return options;
|
|
46
|
+
}
|
|
47
|
+
function createWavHeader(dataLength, options) {
|
|
48
|
+
const { numChannels, sampleRate, bitsPerSample } = options;
|
|
49
|
+
// http://soundfile.sapp.org/doc/WaveFormat
|
|
50
|
+
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
|
|
51
|
+
const blockAlign = (numChannels * bitsPerSample) / 8;
|
|
52
|
+
const buffer = Buffer.alloc(44);
|
|
53
|
+
buffer.write('RIFF', 0); // ChunkID
|
|
54
|
+
buffer.writeUInt32LE(36 + dataLength, 4); // ChunkSize
|
|
55
|
+
buffer.write('WAVE', 8); // Format
|
|
56
|
+
buffer.write('fmt ', 12); // Subchunk1ID
|
|
57
|
+
buffer.writeUInt32LE(16, 16); // Subchunk1Size (PCM)
|
|
58
|
+
buffer.writeUInt16LE(1, 20); // AudioFormat (1 = PCM)
|
|
59
|
+
buffer.writeUInt16LE(numChannels, 22); // NumChannels
|
|
60
|
+
buffer.writeUInt32LE(sampleRate, 24); // SampleRate
|
|
61
|
+
buffer.writeUInt32LE(byteRate, 28); // ByteRate
|
|
62
|
+
buffer.writeUInt16LE(blockAlign, 32); // BlockAlign
|
|
63
|
+
buffer.writeUInt16LE(bitsPerSample, 34); // BitsPerSample
|
|
64
|
+
buffer.write('data', 36); // Subchunk2ID
|
|
65
|
+
buffer.writeUInt32LE(dataLength, 40); // Subchunk2Size
|
|
66
|
+
return buffer;
|
|
67
|
+
}
|
|
68
|
+
function defaultAudioChunkHandler({ data, mimeType }) {
|
|
69
|
+
audioParts.push(data);
|
|
70
|
+
const fileName = 'audio.wav';
|
|
71
|
+
const buffer = convertToWav(audioParts, mimeType);
|
|
72
|
+
saveBinaryFile(fileName, buffer);
|
|
73
|
+
}
|
|
74
|
+
export async function startGenAiSession({ onAssistantAudioChunk, onAssistantStartSpeaking, onAssistantStopSpeaking, onAssistantInterruptSpeaking, systemMessage, tools, geminiApiKey, } = {}) {
|
|
75
|
+
let session = undefined;
|
|
76
|
+
const callableTools = [];
|
|
77
|
+
let isAssistantSpeaking = false;
|
|
78
|
+
const audioChunkHandler = onAssistantAudioChunk || defaultAudioChunkHandler;
|
|
79
|
+
// Convert AI SDK tools to GenAI CallableTools
|
|
80
|
+
if (tools) {
|
|
81
|
+
for (const [name, tool] of Object.entries(tools)) {
|
|
82
|
+
callableTools.push(aiToolToCallableTool(tool, name));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function handleModelTurn(message) {
|
|
86
|
+
if (message.toolCall) {
|
|
87
|
+
genaiLogger.log('Tool call:', message.toolCall);
|
|
88
|
+
// Handle tool calls
|
|
89
|
+
if (message.toolCall.functionCalls && callableTools.length > 0) {
|
|
90
|
+
for (const tool of callableTools) {
|
|
91
|
+
if (!message.toolCall.functionCalls.some((x) => x.name === tool.name)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
tool
|
|
95
|
+
.callTool(message.toolCall.functionCalls)
|
|
96
|
+
.then((parts) => {
|
|
97
|
+
const functionResponses = parts
|
|
98
|
+
.filter((part) => part.functionResponse)
|
|
99
|
+
.map((part) => ({
|
|
100
|
+
response: part.functionResponse.response,
|
|
101
|
+
id: part.functionResponse.id,
|
|
102
|
+
name: part.functionResponse.name,
|
|
103
|
+
}));
|
|
104
|
+
if (functionResponses.length > 0 && session) {
|
|
105
|
+
session.sendToolResponse({ functionResponses });
|
|
106
|
+
genaiLogger.log('client-toolResponse: ' + JSON.stringify({ functionResponses }));
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
.catch((error) => {
|
|
110
|
+
genaiLogger.error('Error handling tool calls:', error);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (message.serverContent?.modelTurn?.parts) {
|
|
116
|
+
for (const part of message.serverContent.modelTurn.parts) {
|
|
117
|
+
if (part?.fileData) {
|
|
118
|
+
genaiLogger.log(`File: ${part?.fileData.fileUri}`);
|
|
119
|
+
}
|
|
120
|
+
if (part?.inlineData) {
|
|
121
|
+
const inlineData = part.inlineData;
|
|
122
|
+
if (!inlineData.mimeType || !inlineData.mimeType.startsWith('audio/')) {
|
|
123
|
+
genaiLogger.log('Skipping non-audio inlineData:', inlineData.mimeType);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// Trigger start speaking callback the first time audio is received
|
|
127
|
+
if (!isAssistantSpeaking && onAssistantStartSpeaking) {
|
|
128
|
+
isAssistantSpeaking = true;
|
|
129
|
+
onAssistantStartSpeaking();
|
|
130
|
+
}
|
|
131
|
+
const buffer = Buffer.from(inlineData?.data ?? '', 'base64');
|
|
132
|
+
audioChunkHandler({
|
|
133
|
+
data: buffer,
|
|
134
|
+
mimeType: inlineData.mimeType ?? '',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (part?.text) {
|
|
138
|
+
genaiLogger.log('Text:', part.text);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Handle input transcription (user's audio transcription)
|
|
143
|
+
if (message.serverContent?.inputTranscription?.text) {
|
|
144
|
+
genaiLogger.log('[user transcription]', message.serverContent.inputTranscription.text);
|
|
145
|
+
}
|
|
146
|
+
// Handle output transcription (model's audio transcription)
|
|
147
|
+
if (message.serverContent?.outputTranscription?.text) {
|
|
148
|
+
genaiLogger.log('[assistant transcription]', message.serverContent.outputTranscription.text);
|
|
149
|
+
}
|
|
150
|
+
if (message.serverContent?.interrupted) {
|
|
151
|
+
genaiLogger.log('Assistant was interrupted');
|
|
152
|
+
if (isAssistantSpeaking && onAssistantInterruptSpeaking) {
|
|
153
|
+
isAssistantSpeaking = false;
|
|
154
|
+
onAssistantInterruptSpeaking();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (message.serverContent?.turnComplete) {
|
|
158
|
+
genaiLogger.log('Assistant turn complete');
|
|
159
|
+
if (isAssistantSpeaking && onAssistantStopSpeaking) {
|
|
160
|
+
isAssistantSpeaking = false;
|
|
161
|
+
onAssistantStopSpeaking();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const apiKey = geminiApiKey || process.env.GEMINI_API_KEY;
|
|
166
|
+
if (!apiKey) {
|
|
167
|
+
genaiLogger.error('No Gemini API key provided');
|
|
168
|
+
throw new Error('Gemini API key is required for voice interactions');
|
|
169
|
+
}
|
|
170
|
+
const ai = new GoogleGenAI({
|
|
171
|
+
apiKey,
|
|
172
|
+
});
|
|
173
|
+
const model = 'gemini-2.5-flash-native-audio-preview-12-2025';
|
|
174
|
+
session = await ai.live.connect({
|
|
175
|
+
model,
|
|
176
|
+
callbacks: {
|
|
177
|
+
onopen: function () {
|
|
178
|
+
genaiLogger.debug('Opened');
|
|
179
|
+
},
|
|
180
|
+
onmessage: function (message) {
|
|
181
|
+
// genaiLogger.log(message)
|
|
182
|
+
try {
|
|
183
|
+
handleModelTurn(message);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
genaiLogger.error('Error handling turn:', error);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
onerror: function (e) {
|
|
190
|
+
genaiLogger.debug('Error:', e.message);
|
|
191
|
+
},
|
|
192
|
+
onclose: function (e) {
|
|
193
|
+
genaiLogger.debug('Close:', e.reason);
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
config: {
|
|
197
|
+
tools: callableTools,
|
|
198
|
+
responseModalities: [Modality.AUDIO],
|
|
199
|
+
mediaResolution: MediaResolution.MEDIA_RESOLUTION_MEDIUM,
|
|
200
|
+
inputAudioTranscription: {}, // transcribes your input speech
|
|
201
|
+
outputAudioTranscription: {}, // transcribes the model's spoken audio
|
|
202
|
+
systemInstruction: {
|
|
203
|
+
parts: [
|
|
204
|
+
{
|
|
205
|
+
text: systemMessage || '',
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
speechConfig: {
|
|
210
|
+
voiceConfig: {
|
|
211
|
+
prebuiltVoiceConfig: {
|
|
212
|
+
voiceName: 'Charon', // Orus also not bad
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
contextWindowCompression: {
|
|
217
|
+
triggerTokens: '25600',
|
|
218
|
+
slidingWindow: { targetTokens: '12800' },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
return {
|
|
223
|
+
session,
|
|
224
|
+
stop: () => {
|
|
225
|
+
const currentSession = session;
|
|
226
|
+
session = undefined;
|
|
227
|
+
currentSession?.close();
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Image processing utilities for Discord attachments.
|
|
2
|
+
// Uses sharp (optional) to resize large images and heic-convert (optional) for HEIC support.
|
|
3
|
+
// Falls back gracefully if dependencies are not available.
|
|
4
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
5
|
+
const logger = createLogger(LogPrefix.FORMATTING);
|
|
6
|
+
const MAX_DIMENSION = 1500;
|
|
7
|
+
const HEIC_MIME_TYPES = ['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence'];
|
|
8
|
+
let sharpModule = undefined;
|
|
9
|
+
let heicConvertModule = undefined;
|
|
10
|
+
async function tryLoadSharp() {
|
|
11
|
+
if (sharpModule !== undefined) {
|
|
12
|
+
return sharpModule;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
sharpModule = (await import('sharp')).default;
|
|
16
|
+
logger.log('sharp loaded successfully');
|
|
17
|
+
return sharpModule;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
logger.log('sharp not available, images will be sent at original size');
|
|
21
|
+
sharpModule = null;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function tryLoadHeicConvert() {
|
|
26
|
+
if (heicConvertModule !== undefined) {
|
|
27
|
+
return heicConvertModule;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const mod = await import('heic-convert');
|
|
31
|
+
heicConvertModule = mod.default;
|
|
32
|
+
logger.log('heic-convert loaded successfully');
|
|
33
|
+
return heicConvertModule;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
logger.log('heic-convert not available, HEIC images will be sent as-is');
|
|
37
|
+
heicConvertModule = null;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function isHeicMime(mime) {
|
|
42
|
+
return HEIC_MIME_TYPES.includes(mime.toLowerCase());
|
|
43
|
+
}
|
|
44
|
+
export async function processImage(buffer, mime) {
|
|
45
|
+
// Skip non-images (PDFs, etc.)
|
|
46
|
+
if (!mime.startsWith('image/')) {
|
|
47
|
+
return { buffer, mime };
|
|
48
|
+
}
|
|
49
|
+
let workingBuffer = buffer;
|
|
50
|
+
let workingMime = mime;
|
|
51
|
+
// Handle HEIC conversion first (before sharp, since sharp doesn't support HEIC)
|
|
52
|
+
if (isHeicMime(mime)) {
|
|
53
|
+
const heicConvert = await tryLoadHeicConvert();
|
|
54
|
+
if (heicConvert) {
|
|
55
|
+
try {
|
|
56
|
+
const outputArrayBuffer = await heicConvert({
|
|
57
|
+
buffer: workingBuffer.buffer.slice(workingBuffer.byteOffset, workingBuffer.byteOffset + workingBuffer.byteLength),
|
|
58
|
+
format: 'JPEG',
|
|
59
|
+
quality: 0.85,
|
|
60
|
+
});
|
|
61
|
+
workingBuffer = Buffer.from(outputArrayBuffer);
|
|
62
|
+
workingMime = 'image/jpeg';
|
|
63
|
+
logger.log(`Converted HEIC to JPEG (${buffer.length} → ${workingBuffer.length} bytes)`);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
logger.error('Failed to convert HEIC, sending original:', error);
|
|
67
|
+
return { buffer, mime };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// No heic-convert available, return original (LLM might not support it)
|
|
72
|
+
logger.log('HEIC image detected but heic-convert not available, sending as-is');
|
|
73
|
+
return { buffer, mime };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Now process with sharp (resize + ensure JPEG output)
|
|
77
|
+
const sharp = await tryLoadSharp();
|
|
78
|
+
if (!sharp) {
|
|
79
|
+
return { buffer: workingBuffer, mime: workingMime };
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const image = sharp(workingBuffer);
|
|
83
|
+
const metadata = await image.metadata();
|
|
84
|
+
const { width, height } = metadata;
|
|
85
|
+
const needsResize = width && height && (width > MAX_DIMENSION || height > MAX_DIMENSION);
|
|
86
|
+
if (!needsResize) {
|
|
87
|
+
// Still convert to JPEG for consistency (unless already JPEG from HEIC conversion)
|
|
88
|
+
const outputBuffer = await image.jpeg({ quality: 85 }).toBuffer();
|
|
89
|
+
logger.log(`Converted image to JPEG: ${width}x${height} (${outputBuffer.length} bytes)`);
|
|
90
|
+
return { buffer: outputBuffer, mime: 'image/jpeg' };
|
|
91
|
+
}
|
|
92
|
+
// Resize and convert to JPEG
|
|
93
|
+
const outputBuffer = await image
|
|
94
|
+
.resize(MAX_DIMENSION, MAX_DIMENSION, {
|
|
95
|
+
fit: 'inside',
|
|
96
|
+
withoutEnlargement: true,
|
|
97
|
+
})
|
|
98
|
+
.jpeg({ quality: 85 })
|
|
99
|
+
.toBuffer();
|
|
100
|
+
logger.log(`Resized image: ${width}x${height} → max ${MAX_DIMENSION}px (${outputBuffer.length} bytes)`);
|
|
101
|
+
return { buffer: outputBuffer, mime: 'image/jpeg' };
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
logger.error('Failed to process image with sharp, using working buffer:', error);
|
|
105
|
+
return { buffer: workingBuffer, mime: workingMime };
|
|
106
|
+
}
|
|
107
|
+
}
|