← back to Butlr

lib/elevenlabs.js

75 lines

// ElevenLabs TTS synthesis for the always-announce preamble.
//
// Standing rules in play:
//   • feedback_always_elevenlabs_voice.md — never piper/say/coqui as primary
//   • feedback_holdforme_always_announce.md — every outbound call announces consent
//
// Cache strategy: hash(text + voiceId + model) → mp3 file on disk.
// If the EL key is missing/invalid (401), synth returns null and the caller
// falls back to Twilio `<Say voice="Polly.Joanna">…</Say>` so the announcement
// still plays. The rule is non-negotiable — only the voice quality is.

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

const API_KEY = process.env.ELEVENLABS_API_KEY || '';
const DEFAULT_VOICE = process.env.ELEVENLABS_VOICE_ID || '21m00Tcm4TlvDq8ikWAM'; // Rachel — neutral pro
const DEFAULT_MODEL = process.env.ELEVENLABS_MODEL || 'eleven_turbo_v2_5';

const CACHE_DIR = path.join(__dirname, '..', 'data', 'audio-cache');
fs.mkdirSync(CACHE_DIR, { recursive: true });

function cachePathFor(text, voiceId, modelId) {
  const h = crypto.createHash('sha256').update(`${voiceId}|${modelId}|${text}`).digest('hex').slice(0, 24);
  return path.join(CACHE_DIR, `${h}.mp3`);
}

async function synthesize(text, opts = {}) {
  const voiceId = opts.voiceId || DEFAULT_VOICE;
  const modelId = opts.modelId || DEFAULT_MODEL;
  const cachePath = cachePathFor(text, voiceId, modelId);

  if (fs.existsSync(cachePath)) {
    return { ok: true, path: cachePath, cached: true };
  }
  if (!API_KEY) {
    return { ok: false, error: 'no_api_key', fallback: 'twilio_say' };
  }

  try {
    const url = `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voiceId)}`;
    const resp = await fetch(url, {
      method: 'POST',
      headers: {
        'xi-api-key': API_KEY,
        'Content-Type': 'application/json',
        'Accept': 'audio/mpeg',
      },
      body: JSON.stringify({
        text,
        model_id: modelId,
        voice_settings: { stability: 0.5, similarity_boost: 0.75 },
      }),
    });
    if (!resp.ok) {
      const body = await resp.text().catch(() => '');
      return { ok: false, error: `el_http_${resp.status}`, body: body.slice(0, 200), fallback: 'twilio_say' };
    }
    const buf = Buffer.from(await resp.arrayBuffer());
    fs.writeFileSync(cachePath, buf);
    return { ok: true, path: cachePath, cached: false, bytes: buf.length };
  } catch (e) {
    return { ok: false, error: 'el_exception', detail: e.message, fallback: 'twilio_say' };
  }
}

// Pre-warm the cache for a call's announcement so the TwiML endpoint can
// serve it immediately. Returns the cache path or null on failure.
async function prewarmAnnouncement(text, opts = {}) {
  const r = await synthesize(text, opts);
  return r.ok ? r.path : null;
}

module.exports = { synthesize, prewarmAnnouncement, cachePathFor, DEFAULT_VOICE, DEFAULT_MODEL };