← back to Cncp Failures Mockups

server.js

160 lines

// CNCP Failures Mockups — 6-variant web viewer.
// Serves /public statically + a /data endpoint returning sample-data.json
// so every mockup can render against the same realistic seed.

const express = require('express');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { execFile } = require('child_process');

const PORT = parseInt(process.env.PORT, 10) || 9771;
const BIND = process.env.BIND || '127.0.0.1';
const CNCP_URL = process.env.CNCP_URL || 'http://127.0.0.1:3333';

// ElevenLabs config — Steve's cloned voice + per-band pitch/tempo shift via ffmpeg.
const EL_API_KEY = process.env.ELEVENLABS_API_KEY || readEnvFromSecrets('ELEVENLABS_API_KEY');
const STEVE_VOICE_ID = (() => {
  try {
    const active = JSON.parse(fs.readFileSync(path.join(process.env.HOME, '.claude/skills/clone-voice/active.json'), 'utf-8'));
    return active.engines?.elevenlabs?.voice_id || null;
  } catch { return null; }
})();
function readEnvFromSecrets(key) {
  try {
    const raw = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf-8');
    const m = raw.match(new RegExp('^' + key + '=(.*)$', 'm'));
    return m ? m[1].trim() : null;
  } catch { return null; }
}

// Per-band ElevenLabs voice ids — natural stock voices picked from the library.
// No pitch-shifting; each age band gets a voice that naturally sounds the right age.
// Picked from `curl /v1/voices` after Steve said "just use suggested 11 labs voice".
const BAND_VOICE_IDS = {
  toddler: 'c6cUlh8iJa74kYbBwDaZ',  // Oceano — A very young narrator
  kid:     'cgSgspJ2msm6clMCkdW9',  // Jessica — Playful, Bright, Warm (young female)
  tween:   'FGY2WhTYpPnrIDTdsKH5',  // Laura — Enthusiast, Quirky Attitude (young female)
  teen:    'TX3LPaxmHKxFdv7VOQHJ',  // Liam — Energetic, Social Media Creator (young male)
  adult:   'EXAVITQu4vr4xnSDxMaL',  // Sarah — Mature, Reassuring, Confident
  mature:  'JBFqnCBsd6RMkjVDRZzb',  // George — Warm, Captivating Storyteller (middle-aged male)
  senior:  'nPczCjzI2devNBz1zQrb',  // Brian — Deep, Resonant and Comforting
  elder:   'pqHfZKP75CvOlQylNhV4'   // Bill — Wise, Mature, Balanced ("old" bucket)
};
const AUDIO_CACHE = path.join(__dirname, 'audio-cache');
fs.mkdirSync(AUDIO_CACHE, { recursive: true });

const app = express();

// 404-guard — never serve editor/build snapshot artifacts even if one accidentally
// lands under public/. Catches *.bak, *.bak.*, *.pre-*, *.swp paths anywhere in the URL.
app.use((req, res, next) => {
  if (/\.(bak|swp)(\.|$|\/)/i.test(req.path) || /\.pre-/i.test(req.path)) {
    return res.status(404).type('text/plain').send('Not found');
  }
  next();
});

app.use(express.static(path.join(__dirname, 'public')));

// GET /data
//   default → LIVE from CNCP (failures + hermes status), merged in same schema as sample-data.json.
//   ?seed=1 → force the static sample. Useful for screenshots / pixel-stable design comparison.
//   if live fetch fails (CNCP down) → automatically falls back to seed with `_live: false, _fallback: true`.
app.get('/data', async (req, res) => {
  const wantSeed = req.query.seed === '1';
  res.set('Cache-Control', 'no-store');
  if (wantSeed) return sendSeed(res, false);
  try {
    const ctrl = AbortSignal.timeout(2500);
    const [failuresR, hermesR] = await Promise.all([
      fetch(CNCP_URL + '/api/failures', { signal: ctrl }),
      fetch(CNCP_URL + '/api/hermes/status', { signal: ctrl })
    ]);
    if (!failuresR.ok) throw new Error('failures HTTP ' + failuresR.status);
    if (!hermesR.ok) throw new Error('hermes HTTP ' + hermesR.status);
    const failures = await failuresR.json();
    const hermes = await hermesR.json();
    return res.type('application/json').send(JSON.stringify({
      _live: true,
      _fetchedAt: Date.now(),
      _source: 'cncp@' + CNCP_URL,
      failures,
      hermes
    }));
  } catch (e) {
    return sendSeed(res, true, e.message);
  }
});

function sendSeed(res, isFallback, errMsg) {
  try {
    const raw = JSON.parse(fs.readFileSync(path.join(__dirname, 'sample-data.json'), 'utf-8'));
    res.type('application/json').send(JSON.stringify({
      _live: false,
      _fallback: !!isFallback,
      _fallbackError: errMsg || null,
      _fetchedAt: Date.now(),
      _source: 'sample-data.json',
      ...raw
    }));
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
}

// GET /api/voice?text=...&band=...  →  MP3 from the per-band stock ElevenLabs voice (no pitch shift).
// Caches by hash(text|band) so re-saying a phrase is free after the first generation.
app.get('/api/voice', async (req, res) => {
  const text = String(req.query.text || '').slice(0, 400).trim();
  const band = String(req.query.band || 'adult').trim();
  if (!text) return res.status(400).json({ error: 'text required' });
  if (!EL_API_KEY) return res.status(503).json({ error: 'ELEVENLABS_API_KEY not configured' });
  const voiceId = BAND_VOICE_IDS[band];
  if (!voiceId) return res.status(400).json({ error: 'unknown band: ' + band });

  const hash = crypto.createHash('sha256').update(text + '|' + voiceId).digest('hex').slice(0, 24);
  const cached = path.join(AUDIO_CACHE, `${band}-${hash}.mp3`);
  res.set('Cache-Control', 'public, max-age=86400');
  res.set('Content-Type', 'audio/mpeg');
  if (fs.existsSync(cached)) {
    res.set('X-Cache', 'HIT');
    return fs.createReadStream(cached).pipe(res);
  }

  try {
    const r = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
      method: 'POST',
      headers: { 'xi-api-key': EL_API_KEY, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
      body: JSON.stringify({
        text,
        model_id: 'eleven_turbo_v2_5',
        voice_settings: { stability: 0.55, similarity_boost: 0.85, style: 0.0, use_speaker_boost: true }
      })
    });
    if (!r.ok) {
      const body = await r.text().catch(() => '');
      console.error('[voice]', band, voiceId, r.status, body.slice(0, 200));
      return res.status(502).json({ error: 'elevenlabs ' + r.status, detail: body.slice(0, 200) });
    }
    const mp3 = Buffer.from(await r.arrayBuffer());
    fs.writeFileSync(cached, mp3);
    res.set('X-Cache', 'MISS');
    return res.send(mp3);
  } catch (e) {
    console.error('[voice] threw:', e.message);
    return res.status(502).json({ error: 'elevenlabs unreachable: ' + e.message });
  }
});

app.get('/health', (_, res) => res.json({
  ok: true,
  variants: 7,
  cncp: CNCP_URL,
  voice: { el_key_present: !!EL_API_KEY, bands: BAND_VOICE_IDS }
}));

app.listen(PORT, BIND, () => {
  console.log(`cncp-failures-mockups listening at http://${BIND}:${PORT}/`);
});