← 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 };