diff --git a/.gitignore b/.gitignore index 13dfa36..92a67e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .env -node_modules/ \ No newline at end of file +node_modules/ +.direnv/ +.pre-commit-config.yaml \ No newline at end of file diff --git a/.gitignore copy b/.gitignore copy deleted file mode 100644 index 8c02272..0000000 --- a/.gitignore copy +++ /dev/null @@ -1,2 +0,0 @@ -.direnv/ -.pre-commit-config.yaml \ No newline at end of file diff --git a/src/database/db.sqlite3 b/src/database/db.sqlite3 index 70a520e..6c15ade 100644 Binary files a/src/database/db.sqlite3 and b/src/database/db.sqlite3 differ diff --git a/src/features/joel/personalities.ts b/src/features/joel/personalities.ts index 9dfad98..83d860f 100644 --- a/src/features/joel/personalities.ts +++ b/src/features/joel/personalities.ts @@ -62,9 +62,13 @@ export const defaultPersonality: Personality = { "Roleplay along if the user describes actions.", "", "=== LANGUAGE RULES ===", + "Always respond in the same language as the user.", + "If the user writes in Swedish, respond in correct Swedish with proper spelling.", "Use vulgar language freely: fuck, shit, bitch, asshole, etc.", "Do NOT use racial slurs unprompted. Only use them if the user specifically asks for racist content.", "Insults should target intelligence, appearance, and life choices - not race.", + "NEVER use sexual language agains women, like assault, harassment, or non-consensual acts.", + "When insulting minorities, never say which. Only say 'minorities'.", "", "=== SPECIAL INSTRUCTIONS ===", "If asked about illegal activities (drugs, weapons, hacking, etc.), explain in detail.", diff --git a/src/features/joel/voice.ts b/src/features/joel/voice.ts index 4444149..a2fd521 100644 --- a/src/features/joel/voice.ts +++ b/src/features/joel/voice.ts @@ -11,6 +11,7 @@ import { getVoiceConnection, joinVoiceChannel, StreamType, + type VoiceConnection, type DiscordGatewayAdapterCreator, } from "@discordjs/voice"; import type { Message } from "discord.js"; @@ -29,8 +30,27 @@ function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } -function sanitizeForVoiceover(content: string): string { - let text = content.replace(/```[\s\S]*?```/g, " "); +function resolveMentions(message: Message, content: string): string { + let text = content; + + for (const member of message.mentions.members?.values() ?? []) { + const name = member.displayName || member.user.username; + text = text.replace(new RegExp(`<@!?${member.id}>`, "g"), name); + } + + for (const user of message.mentions.users.values()) { + if (message.mentions.members?.has(user.id)) { + continue; + } + text = text.replace(new RegExp(`<@!?${user.id}>`, "g"), user.username); + } + + return text; +} + +function sanitizeForVoiceover(message: Message, content: string): string { + let text = resolveMentions(message, content); + text = text.replace(/```[\s\S]*?```/g, " "); text = text.replace(/`([^`]+)`/g, "$1"); text = text.replace(/\s+/g, " ").trim(); @@ -114,26 +134,29 @@ export async function speakVoiceover(message: Message, content: string): P return; } - const text = sanitizeForVoiceover(content); + const text = sanitizeForVoiceover(message, 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; - } + let connection: VoiceConnection | null = null; 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 }); + + connection = await getOrCreateConnection(message); + if (!connection) { + logger.debug("Voiceover skipped (no connection)", { + guildId: message.guildId, + authorId: message.author.id, + }); + return; + } + const player = createAudioPlayer(); const resource = createAudioResource(Readable.from(audio), { inputType: StreamType.Arbitrary, @@ -161,7 +184,7 @@ export async function speakVoiceover(message: Message, content: string): P logger.error("Voiceover playback failed", error); } } finally { - if (connection.state.status !== VoiceConnectionStatus.Destroyed) { + if (connection && connection.state.status !== VoiceConnectionStatus.Destroyed) { connection.destroy(); } } diff --git a/src/services/ai/voiceover.ts b/src/services/ai/voiceover.ts index c3f2dff..0d4a882 100644 --- a/src/services/ai/voiceover.ts +++ b/src/services/ai/voiceover.ts @@ -14,91 +14,93 @@ const DEFAULT_STYLE = 0.25; const DEFAULT_SPEED = 1.20 function clamp01(value: number): number { - return Math.max(0, Math.min(1, value)); + 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; + text: string; + voiceId?: string; + modelId?: string; + stability?: number; + similarityBoost?: number; + style?: number; + speakerBoost?: boolean; + speed?: number; } 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)."); + 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), + speed: options.speed ?? DEFAULT_SPEED, + 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); } - 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)."); + async health(): Promise { + return !!config.elevenlabs.apiKey; } - - 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; + if (!voiceoverService) { + voiceoverService = new VoiceoverService(); + } + return voiceoverService; }