Files
gh-saharcarmel-candlekeep-p…/skills/npc-voice/speak-npc.js
2025-11-30 08:53:36 +08:00

286 lines
8.1 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* D&D NPC Voice TTS CLI Tool
* Usage: node speak-npc.js --text "NPC dialogue" --voice <voice-name> [--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 <preset> [--npc "Name"]
node speak-npc.js --list
node speak-npc.js --help
Options:
--text <string> The dialogue text to speak (required)
--voice <preset> Voice preset (default: "default")
--npc <name> 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();