/.nova
/.nova
/.vscode
/.vscode
/.zed
/.zed
discord-bot/node_modules
public function index(Request $request)
public function index(Request $request)
{
{
$messages = $this->formatMessages($request->input('history', []));
$messages = $this->formatMessages($request->input('history', []));
$mode = $request->input('mode', 'text');
return AIService::call($messages);
return AIService::call($messages, $mode);
}
}
private function formatMessages($history)
private function formatMessages($history)
<?php
namespace App\Http\Controllers;
use App\Models\VoiceCommand;
use App\Models\VoiceTranscript;
use Illuminate\Http\Request;
class VoiceController extends Controller
{
public function addTranscript(Request $request)
{
$request->validate([
'text' => 'required|string',
]);
VoiceTranscript::create($request->only([
'text',
'language',
'confidence',
'audio_duration_ms',
'started_at',
]));
return response()->json(['ok' => true]);
}
public function addCommand(Request $request)
{
$request->validate([
'trigger_type' => 'required|string',
'trigger_text' => 'required|string',
]);
VoiceCommand::create([
...$request->only(['trigger_type', 'trigger_text', 'context_text']),
'status' => 'pending',
]);
return response()->json(['ok' => true]);
}
public function completeCommand(Request $request)
{
$request->validate([
'trigger_text' => 'required|string',
'response_text' => 'required|string',
]);
VoiceCommand::where('trigger_text', $request->input('trigger_text'))
->where('status', 'pending')
->latest('id')
->first()
?->update([
'response_text' => $request->input('response_text'),
'status' => 'completed',
]);
return response()->json(['ok' => true]);
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VoiceCommand extends Model
{
public $timestamps = false;
protected $fillable = [
'trigger_type',
'trigger_text',
'context_text',
'response_text',
'status',
];
protected function casts(): array
{
return [
'created_at' => 'datetime',
];
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VoiceTranscript extends Model
{
public $timestamps = false;
protected $fillable = [
'text',
'language',
'confidence',
'audio_duration_ms',
'started_at',
];
protected function casts(): array
{
return [
'confidence' => 'float',
'audio_duration_ms' => 'integer',
'started_at' => 'datetime',
'created_at' => 'datetime',
];
}
}
Dont make information up. If you don't know the answer, say so. Don't try to guess or fabricate information.
Dont make information up. If you don't know the answer, say so. Don't try to guess or fabricate information.
The same goes for any idea's that i might give. be honest about the feasibility of the idea's and don't try to make them work if they are not feasible. If an idea is not feasible, explain why and suggest alternatives if possible.
The same goes for any idea's that i might give. be honest about the feasibility of the idea's and don't try to make them work if they are not feasible. If an idea is not feasible, explain why and suggest alternatives if possible.
Keep your awnser as short as possible while still providing all necessary information. Avoid long explanations and tangents. Focus on the core of the question and provide a clear and concise answer.
Keep your awnser as short as possible while still providing all necessary information. Avoid long explanations and tangents. Focus on the core of the question and provide a clear and concise answer.
The current date and time is: {{current_datetime}}
SYSTEM;
SYSTEM;
public static function call($messages)
private const VOICE_PROMPT = <<<VOICE
You are speaking to the user through voice in a call. Your response will be read aloud by a text-to-speech engine, so write exactly how you would naturally say it out loud.
Rules for voice responses:
- Talk like a real person in a casual conversation. Be natural, not robotic or formal.
- Never use markdown, bullet points, numbered lists, headings, bold, italic, code blocks, or any formatting.
- Never spell out URLs, file paths, or technical syntax — paraphrase them instead.
- Keep responses short. A few sentences is usually enough. If the answer is complex, give the key takeaway first and offer to go deeper.
- Use contractions and casual phrasing. Say "don't" not "do not", "it's" not "it is".
- Don't start with filler like "Sure!" or "Great question!". Just answer directly and to the point.
VOICE;
public static function call($messages, $mode = 'text')
{
{
$usedTools = [];
$usedTools = [];
return response()->stream(function () use ($messages, $usedTools) {
return response()->stream(function () use ($messages, $usedTools, $mode) {
self::callClaude($messages, $usedTools);
self::callClaude($messages, $usedTools, $mode);
}, 200, [
}, 200, [
'X-Accel-Buffering' => 'no',
'X-Accel-Buffering' => 'no',
'Cache-Control' => 'no-cache',
'Cache-Control' => 'no-cache',
]);
]);
}
}
private static function callClaude($messages, &$usedTools = [])
private static function callClaude($messages, &$usedTools = [], $mode = 'text')
{
{
$response = Http::withHeaders([
$response = Http::withHeaders([
'Content-Type' => 'application/json',
'Content-Type' => 'application/json',
'model' => self::MODEL,
'model' => self::MODEL,
'max_tokens' => 1024,
'max_tokens' => 1024,
'stream' => true,
'stream' => true,
'system' => self::SYSTEM_PROMPT,
'system' => self::buildSystemPrompt($mode),
'messages' => $messages,
'messages' => $messages,
'temperature' => 0.7,
'temperature' => 0.7,
'tools' => self::tools(),
'tools' => self::tools(),
$messages[] = ['role' => 'assistant', 'content' => $assistantContent];
$messages[] = ['role' => 'assistant', 'content' => $assistantContent];
$messages[] = ['role' => 'user', 'content' => $toolResults];
$messages[] = ['role' => 'user', 'content' => $toolResults];
self::callClaude($messages, $usedTools);
self::callClaude($messages, $usedTools, $mode);
}
}
}
}
return null;
return null;
}
}
private static function buildSystemPrompt($mode = 'text')
{
$prompt = self::SYSTEM_PROMPT;
$currentDateTime = now()->toDateTimeString();
$prompt = str_replace('{{current_datetime}}', $currentDateTime, $prompt);
if ($mode === 'voice') {
$prompt .= "\n\n" . self::VOICE_PROMPT;
}
return $prompt;
}
}
}
// Get a list of things i can work on based on events
// Get a list of things i can work on based on events
// Example:
// Example:
// User: "What are issues and prs that i can work on (in webinargeek) right now"
// User: "What are issues and prs that i can work on (in webinargeek) right now"
// TOOLS:
// - list_issues_and_prs: List issues and pull requests that are open and unassigned in the repositories the user has access to, with filters for labels, milestones, and assignees.
// - get_issue_or_pr_details: Get detailed information about a specific issue or pull (including comments, commits, and related issues/prs) to help the user understand the context and what needs to be done.
// - Manage code using a remote coding server (maybe invoke a sub agent?)
// - create_issue: Create a new issue in a specified repository with a title, description, and optional labels and assignees.
// - update_issue_or_pr: Update the title, description, labels, or assigne
];
];
public const TOOL_FUNCTION_MAP = [];
public const TOOL_FUNCTION_MAP = [];
// If unclear about what to store, ask the user for clarification:
// If unclear about what to store, ask the user for clarification:
// User: appretnly Al who loves hiking and cooking
// User: appretnly Al who loves hiking and cooking
// Is al a nick name for Alice? If so, should I link this new information to the existing memory about Alice?
// Is al a nick name for Alice? If so, should I link this new information to the existing memory about Alice?
// TOOLS:
// - store_memory: Store a memory with a key and value.
// - update_memory: Update a memory with a key and new value.
// - retrieve_memory: Retrieve a memory with a key.
];
];
public const TOOL_FUNCTION_MAP = [];
public const TOOL_FUNCTION_MAP = [];
// AI: [stores reminder: "call Bob" with time "tomorrow at 3pm"]
// AI: [stores reminder: "call Bob" with time "tomorrow at 3pm"]
// Then we have a cron job that checks for upcoming reminders and sends them to the user at the appropriate time.
// Then we have a cron job that checks for upcoming reminders and sends them to the user at the appropriate time.
// We could also have a tool that lists all upcoming reminders when the user asks.
// We could also have a tool that lists all upcoming reminders when the user asks.
// TOOLS:
// - store_reminder: Store a reminder with a title and optional time.
// - retrieve_reminders: Retrieve all reminders.
// - update_reminder: Update a reminder's title or time.
// - delete_reminder: Delete a reminder.
];
];
public const TOOL_FUNCTION_MAP = [];
public const TOOL_FUNCTION_MAP = [];
// Use case:
// Use case:
// Give me nice holiday destinations based on my preferences and book a hotel for me
// Give me nice holiday destinations based on my preferences and book a hotel for me
// Order a pizza for me
// Order a pizza for me
// TOOLS:
// - search_web: Search the web for information based on a query and return summarized results with links to the original sources.
// - submit_form: Fill out and submit a web form on behalf of the user,
// - get_web_content: Retrieve and summarize the content of a specific web page to help the user quickly understand the information without having to read through the entire page.
];
];
public const TOOL_FUNCTION_MAP = [];
public const TOOL_FUNCTION_MAP = [];
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('voice_transcripts', function (Blueprint $table) {
$table->id();
$table->text('text');
$table->string('language')->nullable();
$table->float('confidence')->nullable();
$table->integer('audio_duration_ms')->nullable();
$table->datetime('started_at');
$table->datetime('created_at')->default(DB::raw('CURRENT_TIMESTAMP'));
$table->index('session_id');
});
Schema::create('voice_commands', function (Blueprint $table) {
$table->id();
$table->string('trigger_type');
$table->text('trigger_text');
$table->text('context_text')->nullable();
$table->text('response_text')->nullable();
$table->string('status')->default('pending');
$table->datetime('created_at')->default(DB::raw('CURRENT_TIMESTAMP'));
});
}
public function down(): void
{
// No reversal — original tables had guild_id/channel_id columns
}
};
# Prime Discord Voice Bot
Voice assistant that sits in a Discord voice channel, transcribes speech, and responds to "Hey Prime" commands via AI.
## Prerequisites
- **Node.js** 22+
- **Laravel** backend running (the main svelte-test app)
- **Discord bot** created at https://discord.com/developers/applications
- **OpenAI API key** for Whisper (speech-to-text) and TTS (text-to-speech)
## Discord Bot Setup
1. Go to https://discord.com/developers/applications → **New Application**
2. **Bot** tab → **Reset Token** → copy the token
3. Enable under **Privileged Gateway Intents**:
- Server Members Intent
- Message Content Intent
4. **OAuth2** → **URL Generator** → select `bot` scope with permissions:
- Connect, Speak, Use Voice Activity
- Read Messages/View Channels, Send Messages, Embed Links
5. Open the generated URL → invite bot to your server
### Getting IDs
Enable **Developer Mode** in Discord (Settings → Advanced), then:
- Right-click your **server** → Copy Server ID → `DISCORD_GUILD_ID`
- Right-click a **voice channel** → Copy Channel ID → `DISCORD_VOICE_CHANNEL_ID`
- Right-click a **text channel** → Copy Channel ID → `DISCORD_TEXT_CHANNEL_ID`
## Installation
```bash
cd discord-bot
npm install --legacy-peer-deps
```
## Configuration
The bot reads from the main project `.env` file (the Laravel `.env` in the repo root). Add these variables there:
```env
DISCORD_TOKEN=your-bot-token
DISCORD_GUILD_ID=your-server-id
DISCORD_VOICE_CHANNEL_ID=your-voice-channel-id
DISCORD_TEXT_CHANNEL_ID=your-text-channel-id
OPENAI_API_KEY=sk-your-openai-key
LARAVEL_API_URL=http://127.0.0.1:8000
LARAVEL_AGENT_TOKEN=13November.2006
```
The bot also uses the `DB_*` variables from the same `.env` to connect to the shared database.
## Running
### 1. Start Laravel (in a separate terminal)
```bash
cd C:\Users\vanbr\svelte-test
php artisan serve
```
### 2. Start the bot
```bash
cd C:\Users\vanbr\svelte-test\discord-bot
node src/index.js
```
You should see:
```
Logged in as Prime#0179
Joined voice channel: General
Audio pipeline active — listening for speech
```
### 3. Talk to Prime
Join the same voice channel in Discord and say:
- **"Hey Prime, what is two plus two?"** — inline command
- **"Prime, summarize the last 5 minutes"** — deferred command (uses transcript context)
Prime responds with both **voice** (TTS in the voice channel) and **text** (in the configured text channel).
## Running with pm2 (persistent)
```bash
npm install -g pm2
pm2 start src/index.js --name prime-bot
pm2 save
```
To restart after changes:
```bash
pm2 restart prime-bot
```
View logs:
```bash
pm2 logs prime-bot
```
## Wake Words
| Pattern | Example |
|---------|---------|
| "Hey Prime, ..." | "Hey Prime, what time is it?" |
| "Hee Prime, ..." | "Hee Prime, hoe laat is het?" |
| "Prime, ..." (at sentence start) | "Prime, summarize the last 5 minutes" |
## Cost
- **Whisper STT**: ~$0.18/hour (30 min actual speech)
- **Claude API**: Free (via Anthropic OAuth token)
- **OpenAI TTS**: ~$0.01/hour
- **Total**: ~$0.19/hour
## Troubleshooting
| Problem | Fix |
|---------|-----|
| Bot won't join voice channel | Make sure `@snazzah/davey` is installed (`npm ls @snazzah/davey`) |
| No transcriptions | Check you're in the same voice channel as the bot |
| Wrong transcriptions | Whisper struggles with background noise — speak clearly |
| "ECONNREFUSED" errors | Laravel isn't running — start `php artisan serve` |
| "Unauthorized" from API | Check `LARAVEL_AGENT_TOKEN` matches `AGENT_TOKEN` in Laravel's `.env` |
{
"name": "prime-discord-bot",
"version": "1.0.0",
"description": "Discord voice assistant for Prime AI",
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test": "vitest run"
},
"dependencies": {
"@discordjs/voice": "^0.19.0",
"@snazzah/davey": "^0.1.10",
"discord.js": "^14.16",
"dotenv": "^16.4",
"ffmpeg-static": "^5.2",
"libsodium-wrappers": "^0.8.2",
"openai": "^4.73",
"opusscript": "^0.1",
"prism-media": "^1.3",
"tweetnacl": "^1.0.3",
"winston": "^3.14"
},
"devDependencies": {
"vitest": "^2.0"
}
}
// Extract minute count from deferred commands like "last 5 minutes"
const MINUTES_PATTERN = /(?:last|laatste)\s+(\d+)\s+min/i;
export class CommandParser {
constructor(transcriptBuffer) {
this.transcriptBuffer = transcriptBuffer;
}
assemble(detection, fullText) {
if (detection.type === 'deferred') {
return this._assembleDeferred(detection, fullText);
}
return this._assembleInline(detection, fullText);
}
_assembleInline(detection, fullText) {
// For inline commands, send just the command as user message
// Include a small amount of recent context so Prime knows what "that" or "this" refers to
const recentContext = this.transcriptBuffer.getRecentText(2); // Last 2 minutes for context
const history = [];
if (recentContext && recentContext.length > 0) {
history.push({
role: 'user',
text: `[Voice channel context - recent conversation transcript]\n${recentContext}`,
});
history.push({
role: 'assistant',
text: 'Understood, I have the conversation context.',
});
}
history.push({
role: 'user',
text: detection.command || fullText,
});
return {
type: 'inline',
history,
contextText: recentContext || null,
};
}
_assembleDeferred(detection, fullText) {
// For deferred commands, include more transcript context
const minutesMatch = detection.command.match(MINUTES_PATTERN);
const minutes = minutesMatch ? parseInt(minutesMatch[1], 10) : 10;
const contextText = this.transcriptBuffer.getRecentText(minutes);
const history = [
{
role: 'user',
text: `[Voice channel transcript - last ${minutes} minutes]\n${contextText}\n\n[End of transcript]\n\nRequest: ${detection.command}`,
},
];
return {
type: 'deferred',
history,
contextText,
};
}
}
const REQUEST_TIMEOUT_MS = 120_000;
export class PrimeClient {
constructor(apiUrl, agentToken) {
this.endpoint = `${apiUrl}/api/test`;
this.agentToken = agentToken;
}
async chat(history) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.agentToken}`,
'Accept': 'application/json',
},
body: JSON.stringify({ history, mode: 'voice' }),
signal: controller.signal,
});
console.log(`[PrimeClient] response status: ${response.status}`);
if (!response.ok) {
const body = await response.text();
throw new Error(`Prime API error ${response.status}: ${body}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
let usedTools = [];
let chunkCount = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
chunkCount++;
const lines = chunk.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const parsed = JSON.parse(line);
if (parsed.data) {
if (parsed.data.text_chunk) {
fullText += parsed.data.text_chunk;
}
if (parsed.data.used_tools?.length) {
usedTools = parsed.data.used_tools;
}
}
} catch {
console.warn(`[PrimeClient] unparseable line: ${line.substring(0, 200)}`);
}
}
}
console.log(`[PrimeClient] stream done: ${chunkCount} chunks, ${fullText.length} chars`);
return {
response: fullText,
used_tools: usedTools,
};
} catch (err) {
if (err.name === 'AbortError') {
throw new Error('Prime API request timed out');
}
throw err;
} finally {
clearTimeout(timeout);
}
}
}
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import path from 'path';
// Load from the main project .env (single source of truth)
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const laravelRoot = path.resolve(__dirname, '../..');
dotenv.config({ path: path.resolve(laravelRoot, '.env') });
const required = [
'DISCORD_TOKEN',
'DISCORD_GUILD_ID',
'DISCORD_VOICE_CHANNEL_ID',
'DISCORD_TEXT_CHANNEL_ID',
'OPENAI_API_KEY',
];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}
const config = {
discord: {
token: process.env.DISCORD_TOKEN,
guildId: process.env.DISCORD_GUILD_ID,
voiceChannelId: process.env.DISCORD_VOICE_CHANNEL_ID,
textChannelId: process.env.DISCORD_TEXT_CHANNEL_ID,
},
openai: {
apiKey: process.env.OPENAI_API_KEY,
},
laravel: {
apiUrl: process.env.LARAVEL_API_URL || 'http://localhost:8000',
agentToken: process.env.LARAVEL_AGENT_TOKEN || '',
},
audio: {
silenceThresholdMs: parseInt(process.env.SILENCE_THRESHOLD_MS || '1500', 10),
maxSegmentMs: parseInt(process.env.MAX_SEGMENT_MS || '30000', 10),
minSegmentMs: 500,
},
transcript: {
bufferMinutes: parseInt(process.env.TRANSCRIPT_BUFFER_MINUTES || '10', 10),
},
tts: {
voice: process.env.TTS_VOICE || 'onyx',
},
logLevel: process.env.LOG_LEVEL || 'info',
};
export default config;
// Wake word patterns
const STRONG_PATTERN = /\bh[ea]+[iy]?\s+prime\b/i; // "hey prime", "hee prime" (Dutch)
const SENTENCE_START_PATTERN = /^prime[\s,]/i; // "Prime, ..." at start of sentence
// Deferred command keywords — these indicate the user wants context from the transcript buffer
const DEFERRED_KEYWORDS = [
/\bsummar/i, // summarize, summary
/\bsamenva/i, // samenvatten, samenvatting (Dutch)
/\brecap\b/i,
/\blast\s+\d+\s+min/i, // "last 5 minutes"
/\blaatste\s+\d+\s+min/i, // "laatste 5 minuten" (Dutch)
/\bwat\s+(hebben|was|waren)\s+/i, // "wat hebben we besproken" (Dutch)
/\bwhat\s+(did|was|were|have)\s+/i,
];
export class WakeWordDetector {
detect(text) {
if (!text || text.trim().length === 0) return null;
const trimmed = text.trim();
let commandText = null;
// Check strong pattern first ("hey prime ...")
const strongMatch = trimmed.match(STRONG_PATTERN);
if (strongMatch) {
commandText = trimmed.substring(strongMatch.index + strongMatch[0].length).trim();
}
// Check sentence start ("Prime, ...")
if (!commandText) {
const startMatch = trimmed.match(SENTENCE_START_PATTERN);
if (startMatch) {
commandText = trimmed.substring(startMatch[0].length).trim();
}
}
if (commandText === null) return null;
// Remove leading punctuation/whitespace from command
commandText = commandText.replace(/^[,\s]+/, '').trim();
if (commandText.length === 0) {
// Just said "Hey Prime" with nothing after — treat as attention-getter
return {
type: 'inline',
command: '',
fullMatch: trimmed,
};
}
// Classify as inline or deferred
const isDeferred = DEFERRED_KEYWORDS.some((pattern) => pattern.test(commandText));
return {
type: isDeferred ? 'deferred' : 'inline',
command: commandText,
fullMatch: trimmed,
};
}
}
import { Client, Events, GatewayIntentBits } from 'discord.js';
import { AudioBuffer } from './voice/AudioBuffer.js';
import { AudioReceiver } from './voice/AudioReceiver.js';
import { CommandParser } from './ai/CommandParser.js';
import { DiscordResponder } from './response/DiscordResponder.js';
import { PrimeClient } from './ai/PrimeClient.js';
import { TTSEngine } from './response/TTSEngine.js';
import { ThinkingIndicator } from './response/ThinkingIndicator.js';
import { TranscriptBuffer } from './transcription/TranscriptBuffer.js';
import { TranscriptStore } from './transcription/TranscriptStore.js';
import { VoiceManager } from './voice/VoiceManager.js';
import { WakeWordDetector } from './detection/WakeWordDetector.js';
import { WhisperClient } from './transcription/WhisperClient.js';
import config from './config.js';
// Load libsodium before @discordjs/voice discovers encryption libs
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const sodium = require('libsodium-wrappers');
await sodium.ready;
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
// Initialize components
const transcriptStore = new TranscriptStore();
const transcriptBuffer = new TranscriptBuffer(config.transcript.bufferMinutes);
const whisperClient = new WhisperClient(config.openai.apiKey);
const wakeWordDetector = new WakeWordDetector();
const commandParser = new CommandParser(transcriptBuffer);
const primeClient = new PrimeClient(config.laravel.apiUrl, config.laravel.agentToken);
const ttsEngine = new TTSEngine(config.openai.apiKey, config.tts.voice);
let voiceManager = null;
let audioReceiver = null;
let responder = null;
// Conversation mode: after a wake word, keep listening for follow-ups without requiring wake word
const CONVERSATION_TIMEOUT_MS = 30_000; // 30 seconds of follow-up window
let conversationActive = false;
let conversationTimer = null;
let conversationHistory = []; // Maintain multi-turn history during conversation
client.once(Events.ClientReady, async (readyClient) => {
const guild = readyClient.guilds.cache.get(config.discord.guildId);
if (!guild) {
process.exit(1);
}
const voiceChannel = guild.channels.cache.get(config.discord.voiceChannelId);
if (!voiceChannel) {
process.exit(1);
}
const textChannel = guild.channels.cache.get(config.discord.textChannelId);
if (!textChannel) {
process.exit(1);
}
responder = new DiscordResponder(textChannel);
voiceManager = new VoiceManager(voiceChannel);
const connection = await voiceManager.join();
audioReceiver = new AudioReceiver(connection);
const audioBuffer = new AudioBuffer(config.audio);
// Wire up the audio pipeline
audioReceiver.on('audio', (data) => {
audioBuffer.push(data);
});
audioBuffer.on('segment', async (wavBuffer, durationMs) => {
await handleAudioSegment(wavBuffer, durationMs);
});
audioReceiver.start();
});
async function handleAudioSegment(wavBuffer, durationMs) {
try {
const result = await whisperClient.transcribe(wavBuffer);
if (!result || !result.text || result.text.trim().length === 0) {
return;
}
const text = result.text.trim();
const language = result.language || null;
console.log(`[transcribe] "${text}" (lang=${language})`);
// Store in DB and in-memory buffer
await transcriptStore.addTranscript({
text,
language,
confidence: result.confidence || null,
audioDurationMs: durationMs,
startedAt: new Date().toISOString(),
});
transcriptBuffer.add(text, language);
// Check for wake word
const detection = wakeWordDetector.detect(text);
if (detection) {
console.log(`[wake-word] detected: type=${detection.type}, command="${detection.command}"`);
await handleCommand(detection, text);
} else if (conversationActive) {
console.log(`[conversation] follow-up: "${text}"`);
const followUp = { type: 'inline', command: text, fullMatch: text };
await handleCommand(followUp, text);
}
} catch (err) {
console.error('[handleAudioSegment] error:', err);
}
}
async function handleCommand(detection, fullText) {
const thinking = new ThinkingIndicator(responder.textChannel, voiceManager);
try {
// Start thinking indicator (chime + typing) immediately
await thinking.start();
let history;
if (conversationActive && conversationHistory.length > 0) {
// Continue existing conversation — append the new user message
history = [
...conversationHistory,
{ role: 'user', text: detection.command || fullText },
];
} else {
// New conversation — use CommandParser for context assembly
const context = commandParser.assemble(detection, fullText);
history = context.history;
await transcriptStore.addCommand({
triggerType: context.type,
triggerText: fullText,
contextText: context.contextText || null,
});
}
console.log(`[handleCommand] calling Prime API with ${history.length} messages`);
const result = await primeClient.chat(history);
// Stop thinking indicator before responding
thinking.stop();
console.log(`[handleCommand] result: response=${result?.response?.length ?? 0} chars, tools=${result?.used_tools?.join(', ') || 'none'}`);
if (!result || !result.response) {
console.warn('[handleCommand] empty response from Prime API');
return;
}
// Update conversation history for multi-turn
conversationHistory = [
...history,
{ role: 'assistant', text: result.response },
];
// Update command status
await transcriptStore.updateCommandResponse(fullText, result.response);
// Respond via TTS + text channel — wait for it to finish before starting conversation timer
await responder.respond(result.response, ttsEngine, voiceManager);
// Start/reset conversation mode AFTER TTS finishes, so the user has 30s from hearing the response
startConversationMode();
} catch (err) {
console.error('[handleCommand] error:', err);
thinking.stop();
}
}
function startConversationMode() {
conversationActive = true;
if (conversationTimer) {
clearTimeout(conversationTimer);
}
conversationTimer = setTimeout(() => {
conversationActive = false;
conversationHistory = [];
conversationTimer = null;
}, CONVERSATION_TIMEOUT_MS);
}
// Graceful shutdown
async function shutdown() {
if (voiceManager) {
voiceManager.leave();
}
client.destroy();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('unhandledRejection', (err) => {
console.error('[unhandledRejection]', err);
});
client.login(config.discord.token);
import { Readable } from 'stream';
export class DiscordResponder {
constructor(textChannel) {
this.textChannel = textChannel;
this.responseQueue = [];
this.isProcessing = false;
}
async respond(text, ttsEngine, voiceManager) {
this.responseQueue.push({ text, ttsEngine, voiceManager });
if (!this.isProcessing) {
await this._processQueue();
}
}
async _processQueue() {
this.isProcessing = true;
while (this.responseQueue.length > 0) {
const { text, ttsEngine, voiceManager } = this.responseQueue.shift();
try {
await this._sendResponse(text, ttsEngine, voiceManager);
} catch (err) {
}
}
this.isProcessing = false;
}
async _sendResponse(text, ttsEngine, voiceManager) {
// Send text to text channel and TTS to voice channel in parallel
const results = await Promise.allSettled([
this._sendTextMessage(text),
this._playTTS(text, ttsEngine, voiceManager),
]);
for (const result of results) {
if (result.status === 'rejected') {
}
}
}
async _sendTextMessage(text) {
try {
// Discord message limit is 2000 chars
if (text.length <= 2000) {
await this.textChannel.send(text);
} else {
const chunks = [];
for (let i = 0; i < text.length; i += 2000) {
chunks.push(text.substring(i, i + 2000));
}
for (const chunk of chunks) {
await this.textChannel.send(chunk);
}
}
} catch (err) {
}
}
_sanitizeForSpeech(text) {
return text
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`([^`]+)`/g, '$1') // Unwrap inline code
.replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold
.replace(/\*([^*]+)\*/g, '$1') // Remove italic
.replace(/__([^_]+)__/g, '$1') // Remove underline bold
.replace(/_([^_]+)_/g, '$1') // Remove underline italic
.replace(/~~([^~]+)~~/g, '$1') // Remove strikethrough
.replace(/^#{1,6}\s+/gm, '') // Remove heading markers
.replace(/^[-*+]\s+/gm, '') // Remove list markers
.replace(/^\d+\.\s+/gm, '') // Remove numbered list markers
.replace(/!?\[([^\]]*)\]\([^)]+\)/g, '$1') // Remove links/images, keep label
.replace(/\n{2,}/g, '\n') // Collapse multiple newlines
.trim();
}
async _playTTS(text, ttsEngine, voiceManager) {
try {
const speechText = this._sanitizeForSpeech(text);
const audioBuffer = await ttsEngine.synthesize(speechText);
if (!audioBuffer) {
return;
}
const stream = Readable.from(audioBuffer);
await voiceManager.playAudio(stream);
} catch (err) {
}
}
}
import OpenAI from 'openai';
export class TTSEngine {
constructor(apiKey, voice = 'onyx') {
this.openai = new OpenAI({ apiKey });
this.voice = voice;
}
async synthesize(text) {
if (!text || text.trim().length === 0) {
return null;
}
// TTS has a 4096 character limit — truncate if needed
const input = text.length > 4000 ? text.substring(0, 4000) + '...' : text;
const response = await this.openai.audio.speech.create({
model: 'tts-1-hd',
voice: this.voice,
input,
response_format: 'opus', // Native Discord format — no transcoding needed
});
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return buffer;
}
}
import { dirname, join } from 'path';
import { Readable } from 'stream';
import { fileURLToPath } from 'url';
import { readFileSync } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const CHIME_PATH = join(__dirname, '../../data/thinking-chime.ogg');
let chimeBuffer = null;
function getChimeBuffer() {
if (!chimeBuffer) {
try {
chimeBuffer = readFileSync(CHIME_PATH);
} catch (err) {
}
}
return chimeBuffer;
}
export class ThinkingIndicator {
constructor(textChannel, voiceManager) {
this.textChannel = textChannel;
this.voiceManager = voiceManager;
this.typingInterval = null;
}
/**
* Start the thinking indicator:
* - Play a short chime in the voice channel
* - Start typing indicator in the text channel
*/
async start() {
// Start typing indicator (repeats every 8s, Discord typing lasts ~10s)
this._startTyping();
// Play chime in voice channel
await this._playChime();
}
/**
* Stop the thinking indicator.
*/
stop() {
if (this.typingInterval) {
clearInterval(this.typingInterval);
this.typingInterval = null;
}
}
_startTyping() {
// Send typing indicator immediately, then repeat every 8 seconds
this.textChannel.sendTyping().catch(() => {});
this.typingInterval = setInterval(() => {
this.textChannel.sendTyping().catch(() => {});
}, 8_000);
}
async _playChime() {
const buf = getChimeBuffer();
if (!buf) return;
try {
const stream = Readable.from(buf);
await this.voiceManager.playAudio(stream);
} catch (err) {
}
}
}
export class TranscriptBuffer {
constructor(bufferMinutes = 10) {
this.bufferMinutes = bufferMinutes;
this.entries = [];
}
add(text, language = null) {
this.entries.push({
text,
language,
timestamp: Date.now(),
});
this._prune();
}
getRecent(minutes = null) {
const lookbackMs = (minutes || this.bufferMinutes) * 60 * 1000;
const cutoff = Date.now() - lookbackMs;
return this.entries.filter((e) => e.timestamp >= cutoff);
}
getRecentText(minutes = null) {
return this.getRecent(minutes)
.map((e) => e.text)
.join(' ');
}
_prune() {
const cutoff = Date.now() - this.bufferMinutes * 60 * 1000;
this.entries = this.entries.filter((e) => e.timestamp >= cutoff);
}
get length() {
return this.entries.length;
}
clear() {
this.entries = [];
}
}
import config from '../config.js';
export class TranscriptStore {
constructor() {
this.baseUrl = `${config.laravel.apiUrl}/api/voice`;
this.token = config.laravel.agentToken;
}
async _post(path, body = {}) {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`,
'Accept': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Voice API error ${res.status} on ${path}: ${text}`);
}
return res.json();
}
async _put(path, body = {}) {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`,
'Accept': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Voice API error ${res.status} on ${path}: ${text}`);
}
return res.json();
}
async addTranscript({ text, language, confidence, audioDurationMs, startedAt }) {
await this._post('/transcripts', {
text,
language,
confidence,
audio_duration_ms: audioDurationMs,
started_at: startedAt,
});
}
async addCommand({ triggerType, triggerText, contextText }) {
await this._post('/commands', {
trigger_type: triggerType,
trigger_text: triggerText,
context_text: contextText,
});
}
async updateCommandResponse(triggerText, responseText) {
await this._put('/commands/complete', {
trigger_text: triggerText,
response_text: responseText,
});
}
close() {
// No-op — no local connections to close
}
}
import OpenAI from 'openai';
const MAX_CONCURRENT = 5;
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1000;
// Prompt biases Whisper toward recognizing "Prime" as the wake word.
// Keep it minimal — extra words leak into transcriptions as hallucinations.
const WHISPER_PROMPT = 'Hey Prime,';
// Errors that should not be retried
const NON_RETRYABLE_CODES = ['audio_too_short', 'audio_too_long'];
// Known Whisper hallucinations on silent/noisy audio
const HALLUCINATION_PATTERNS = [
/^\.{2,}$/, // "..." or "......"
/untertitel/i, // "Untertitel der Amara.org-Community"
/amara\.org/i,
/^(he[,.\s]*)+$/i, // "He, He, He, He" repeated
/^(pr+[aeiou]*[,.\s]*)+$/i, // "Prrrr" noise
/^.{0,3}$/, // 3 chars or less
/ondertiteling/i, // Dutch subtitle hallucination
/sous-titres/i, // French subtitle hallucination
/copyright/i,
/^(uh+[,.\s]*)+$/i, // "Uh, uh, uh"
/^(um+[,.\s]*)+$/i, // "Um, um, um"
/blogspot\.com/i, // URL hallucinations
/^(slurp[,.\s]*)+$/i, // "Slurp slurp slurp"
/outtakes/i,
];
// Detect when Whisper echoes the prompt back (repeating the same phrase 3+ times)
function isPromptEcho(text) {
// Split into sentences and check for 3+ near-identical repetitions
const parts = text.split(/[.!?]+/).map(s => s.trim().toLowerCase()).filter(s => s.length > 2);
if (parts.length >= 3) {
const first = parts[0];
const matches = parts.filter(p => p === first).length;
if (matches >= 3) return true;
}
return false;
}
// Minimum no_speech probability — segments above this are likely noise
const NO_SPEECH_THRESHOLD = 0.8;
export class WhisperClient {
constructor(apiKey) {
this.openai = new OpenAI({ apiKey });
this.activeRequests = 0;
this.queue = [];
}
async transcribe(wavBuffer) {
return new Promise((resolve, reject) => {
const task = { wavBuffer, resolve, reject, retries: 0 };
this.queue.push(task);
this._processQueue();
});
}
_processQueue() {
while (this.activeRequests < MAX_CONCURRENT && this.queue.length > 0) {
const task = this.queue.shift();
this.activeRequests++;
this._executeTask(task).finally(() => {
this.activeRequests--;
this._processQueue();
});
}
}
async _executeTask(task) {
try {
const result = await this._callWhisper(task.wavBuffer);
task.resolve(result);
} catch (err) {
if (err.code && NON_RETRYABLE_CODES.includes(err.code)) {
task.resolve(null);
return;
}
if (task.retries < MAX_RETRIES) {
task.retries++;
const delay = BASE_DELAY_MS * Math.pow(2, task.retries - 1);
await new Promise((r) => setTimeout(r, delay));
this.queue.unshift(task);
this._processQueue();
} else {
task.resolve(null);
}
}
}
async _callWhisper(wavBuffer) {
const file = new File([wavBuffer], 'audio.wav', { type: 'audio/wav' });
const response = await this.openai.audio.transcriptions.create({
model: 'whisper-1',
file,
response_format: 'verbose_json',
prompt: WHISPER_PROMPT,
});
if (!response || !response.text) {
return null;
}
const text = response.text.trim();
// Filter hallucinations and prompt echoes
if (this._isHallucination(text) || isPromptEcho(text)) {
return null;
}
// Check no_speech_probability on segments
const segments = response.segments || [];
if (segments.length > 0) {
const avgNoSpeech = segments.reduce((sum, s) => sum + (s.no_speech_prob || 0), 0) / segments.length;
if (avgNoSpeech > NO_SPEECH_THRESHOLD) {
return null;
}
}
return {
text,
language: response.language || null,
confidence: segments[0]?.avg_logprob
? Math.exp(segments[0].avg_logprob)
: null,
};
}
_isHallucination(text) {
return HALLUCINATION_PATTERNS.some((pattern) => pattern.test(text));
}
}
import { EventEmitter } from 'events';
import ffmpegPath from 'ffmpeg-static';
import { spawn } from 'child_process';
// Silence detection: an Opus frame of ~3 bytes or less is silence
const SILENCE_FRAME_THRESHOLD = 3;
export class AudioBuffer extends EventEmitter {
constructor(audioConfig) {
super();
this.silenceThresholdMs = audioConfig.silenceThresholdMs;
this.maxSegmentMs = audioConfig.maxSegmentMs;
this.minSegmentMs = audioConfig.minSegmentMs;
this.opusPackets = [];
this.segmentStartTime = null;
this.lastAudioTime = null;
this.silenceTimer = null;
this.hasSpeech = false;
}
push(opusPacket) {
const now = Date.now();
const isSilence = opusPacket.length <= SILENCE_FRAME_THRESHOLD;
if (!isSilence) {
this.hasSpeech = true;
this.lastAudioTime = now;
if (!this.segmentStartTime) {
this.segmentStartTime = now;
}
// Store raw Opus packet — let ffmpeg decode later
this.opusPackets.push(Buffer.from(opusPacket));
this._resetSilenceTimer();
const segmentDuration = now - this.segmentStartTime;
if (segmentDuration >= this.maxSegmentMs) {
this._flush();
}
} else if (this.hasSpeech) {
this._resetSilenceTimer();
}
}
_resetSilenceTimer() {
if (this.silenceTimer) {
clearTimeout(this.silenceTimer);
}
this.silenceTimer = setTimeout(() => {
if (this.hasSpeech) {
this._flush();
}
}, this.silenceThresholdMs);
}
_flush() {
if (this.silenceTimer) {
clearTimeout(this.silenceTimer);
this.silenceTimer = null;
}
if (this.opusPackets.length === 0) {
this._reset();
return;
}
const durationMs = this.segmentStartTime ? Date.now() - this.segmentStartTime : 0;
if (durationMs < this.minSegmentMs) {
this._reset();
return;
}
const packets = this.opusPackets;
this._reset();
// Convert raw Opus packets → WAV using ffmpeg with Opus decoding
this._opusToWav(packets).then((wavBuffer) => {
if (wavBuffer && wavBuffer.length > 44) {
this.emit('segment', wavBuffer, durationMs);
}
}).catch((err) => {
});
}
_reset() {
this.opusPackets = [];
this.segmentStartTime = null;
this.lastAudioTime = null;
this.hasSpeech = false;
}
_opusToWav(opusPackets) {
return new Promise((resolve, reject) => {
// Build an OggS container with Opus data so ffmpeg can decode it.
// Each Discord Opus packet is a single Opus frame (20ms at 48kHz).
const oggBuffer = this._buildOggOpus(opusPackets);
const ffmpeg = spawn(ffmpegPath, [
'-f', 'ogg', // Input: Ogg/Opus container
'-i', 'pipe:0',
'-ar', '16000', // Whisper expects 16kHz
'-ac', '1', // Mono
'-f', 'wav',
'pipe:1',
], {
stdio: ['pipe', 'pipe', 'pipe'],
});
const chunks = [];
ffmpeg.stdout.on('data', (chunk) => chunks.push(chunk));
ffmpeg.stderr.on('data', () => {});
ffmpeg.on('close', (code) => {
if (code === 0) {
resolve(Buffer.concat(chunks));
} else {
reject(new Error(`ffmpeg exited with code ${code}`));
}
});
ffmpeg.on('error', reject);
ffmpeg.stdin.write(oggBuffer);
ffmpeg.stdin.end();
});
}
/**
* Build a minimal Ogg/Opus container from raw Opus packets.
* Ogg format: https://www.xiph.org/ogg/doc/framing.html
* Opus in Ogg: https://tools.ietf.org/html/rfc7845
*/
_buildOggOpus(opusPackets) {
const pages = [];
let granulePos = 0n;
const serialNo = 0x50524D45; // "PRME"
let pageSeq = 0;
// Page 1: OpusHead header
const opusHead = Buffer.alloc(19);
opusHead.write('OpusHead', 0); // Magic
opusHead.writeUInt8(1, 8); // Version
opusHead.writeUInt8(2, 9); // Channel count (stereo)
opusHead.writeUInt16LE(0, 10); // Pre-skip
opusHead.writeUInt32LE(48000, 12); // Sample rate
opusHead.writeUInt16LE(0, 16); // Output gain
opusHead.writeUInt8(0, 18); // Channel mapping family
pages.push(this._buildOggPage(opusHead, serialNo, pageSeq++, 0n, 0x02)); // BOS flag
// Page 2: OpusTags header
const vendor = 'prime-bot';
const tagsSize = 8 + 4 + vendor.length + 4;
const opusTags = Buffer.alloc(tagsSize);
opusTags.write('OpusTags', 0);
opusTags.writeUInt32LE(vendor.length, 8);
opusTags.write(vendor, 12);
opusTags.writeUInt32LE(0, 12 + vendor.length); // No user comments
pages.push(this._buildOggPage(opusTags, serialNo, pageSeq++, 0n, 0x00));
// Data pages: each Opus packet becomes a segment in an Ogg page
// Group up to 255 packets per page (Ogg max segments)
const PACKETS_PER_PAGE = 50;
for (let i = 0; i < opusPackets.length; i += PACKETS_PER_PAGE) {
const batch = opusPackets.slice(i, i + PACKETS_PER_PAGE);
const isLast = (i + PACKETS_PER_PAGE >= opusPackets.length);
// Each packet is 20ms = 960 samples at 48kHz
for (const pkt of batch) {
granulePos += 960n;
}
pages.push(this._buildOggPageMulti(batch, serialNo, pageSeq++, granulePos, isLast ? 0x04 : 0x00));
}
return Buffer.concat(pages);
}
_buildOggPage(data, serialNo, pageSeq, granulePos, flags) {
return this._buildOggPageMulti([data], serialNo, pageSeq, granulePos, flags);
}
_buildOggPageMulti(segments, serialNo, pageSeq, granulePos, flags) {
const numSegments = segments.length;
const segTable = Buffer.alloc(numSegments);
let totalDataLen = 0;
for (let i = 0; i < numSegments; i++) {
// For simplicity, each segment must be < 255 bytes for single-byte lacing.
// If larger, we need multi-byte lacing.
const len = segments[i].length;
if (len < 255) {
segTable[i] = len;
} else {
// For segments >= 255 bytes, we need proper lacing
// Build proper segment table
return this._buildOggPageLaced(segments, serialNo, pageSeq, granulePos, flags);
}
totalDataLen += len;
}
// 27 byte header + segment table + data
const header = Buffer.alloc(27);
header.write('OggS', 0); // Capture pattern
header.writeUInt8(0, 4); // Version
header.writeUInt8(flags, 5); // Header type flags
header.writeBigUInt64LE(granulePos, 6); // Granule position
header.writeUInt32LE(serialNo, 14); // Serial number
header.writeUInt32LE(pageSeq, 18); // Page sequence
header.writeUInt32LE(0, 22); // CRC (filled later)
header.writeUInt8(numSegments, 26); // Number of segments
const dataBuffer = Buffer.concat(segments);
const page = Buffer.concat([header, segTable, dataBuffer]);
// Calculate CRC32
const crc = this._oggCRC(page);
page.writeUInt32LE(crc, 22);
return page;
}
_buildOggPageLaced(segments, serialNo, pageSeq, granulePos, flags) {
// Build proper segment table with lacing for segments >= 255 bytes
const segTableParts = [];
let totalDataLen = 0;
for (const seg of segments) {
let remaining = seg.length;
while (remaining >= 255) {
segTableParts.push(255);
remaining -= 255;
}
segTableParts.push(remaining);
totalDataLen += seg.length;
}
const numSegments = segTableParts.length;
const segTable = Buffer.from(segTableParts);
const header = Buffer.alloc(27);
header.write('OggS', 0);
header.writeUInt8(0, 4);
header.writeUInt8(flags, 5);
header.writeBigUInt64LE(granulePos, 6);
header.writeUInt32LE(serialNo, 14);
header.writeUInt32LE(pageSeq, 18);
header.writeUInt32LE(0, 22);
header.writeUInt8(numSegments, 26);
const dataBuffer = Buffer.concat(segments);
const page = Buffer.concat([header, segTable, dataBuffer]);
const crc = this._oggCRC(page);
page.writeUInt32LE(crc, 22);
return page;
}
_oggCRC(data) {
// OggS uses CRC-32 with polynomial 0x04C11DB7 (no reflection)
let crc = 0;
for (let i = 0; i < data.length; i++) {
crc = ((crc << 8) ^ OGG_CRC_TABLE[((crc >>> 24) & 0xFF) ^ data[i]]) >>> 0;
}
return crc;
}
}
// Precompute OggS CRC table
const OGG_CRC_TABLE = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let r = i << 24;
for (let j = 0; j < 8; j++) {
r = (r & 0x80000000) ? ((r << 1) ^ 0x04C11DB7) : (r << 1);
r = r >>> 0;
}
OGG_CRC_TABLE[i] = r;
}
import { EndBehaviorType } from '@discordjs/voice';
import { EventEmitter } from 'events';
export class AudioReceiver extends EventEmitter {
constructor(connection) {
super();
this.connection = connection;
this.receiver = connection.receiver;
this.activeStreams = new Map();
}
start() {
// Listen for any user speaking in the channel
this.receiver.speaking.on('start', (userId) => {
if (this.activeStreams.has(userId)) return;
const opusStream = this.receiver.subscribe(userId, {
end: {
behavior: EndBehaviorType.AfterSilence,
duration: 100, // Short — AudioBuffer handles actual segmentation
},
});
this.activeStreams.set(userId, opusStream);
opusStream.on('data', (packet) => {
this.emit('audio', packet);
});
opusStream.on('end', () => {
this.activeStreams.delete(userId);
});
opusStream.on('error', (err) => {
this.activeStreams.delete(userId);
});
});
}
stop() {
for (const [userId, stream] of this.activeStreams) {
stream.destroy();
}
this.activeStreams.clear();
}
}
import { AudioPlayerStatus, StreamType, VoiceConnectionStatus, createAudioPlayer, createAudioResource, entersState, joinVoiceChannel } from '@discordjs/voice';
export class VoiceManager {
constructor(voiceChannel) {
this.voiceChannel = voiceChannel;
this.connection = null;
this.player = createAudioPlayer();
this.isReady = false;
}
async join() {
this.isReady = false;
this.connection = joinVoiceChannel({
channelId: this.voiceChannel.id,
guildId: this.voiceChannel.guild.id,
adapterCreator: this.voiceChannel.guild.voiceAdapterCreator,
selfDeaf: false,
selfMute: false,
});
// Only handle disconnects after initial connection succeeds
this.connection.on(VoiceConnectionStatus.Disconnected, async () => {
if (!this.isReady) return; // Don't interfere with initial connection
await Promise.race([
entersState(this.connection, VoiceConnectionStatus.Signalling, 5_000),
entersState(this.connection, VoiceConnectionStatus.Connecting, 5_000),
]);
});
this.connection.on(VoiceConnectionStatus.Destroyed, () => {
this.isReady = false;
});
await entersState(this.connection, VoiceConnectionStatus.Ready, 30_000);
this.isReady = true;
this.connection.subscribe(this.player);
return this.connection;
}
async playAudio(audioBuffer) {
return new Promise((resolve, reject) => {
const resource = createAudioResource(audioBuffer, {
inputType: StreamType.OggOpus,
});
this.player.play(resource);
this.player.once(AudioPlayerStatus.Idle, resolve);
this.player.once('error', reject);
});
}
leave() {
if (this.connection) {
this.connection.destroy();
this.connection = null;
}
}
}
use Illuminate\Http\Request;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AiController;
use App\Http\Controllers\AiController;
use App\Http\Controllers\VoiceController;
Route::post('/test', [AiController::class, 'index']);
Route::post('/test', [AiController::class, 'index']);
Route::post('/voice/transcripts', [VoiceController::class, 'addTranscript']);
Route::post('/voice/commands', [VoiceController::class, 'addCommand']);
Route::put('/voice/commands/complete', [VoiceController::class, 'completeCommand']);