← back to Crankd

lib/elevenlabs.js

52 lines

'use strict';

const fs = require('fs');
const path = require('path');

const PERSONAS_DIR = path.join(__dirname, '..', 'personas');

function loadPersona(slug) {
  const idxPath = path.join(PERSONAS_DIR, 'index.json');
  if (!fs.existsSync(idxPath)) throw new Error('personas/index.json missing');
  const idx = JSON.parse(fs.readFileSync(idxPath, 'utf8'));
  const meta = idx.personas[slug];
  if (!meta) throw new Error(`unknown persona: ${slug}`);
  const file = path.join(PERSONAS_DIR, meta.file);
  return JSON.parse(fs.readFileSync(file, 'utf8'));
}

function listPersonas() {
  const idx = JSON.parse(fs.readFileSync(path.join(PERSONAS_DIR, 'index.json'), 'utf8'));
  return Object.entries(idx.personas).map(([slug, meta]) => ({ slug, ...meta }));
}

async function tts({ persona, text }) {
  const p = loadPersona(persona);
  if (!p.elevenlabs.voice_id) {
    throw new Error(`persona '${persona}' has no voice_id yet — record samples + run PVC submission first`);
  }
  const key = process.env.ELEVENLABS_API_KEY;
  if (!key) throw new Error('ELEVENLABS_API_KEY not set');

  const url = `https://api.elevenlabs.io/v1/text-to-speech/${p.elevenlabs.voice_id}`;
  const body = {
    text,
    model_id: p.elevenlabs.model_id || 'eleven_turbo_v2_5',
    voice_settings: {
      stability: p.elevenlabs.stability ?? 0.45,
      similarity_boost: p.elevenlabs.similarity_boost ?? 0.75,
      style: p.elevenlabs.style ?? 0.15,
      use_speaker_boost: p.elevenlabs.use_speaker_boost ?? true,
    },
  };
  const r = await fetch(url, {
    method: 'POST',
    headers: { 'xi-api-key': key, 'content-type': 'application/json', accept: 'audio/mpeg' },
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error(`elevenlabs ${r.status}: ${await r.text()}`);
  return Buffer.from(await r.arrayBuffer());
}

module.exports = { loadPersona, listPersonas, tts };