#!/usr/bin/env node /** * D&D NPC Voice TTS CLI Tool * Usage: node speak-npc.js --text "NPC dialogue" --voice [--npc "NPC Name"] * * Examples: * node speak-npc.js --text "Welcome, traveler!" --voice goblin --npc "Klarg" * node speak-npc.js --text "I need your help" --voice wizard --npc "Sildar" */ import { ElevenLabsClient } from '@elevenlabs/elevenlabs-js'; import { createWriteStream } from 'fs'; import { spawn } from 'child_process'; import { promises as fs } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Load environment variables from .env file in skill directory dotenv.config({ path: join(__dirname, '.env') }); // Voice presets for different character types const VOICE_PRESETS = { // Default/versatile voices 'default': 'JBFqnCBsd6RMkjVDRZzb', // George - neutral male 'narrator': 'pNInz6obpgDQGcFmaJgB', // Adam - calm narrator // Fantasy character archetypes 'goblin': 'EXAVITQu4vr4xnSDxMaL', // Sarah - can sound sneaky/nasty 'dwarf': 'VR6AewLTigWG4xSOukaG', // Arnold - deep male 'elf': 'ThT5KcBeYPX3keUQqHPh', // Dorothy - elegant female 'wizard': 'pNInz6obpgDQGcFmaJgB', // Adam - wise male 'warrior': 'VR6AewLTigWG4xSOukaG', // Arnold - gruff male 'rogue': 'EXAVITQu4vr4xnSDxMaL', // Sarah - sneaky 'cleric': 'ThT5KcBeYPX3keUQqHPh', // Dorothy - gentle female 'merchant': 'JBFqnCBsd6RMkjVDRZzb', // George - friendly 'guard': 'VR6AewLTigWG4xSOukaG', // Arnold - authoritative 'noble': 'pNInz6obpgDQGcFmaJgB', // Adam - refined 'villain': 'EXAVITQu4vr4xnSDxMaL', // Sarah - menacing // Age/gender variations 'oldman': 'pNInz6obpgDQGcFmaJgB', // Adam 'youngman': 'JBFqnCBsd6RMkjVDRZzb', // George 'woman': 'ThT5KcBeYPX3keUQqHPh', // Dorothy 'girl': 'ThT5KcBeYPX3keUQqHPh', // Dorothy }; // ANSI color codes const colors = { cyan: '\x1b[36m', blue: '\x1b[34m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', reset: '\x1b[0m' }; function parseArgs() { const args = process.argv.slice(2); const parsed = { text: null, voice: 'default', npc: null, list: false, help: false }; for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--text': parsed.text = args[++i]; break; case '--voice': parsed.voice = args[++i]; break; case '--npc': parsed.npc = args[++i]; break; case '--list': parsed.list = true; break; case '--help': case '-h': parsed.help = true; break; } } return parsed; } function printHelp() { console.log(` ${colors.cyan}🎭 D&D NPC Voice TTS Tool${colors.reset} Usage: node speak-npc.js --text "dialogue" --voice [--npc "Name"] node speak-npc.js --list node speak-npc.js --help Options: --text The dialogue text to speak (required) --voice Voice preset (default: "default") --npc NPC name for display (optional) --list List all available voice presets --help, -h Show this help message Examples: node speak-npc.js --text "Welcome, traveler!" --voice goblin --npc "Klarg" node speak-npc.js --text "I need your help" --voice wizard --npc "Sildar" node speak-npc.js --text "You dare challenge me?" --voice villain Available voice presets: ${Object.keys(VOICE_PRESETS).join(', ')} Setup: 1. Copy .env.example to .env in the skill directory 2. Add your ElevenLabs API key to .env 3. Get API key from: https://elevenlabs.io/app/settings/api-keys `); } function listVoices() { console.log(`\n${colors.cyan}🎭 Available Voice Presets${colors.reset}\n`); const categories = { 'Default': ['default', 'narrator'], 'Fantasy Archetypes': ['goblin', 'dwarf', 'elf', 'wizard', 'warrior', 'rogue', 'cleric'], 'NPCs': ['merchant', 'guard', 'noble', 'villain'], 'Age/Gender': ['oldman', 'youngman', 'woman', 'girl'] }; for (const [category, voices] of Object.entries(categories)) { console.log(`${colors.yellow}${category}:${colors.reset}`); voices.forEach(voice => { if (VOICE_PRESETS[voice]) { console.log(` ${colors.green}${voice.padEnd(15)}${colors.reset} (ID: ${VOICE_PRESETS[voice]})`); } }); console.log(); } } async function playAudio(audioPath) { return new Promise((resolve, reject) => { // Try afplay (macOS), then ffplay, then mpg123 const players = ['afplay', 'ffplay -nodisp -autoexit', 'mpg123']; let player = players[0]; if (process.platform === 'darwin') { player = 'afplay'; } else if (process.platform === 'linux') { player = 'mpg123'; } const proc = spawn(player.split(' ')[0], [ ...player.split(' ').slice(1), audioPath ], { stdio: 'ignore' }); proc.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`Audio player exited with code ${code}`)); } }); proc.on('error', (err) => { reject(err); }); }); } async function textToSpeech(text, voiceId, npcName = null) { const apiKey = process.env.ELEVENLABS_API_KEY; if (!apiKey || apiKey === 'your_api_key_here') { console.error(`${colors.red}❌ Error: ElevenLabs API key not configured${colors.reset}`); console.error(`\n${colors.yellow}Setup instructions:${colors.reset}`); console.error(`1. Copy .env.example to .env in the skill directory`); console.error(`2. Add your API key to .env`); console.error(`3. Get API key from: https://elevenlabs.io/app/settings/api-keys\n`); process.exit(1); } try { console.log(`${colors.cyan}🎙️ Generating speech...${colors.reset}`); if (npcName) { console.log(`${colors.blue} NPC: ${npcName}${colors.reset}`); } console.log(`${colors.blue} Text: "${text}"${colors.reset}`); const elevenlabs = new ElevenLabsClient({ apiKey: apiKey }); const audio = await elevenlabs.textToSpeech.convert( voiceId, { text: text, model_id: 'eleven_flash_v2_5', output_format: 'mp3_44100_128' } ); // Save to temporary file const tempFile = join(__dirname, '.temp_voice.mp3'); const writeStream = createWriteStream(tempFile); // Handle the audio stream if (audio[Symbol.asyncIterator]) { for await (const chunk of audio) { writeStream.write(chunk); } } else { writeStream.write(audio); } await new Promise((resolve, reject) => { writeStream.end(); writeStream.on('finish', resolve); writeStream.on('error', reject); }); console.log(`${colors.green}✅ Audio generated${colors.reset}`); console.log(`${colors.cyan}🔊 Playing audio...${colors.reset}`); // Play the audio await playAudio(tempFile); // Clean up temp file await fs.unlink(tempFile); console.log(`${colors.green}✅ Complete!${colors.reset}`); } catch (error) { console.error(`${colors.red}❌ Error: ${error.message}${colors.reset}`); if (error.message.includes('401') || error.message.includes('unauthorized')) { console.error(`\n${colors.yellow}Your API key may be invalid. Please check:${colors.reset}`); console.error(`1. API key is correct in .env file`); console.error(`2. Key has not expired`); console.error(`3. Get a new key from: https://elevenlabs.io/app/settings/api-keys\n`); } process.exit(1); } } // Main execution async function main() { const args = parseArgs(); if (args.help) { printHelp(); process.exit(0); } if (args.list) { listVoices(); process.exit(0); } if (!args.text) { console.error(`${colors.red}❌ Error: --text is required${colors.reset}`); console.error(`Run with --help for usage information\n`); process.exit(1); } const voiceId = VOICE_PRESETS[args.voice] || args.voice; if (!VOICE_PRESETS[args.voice] && args.voice !== 'default') { console.warn(`${colors.yellow}⚠️ Warning: Unknown voice preset "${args.voice}", using voice ID directly${colors.reset}`); } await textToSpeech(args.text, voiceId, args.npc); } main();