feat: joel voice

This commit is contained in:
eric
2026-02-05 14:07:58 +01:00
parent 172c5decd3
commit 24b4c12e7d
19 changed files with 627 additions and 18 deletions

View File

@@ -12,6 +12,7 @@ import { personalities, botOptions } from "../../database/schema";
import { eq } from "drizzle-orm";
import { buildStyledPrompt, STYLE_MODIFIERS } from "./personalities";
import { getRandomMention } from "./mentions";
import { speakVoiceover } from "./voice";
import { TypingIndicator } from "./typing";
const logger = createLogger("Features:Joel");
@@ -115,6 +116,9 @@ export const joelResponder = {
const fullResponse = response + mention;
await this.sendResponse(message, fullResponse);
speakVoiceover(message, fullResponse).catch((error) => {
logger.error("Failed to play voiceover", error);
});
} catch (error) {
logger.error("Failed to respond", error);
await this.handleError(message, error);

168
src/features/joel/voice.ts Normal file
View File

@@ -0,0 +1,168 @@
/**
* Voiceover playback for Joel responses
*/
import {
AudioPlayerStatus,
VoiceConnectionStatus,
createAudioPlayer,
createAudioResource,
entersState,
getVoiceConnection,
joinVoiceChannel,
StreamType,
type DiscordGatewayAdapterCreator,
} from "@discordjs/voice";
import type { Message } from "discord.js";
import { Readable } from "node:stream";
import { config } from "../../core/config";
import { createLogger } from "../../core/logger";
import { getVoiceoverService } from "../../services/ai/voiceover";
const logger = createLogger("Features:Joel:Voice");
const MAX_VOICE_TEXT_LENGTH = 800;
const PLAYBACK_TIMEOUT_MS = 60_000;
const READY_TIMEOUT_MS = 15_000;
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}
function sanitizeForVoiceover(content: string): string {
let text = content.replace(/```[\s\S]*?```/g, " ");
text = text.replace(/`([^`]+)`/g, "$1");
text = text.replace(/\s+/g, " ").trim();
if (text.length > MAX_VOICE_TEXT_LENGTH) {
text = `${text.slice(0, MAX_VOICE_TEXT_LENGTH - 3).trimEnd()}...`;
}
return text;
}
async function getOrCreateConnection(message: Message<true>) {
const voiceChannel = message.member?.voice.channel;
if (!voiceChannel) {
logger.debug("No voice channel for author", {
userId: message.author.id,
guildId: message.guildId,
});
return null;
}
const me = message.guild.members.me ?? (await message.guild.members.fetchMe());
const permissions = voiceChannel.permissionsFor(me);
if (!permissions?.has(["Connect", "Speak"])) {
logger.debug("Missing voice permissions", {
guildId: message.guildId,
channelId: voiceChannel.id,
});
return null;
}
const existing = getVoiceConnection(message.guildId);
if (existing && existing.joinConfig.channelId === voiceChannel.id) {
logger.debug("Reusing existing voice connection", {
guildId: message.guildId,
channelId: voiceChannel.id,
});
return existing;
}
if (existing) {
existing.destroy();
}
logger.debug("Joining voice channel", {
guildId: message.guildId,
channelId: voiceChannel.id,
});
const connection = joinVoiceChannel({
channelId: voiceChannel.id,
guildId: voiceChannel.guild.id,
adapterCreator: voiceChannel.guild.voiceAdapterCreator as unknown as DiscordGatewayAdapterCreator,
selfDeaf: false,
});
try {
await entersState(connection, VoiceConnectionStatus.Ready, READY_TIMEOUT_MS);
logger.debug("Voice connection ready", {
guildId: message.guildId,
channelId: voiceChannel.id,
});
return connection;
} catch (error) {
if (isAbortError(error)) {
logger.debug("Voice connection ready timeout", {
guildId: message.guildId,
channelId: voiceChannel.id,
status: connection.state.status,
});
} else {
logger.error("Voice connection failed to become ready", error);
}
connection.destroy();
return null;
}
}
export async function speakVoiceover(message: Message<true>, content: string): Promise<void> {
if (!config.elevenlabs.apiKey || !config.elevenlabs.voiceId) {
logger.debug("Voiceover disabled (missing config)");
return;
}
const text = sanitizeForVoiceover(content);
if (!text) {
logger.debug("Voiceover skipped (empty text after sanitize)");
return;
}
const connection = await getOrCreateConnection(message);
if (!connection) {
logger.debug("Voiceover skipped (no connection)", {
guildId: message.guildId,
authorId: message.author.id,
});
return;
}
try {
const voiceover = getVoiceoverService();
logger.debug("Requesting ElevenLabs voiceover", { textLength: text.length });
const audio = await voiceover.generate({ text });
logger.debug("Voiceover audio received", { bytes: audio.length });
const player = createAudioPlayer();
const resource = createAudioResource(Readable.from(audio), {
inputType: StreamType.Arbitrary,
});
player.on("error", (error) => {
logger.error("Audio player error", error);
});
player.on(AudioPlayerStatus.Playing, () => {
logger.debug("Audio player started", { guildId: message.guildId });
});
player.on(AudioPlayerStatus.Idle, () => {
logger.debug("Audio player idle", { guildId: message.guildId });
});
connection.subscribe(player);
player.play(resource);
await entersState(player, AudioPlayerStatus.Playing, 5_000).catch(() => undefined);
await entersState(player, AudioPlayerStatus.Idle, PLAYBACK_TIMEOUT_MS);
} catch (error) {
if (!isAbortError(error)) {
logger.error("Voiceover playback failed", error);
}
} finally {
if (connection.state.status !== VoiceConnectionStatus.Destroyed) {
connection.destroy();
}
}
}