feat: joel voice
This commit is contained in:
@@ -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
168
src/features/joel/voice.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user