From 24b4c12e7d4b1d8558b95b7f6710d5ea58418bca Mon Sep 17 00:00:00 2001 From: eric Date: Thu, 5 Feb 2026 14:07:58 +0100 Subject: [PATCH] feat: joel voice --- .direnv/bin/nix-direnv-reload | 19 ++ .env.example | 3 + .envrc | 1 + .gitignore copy | 2 + LICENSE | 24 +++ README.md | 3 + bun.lockb | Bin 99178 -> 101763 bytes flake.lock | 101 +++++++++++ flake.nix | 64 +++++++ package.json | 1 + src/commands/definitions/voiceover.ts | 94 ++++++++++ src/core/config.ts | 10 ++ src/database/db.sqlite3 | Bin 360448 -> 376832 bytes .../repositories/memory.repository.ts | 31 ++-- src/features/joel/responder.ts | 4 + src/features/joel/voice.ts | 168 ++++++++++++++++++ src/index.ts | 2 + src/services/ai/tool-handlers.ts | 14 +- src/services/ai/voiceover.ts | 104 +++++++++++ 19 files changed, 627 insertions(+), 18 deletions(-) create mode 100755 .direnv/bin/nix-direnv-reload create mode 100644 .envrc create mode 100644 .gitignore copy create mode 100644 LICENSE create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 src/commands/definitions/voiceover.ts create mode 100644 src/features/joel/voice.ts create mode 100644 src/services/ai/voiceover.ts diff --git a/.direnv/bin/nix-direnv-reload b/.direnv/bin/nix-direnv-reload new file mode 100755 index 0000000..523f57b --- /dev/null +++ b/.direnv/bin/nix-direnv-reload @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -e +if [[ ! -d "/Users/eric/Projects/joel-discord" ]]; then + echo "Cannot find source directory; Did you move it?" + echo "(Looking for "/Users/eric/Projects/joel-discord")" + echo 'Cannot force reload with this script - use "direnv reload" manually and then try again' + exit 1 +fi + +# rebuild the cache forcefully +_nix_direnv_force_reload=1 direnv exec "/Users/eric/Projects/joel-discord" true + +# Update the mtime for .envrc. +# This will cause direnv to reload again - but without re-building. +touch "/Users/eric/Projects/joel-discord/.envrc" + +# Also update the timestamp of whatever profile_rc we have. +# This makes sure that we know we are up to date. +touch -r "/Users/eric/Projects/joel-discord/.envrc" "/Users/eric/Projects/joel-discord/.direnv"/*.rc diff --git a/.env.example b/.env.example index 90ac5d0..5a06415 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,9 @@ HF_TOKEN="" OPENAI_API_KEY="" OPENROUTER_API_KEY="" REPLICATE_API_TOKEN="" +ELEVENLABS_API_KEY="" +ELEVENLABS_VOICE_ID="" +ELEVENLABS_MODEL="eleven_multilingual_v2" WEB_PORT="3000" WEB_BASE_URL="http://localhost:3000" SESSION_SECRET="" \ No newline at end of file diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..8392d15 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake \ No newline at end of file diff --git a/.gitignore copy b/.gitignore copy new file mode 100644 index 0000000..8c02272 --- /dev/null +++ b/.gitignore copy @@ -0,0 +1,2 @@ +.direnv/ +.pre-commit-config.yaml \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/README.md b/README.md index b127991..2f4f4ff 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ src/ | `DISCORD_CLIENT_SECRET` | Discord application client secret | | `OPENROUTER_API_KEY` | OpenRouter API key for AI | | `KLIPY_API_KEY` | Klipy API key for GIF search (optional) | +| `ELEVENLABS_API_KEY` | ElevenLabs API key for voiceover | +| `ELEVENLABS_VOICE_ID` | Default ElevenLabs voice ID (optional) | +| `ELEVENLABS_MODEL` | ElevenLabs model ID (default: `eleven_multilingual_v2`) | | `WEB_PORT` | Port for web dashboard (default: 3000) | | `WEB_BASE_URL` | Base URL for web dashboard | | `SESSION_SECRET` | Secret for session encryption | diff --git a/bun.lockb b/bun.lockb index 2cbd5f115132ef09abca5c86ef715222f0aedba8..e600fc84e654783829f1627bfb9bd26e5c4a96ee 100755 GIT binary patch delta 16051 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 Q@ZNOhux;-!w$}T<0N8S;?*IS* delta 14377 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 + option + .setName("text") + .setDescription("Text to speak") + .setRequired(true) + ) + .addStringOption((option) => + option + .setName("voice") + .setDescription("Optional ElevenLabs voice ID") + .setRequired(false) + ) as SlashCommandBuilder, + category: "ai", + execute: async (interaction) => { + const text = interaction.options.getString("text", true).trim(); + const voiceId = interaction.options.getString("voice") || undefined; + + if (!config.elevenlabs.apiKey) { + await interaction.reply({ + content: "Voiceover is not configured (missing ELEVENLABS_API_KEY).", + ephemeral: true, + }); + return; + } + + if (text.length === 0) { + await interaction.reply({ + content: "Please provide text to speak.", + ephemeral: true, + }); + return; + } + + if (text.length > MAX_TEXT_LENGTH) { + await interaction.reply({ + content: `Text is too long. Max length is ${MAX_TEXT_LENGTH} characters.`, + ephemeral: true, + }); + return; + } + + if (!voiceId && !config.elevenlabs.voiceId) { + await interaction.reply({ + content: "Voiceover needs a voice ID (set ELEVENLABS_VOICE_ID or pass one).", + ephemeral: true, + }); + return; + } + + await interaction.deferReply(); + + try { + const voiceover = getVoiceoverService(); + const audio = await voiceover.generate({ text, voiceId }); + + await interaction.editReply({ + content: "Here is your voiceover:", + files: [ + { + attachment: audio, + name: "voiceover.mp3", + }, + ], + }); + } catch (error) { + logger.error("Voiceover generation failed", error); + const message = error instanceof Error ? error.message : "Unknown error"; + + await interaction.editReply({ + content: `Voiceover failed: ${message}`, + }); + } + }, +}; + +export default command; diff --git a/src/core/config.ts b/src/core/config.ts index af99a99..142ead3 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -25,6 +25,11 @@ interface BotConfig { klipy: { apiKey: string; }; + elevenlabs: { + apiKey: string; + voiceId: string; + modelId: string; + }; bot: { /** Chance of Joel responding without being mentioned (0-1) */ freeWillChance: number; @@ -82,6 +87,11 @@ export const config: BotConfig = { klipy: { apiKey: getEnvOrDefault("KLIPY_API_KEY", ""), }, + elevenlabs: { + apiKey: getEnvOrDefault("ELEVENLABS_API_KEY", ""), + voiceId: getEnvOrDefault("ELEVENLABS_VOICE_ID", ""), + modelId: getEnvOrDefault("ELEVENLABS_MODEL", "eleven_multilingual_v2"), + }, bot: { freeWillChance: 0.02, memoryChance: 0.3, diff --git a/src/database/db.sqlite3 b/src/database/db.sqlite3 index 3c574bb789eb3a210f479f448fed40d94257dc15..70a520e1858d39cf0f8d6b3c86bb04475b1df73b 100644 GIT binary patch delta 1903 zcmZ`)T}&KR6rMZxZnrS(&Q@t5QnN!!)o7tJJ2N{!&_K0Xe~QppjAGO70vlL<78bS| z(?l5Ri!Zvqba-eYCR-j_nsk%GJgLN0(`eE*@i&H~_QAx&&{EpO)Zov#gUjv`_u1_EJRS|19aNIe(|oA&zCg$|gT&Cu_0HN=jHaR8`S6)sVE1CW{(#uaHm-RZ&D)QYAx^nYE&X zEJ~s->8dD(bmnd$A#0+d8)8^iWT;pzN+_BpX$8-2?d;|yCHTFw(#z{xmKapKOz}8o( zf+U-wVk#nYuQ(DiWl*sh37M*4sv2{*IHH-rwOSkrQ=B`+5!IA*=5}#JHYJf+v1d=9 z$D9YfY~?=mltUtTJxE|OjSgwU|M2KZhjfB>aD@ED(}d^{kw8L%2dHm?2go{K>4O|1 zm?1cue1IRe>=@#^EFUy#FJca$>3P*&zK$B~=4$+YKs_db5O@K-N!Rsm*7>O#KhAN@ z8z6NIsmmsJkbq^kvj9>9ATxvueFj{2G3@bXK}!qc^>m!(-i<-?}A@FgMTXze)(P8!J&N_9H3ojamWo8 z)E{ujFVve3S!MHGz>^G~%qCAk7iQuFIH&-ZWBQU|M`;(XeT%P_=Q{fxj&tbPVw%MP zbkHHQl;UZJT&BL&Axj0SD-6DpO-6QiW?1NKZl;33lLY&bD)Ec z`!MLB{?G#194fxW;9J?`k5rC#8_Xk_=a`x=MPwciTTd@r$~F75(`c6+FwtH;VU3mo6fg5~0I)Y{ zdyJO1tb`Z3z1eWMDJ*=qnzm;oo=8jxeWzMW-oy6Kqt@5_)`(X=SwJZHGf;#kVJMn_ zVz-S#Pi#DwNu{DfB6q%jG$o9W4#)dbrNHjWbjRm>^NvSFbQ5(uWQu-9Z@`J9{y3aS HvL5^wd^YI; delta 787 zcmYk4Pe>F|9LL|A-|yw>xXzH8P=ra_UrlG*R%1=FNJEQM(ufYWpocoekX>Wex^yVl zORrNIgh5z1hxO+vggPyRf*v9~crXY=tdl5${hqef!07p1Ky^fGO2 z$}s4VdZ=F>rHuirr0y*&fh+3>f5NsSPvsCNOf(Ts9Fd}>cz7bI7iVQmrz+r?6Yo78Pn}7myhMGnsqq$l zt$<^;UBAmn2uy_j33M|$X)&XU8C6nyQ$O7 zcB4H!w5b(fAk_0V56iTnt6Y%V0CwQ?mTMJm;LiMqzDUM^=CeO%7yjU1Y>=l-Jp nPgTsRs{f@LQ19vWpz61y*UjHymF0zVrz{`(AIm8JP&LZmupQih diff --git a/src/database/repositories/memory.repository.ts b/src/database/repositories/memory.repository.ts index 08ae1a6..ea9f6c7 100644 --- a/src/database/repositories/memory.repository.ts +++ b/src/database/repositories/memory.repository.ts @@ -80,11 +80,11 @@ export const memoryRepository = { /** * Find memories by user ID, sorted by importance then recency */ - async findByUserId(userId: string, limit = 10): Promise { + async findByUserId(userId: string, guildId: string, limit = 10): Promise { const results = await db .select() .from(memories) - .where(eq(memories.user_id, userId)) + .where(and(eq(memories.user_id, userId), eq(memories.guild_id, guildId))) .orderBy(desc(memories.importance), desc(memories.created_at)) .limit(limit); @@ -101,6 +101,7 @@ export const memoryRepository = { */ async findByCategory( userId: string, + guildId: string, category: MemoryCategory, limit = 10 ): Promise { @@ -110,6 +111,7 @@ export const memoryRepository = { .where( and( eq(memories.user_id, userId), + eq(memories.guild_id, guildId), eq(memories.category, category) ) ) @@ -159,11 +161,11 @@ export const memoryRepository = { /** * Get the most important memories for a user */ - async getMostImportant(userId: string, limit = 5): Promise { + async getMostImportant(userId: string, guildId: string, limit = 5): Promise { return db .select() .from(memories) - .where(eq(memories.user_id, userId)) + .where(and(eq(memories.user_id, userId), eq(memories.guild_id, guildId))) .orderBy(desc(memories.importance), desc(memories.access_count)) .limit(limit); }, @@ -171,11 +173,11 @@ export const memoryRepository = { /** * Get frequently accessed memories (likely most useful) */ - async getMostAccessed(userId: string, limit = 5): Promise { + async getMostAccessed(userId: string, guildId: string, limit = 5): Promise { return db .select() .from(memories) - .where(eq(memories.user_id, userId)) + .where(and(eq(memories.user_id, userId), eq(memories.guild_id, guildId))) .orderBy(desc(memories.access_count), desc(memories.importance)) .limit(limit); }, @@ -184,7 +186,12 @@ export const memoryRepository = { * Check for duplicate or similar memories using embedding similarity * Falls back to substring matching if embeddings are unavailable */ - async findSimilar(userId: string, content: string, threshold = 0.85): Promise { + async findSimilar( + userId: string, + guildId: string, + content: string, + threshold = 0.85 + ): Promise { const embeddingService = getEmbeddingService(); // Try embedding-based similarity first @@ -199,6 +206,7 @@ export const memoryRepository = { .where( and( eq(memories.user_id, userId), + eq(memories.guild_id, guildId), sql`${memories.embedding} IS NOT NULL` ) ); @@ -236,6 +244,7 @@ export const memoryRepository = { .where( and( eq(memories.user_id, userId), + eq(memories.guild_id, guildId), like(sql`lower(${memories.content})`, `%${searchTerm}%`) ) ) @@ -331,10 +340,10 @@ export const memoryRepository = { /** * Delete all memories for a user */ - async deleteByUserId(userId: string): Promise { + async deleteByUserId(userId: string, guildId: string): Promise { const result = await db .delete(memories) - .where(eq(memories.user_id, userId)) + .where(and(eq(memories.user_id, userId), eq(memories.guild_id, guildId))) .returning({ id: memories.id }); return result.length; @@ -369,7 +378,7 @@ export const memoryRepository = { /** * Get memory statistics for a user */ - async getStats(userId: string): Promise<{ + async getStats(userId: string, guildId: string): Promise<{ total: number; byCategory: Record; avgImportance: number; @@ -377,7 +386,7 @@ export const memoryRepository = { const allMemories = await db .select() .from(memories) - .where(eq(memories.user_id, userId)); + .where(and(eq(memories.user_id, userId), eq(memories.guild_id, guildId))); const byCategory: Record = {}; let totalImportance = 0; diff --git a/src/features/joel/responder.ts b/src/features/joel/responder.ts index cfbf071..2475ba4 100644 --- a/src/features/joel/responder.ts +++ b/src/features/joel/responder.ts @@ -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); diff --git a/src/features/joel/voice.ts b/src/features/joel/voice.ts new file mode 100644 index 0000000..4444149 --- /dev/null +++ b/src/features/joel/voice.ts @@ -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) { + 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, content: string): Promise { + 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(); + } + } +} diff --git a/src/index.ts b/src/index.ts index fc579ab..93b7b2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,8 @@ const client = new BotClient({ GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildModeration, GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildVoiceStates, + ], }); diff --git a/src/services/ai/tool-handlers.ts b/src/services/ai/tool-handlers.ts index 338dcc7..622f0cf 100644 --- a/src/services/ai/tool-handlers.ts +++ b/src/services/ai/tool-handlers.ts @@ -27,9 +27,9 @@ const toolHandlers: Record = { let userMemories; if (category) { - userMemories = await memoryRepository.findByCategory(userId, category, limit); + userMemories = await memoryRepository.findByCategory(userId, context.guildId, category, limit); } else { - userMemories = await memoryRepository.findByUserId(userId, limit); + userMemories = await memoryRepository.findByUserId(userId, context.guildId, limit); } if (userMemories.length === 0) { @@ -61,7 +61,7 @@ const toolHandlers: Record = { } // Check for duplicate memories using new similarity check - const similar = await memoryRepository.findSimilar(userId, content); + const similar = await memoryRepository.findSimilar(userId, context.guildId, content); if (similar.length > 0) { return "Already knew something similar. Memory not saved (duplicate)."; } @@ -89,7 +89,7 @@ const toolHandlers: Record = { */ async search_memories(args, context): Promise { const query = args.query as string; - const guildId = args.guild_id as string | undefined; + const guildId = (args.guild_id as string | undefined) ?? context.guildId; const category = args.category as MemoryCategory | undefined; const minImportance = args.min_importance as number | undefined; @@ -138,7 +138,7 @@ const toolHandlers: Record = { logger.warn("Forgetting user memories", { userId, requestedBy: context.userId }); - const deleted = await memoryRepository.deleteByUserId(userId); + const deleted = await memoryRepository.deleteByUserId(userId, context.guildId); return `Deleted ${deleted} memories about user ${userId}.`; }, @@ -157,7 +157,7 @@ const toolHandlers: Record = { } // Check for duplicates - const similar = await memoryRepository.findSimilar(context.userId, content); + const similar = await memoryRepository.findSimilar(context.userId, context.guildId, content); if (similar.length > 0) { return "Similar memory already exists. Skipped."; } @@ -185,7 +185,7 @@ const toolHandlers: Record = { async get_memory_stats(args, context): Promise { const userId = (args.user_id as string) || context.userId; - const stats = await memoryRepository.getStats(userId); + const stats = await memoryRepository.getStats(userId, context.guildId); if (stats.total === 0) { return `No memories stored for this user.`; diff --git a/src/services/ai/voiceover.ts b/src/services/ai/voiceover.ts new file mode 100644 index 0000000..c3f2dff --- /dev/null +++ b/src/services/ai/voiceover.ts @@ -0,0 +1,104 @@ +/** + * ElevenLabs voiceover service + */ + +import { config } from "../../core/config"; +import { createLogger } from "../../core/logger"; + +const logger = createLogger("AI:Voiceover"); + +const DEFAULT_OUTPUT_FORMAT = "mp3_44100_128" as const; +const DEFAULT_STABILITY = 0.1; +const DEFAULT_SIMILARITY = 0.90; +const DEFAULT_STYLE = 0.25; +const DEFAULT_SPEED = 1.20 + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +export interface VoiceoverOptions { + text: string; + voiceId?: string; + modelId?: string; + stability?: number; + similarityBoost?: number; + style?: number; + speakerBoost?: boolean; +} + +export class VoiceoverService { + async generate(options: VoiceoverOptions): Promise { + const apiKey = config.elevenlabs.apiKey; + if (!apiKey) { + throw new Error("Voiceover is not configured (missing ELEVENLABS_API_KEY)."); + } + + const voiceId = options.voiceId || config.elevenlabs.voiceId; + if (!voiceId) { + throw new Error("Voiceover is missing a voice ID (set ELEVENLABS_VOICE_ID or pass one)."); + } + + const text = options.text.trim(); + if (!text) { + throw new Error("Voiceover text is empty."); + } + + const modelId = options.modelId || config.elevenlabs.modelId; + + const voiceSettings = { + stability: clamp01(options.stability ?? DEFAULT_STABILITY), + similarity_boost: clamp01(options.similarityBoost ?? DEFAULT_SIMILARITY), + style: clamp01(options.style ?? DEFAULT_STYLE), + use_speaker_boost: options.speakerBoost ?? true, + }; + + const url = new URL(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream`); + url.searchParams.set("output_format", DEFAULT_OUTPUT_FORMAT); + + logger.debug("Generating voiceover", { + textLength: text.length, + voiceId, + modelId, + }); + + const response = await fetch(url.toString(), { + method: "POST", + headers: { + "xi-api-key": apiKey, + "Content-Type": "application/json", + "Accept": "audio/mpeg", + }, + body: JSON.stringify({ + text, + model_id: modelId, + voice_settings: voiceSettings, + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + logger.error("ElevenLabs API error", { + status: response.status, + body: errorBody.slice(0, 300), + }); + throw new Error(`ElevenLabs API error (HTTP ${response.status}).`); + } + + const audioBuffer = await response.arrayBuffer(); + return Buffer.from(audioBuffer); + } + + async health(): Promise { + return !!config.elevenlabs.apiKey; + } +} + +let voiceoverService: VoiceoverService | null = null; + +export function getVoiceoverService(): VoiceoverService { + if (!voiceoverService) { + voiceoverService = new VoiceoverService(); + } + return voiceoverService; +}