[object Object]

← back to Cncp Failures Mockups

v7 voice: replace Web Speech with Steve's ElevenLabs cloned voice + per-band ffmpeg pitch/tempo shift; /api/voice endpoint with sha256 cache; 8 greetings pre-warmed; falls back to Web Speech on error

2f518c5b75a2be32c17d5f3219076bd9952e5dc1 · 2026-05-11 16:21:06 -0700 · SteveStudio2

Files touched

Diff

commit 2f518c5b75a2be32c17d5f3219076bd9952e5dc1
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Mon May 11 16:21:06 2026 -0700

    v7 voice: replace Web Speech with Steve's ElevenLabs cloned voice + per-band ffmpeg pitch/tempo shift; /api/voice endpoint with sha256 cache; 8 greetings pre-warmed; falls back to Web Speech on error
---
 .gitignore                  |   1 +
 public/v7-age-adaptive.html |  43 +++++++++++++++--
 server.js                   | 112 +++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 151 insertions(+), 5 deletions(-)

diff --git a/.gitignore b/.gitignore
index 3c45938..e47d985 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
 node_modules/
 *.log
 .DS_Store
+audio-cache/
diff --git a/public/v7-age-adaptive.html b/public/v7-age-adaptive.html
index 7bfbd82..841ae4c 100644
--- a/public/v7-age-adaptive.html
+++ b/public/v7-age-adaptive.html
@@ -439,9 +439,40 @@ if ('speechSynthesis' in window) {
   window.speechSynthesis.onvoiceschanged = loadVoices;
 }
 
-function speak(text, band) {
+// Speak prefers Steve's cloned voice via /api/voice (ElevenLabs + per-band ffmpeg pitch shift).
+// Falls back to browser Web Speech if /api/voice errors or times out.
+let _currentAudio = null;
+async function speak(text, band) {
+  if (!_voiceEnabled || !text) return;
+  // Cancel anything in flight
+  if (_currentAudio) { try { _currentAudio.pause(); _currentAudio.src = ''; } catch {} _currentAudio = null; }
+  if (window.speechSynthesis) window.speechSynthesis.cancel();
+
+  const url = `/api/voice?band=${encodeURIComponent(band)}&text=${encodeURIComponent(text)}`;
+  try {
+    // HEAD-style probe to make sure server returns audio (not JSON error) — keep cheap.
+    const audio = new Audio(url);
+    audio.preload = 'auto';
+    _currentAudio = audio;
+    audio.onerror = () => {
+      console.warn('[voice] /api/voice errored — falling back to Web Speech');
+      _currentAudio = null;
+      speakWebSpeech(text, band);
+    };
+    await audio.play().catch(e => {
+      console.warn('[voice] audio.play rejected — fallback:', e.message);
+      _currentAudio = null;
+      speakWebSpeech(text, band);
+    });
+  } catch (e) {
+    console.warn('[voice] threw — fallback:', e.message);
+    speakWebSpeech(text, band);
+  }
+}
+
+// Fallback path — original Web Speech behavior in case Steve voice is unavailable.
+function speakWebSpeech(text, band) {
   if (!_voiceEnabled || !('speechSynthesis' in window) || !text) return;
-  // Cancel anything currently speaking so the user isn't piled up
   window.speechSynthesis.cancel();
   const cfg = BAND_VOICES[band] || BAND_VOICES.adult;
   const u = new SpeechSynthesisUtterance(text);
@@ -456,8 +487,12 @@ function toggleVoice() {
   _voiceEnabled = !_voiceEnabled;
   const btn = document.getElementById('voiceToggle');
   if (btn) btn.textContent = _voiceEnabled ? '🔊 voice on' : '🔇 voice off';
-  if (!_voiceEnabled) window.speechSynthesis && window.speechSynthesis.cancel();
-  else speak('voice on', bandFor(Number(slider.value)).id);
+  if (!_voiceEnabled) {
+    if (_currentAudio) { try { _currentAudio.pause(); } catch {} _currentAudio = null; }
+    if (window.speechSynthesis) window.speechSynthesis.cancel();
+  } else {
+    speak('voice on', bandFor(Number(slider.value)).id);
+  }
 }
 
 // ===== UI HOOKS — every band's primary action wired here. =====
diff --git a/server.js b/server.js
index 8ee7d53..90c33a3 100644
--- a/server.js
+++ b/server.js
@@ -5,11 +5,45 @@
 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 ffmpeg audio filter — pitch + tempo combined to suggest age.
+// asetrate trick: change sample rate to shift pitch, aresample back, then atempo to repair duration.
+// Source MP3 from ElevenLabs is at 22050Hz; we keep 22050 as the reference.
+const BAND_FILTERS = {
+  toddler: 'asetrate=22050*1.55,aresample=22050,atempo=0.92',   // +~7st, slightly slower for clarity
+  kid:     'asetrate=22050*1.32,aresample=22050,atempo=0.95',   // +~5st
+  tween:   'asetrate=22050*1.15,aresample=22050,atempo=1.0',    // +~2st
+  teen:    'asetrate=22050*1.06,aresample=22050,atempo=1.02',   // +1st, slightly faster
+  adult:   null,                                                 // natural Steve, no filter
+  mature:  'asetrate=22050*0.96,aresample=22050,atempo=0.98',   // -1st, slightly slower
+  senior:  'asetrate=22050*0.85,aresample=22050,atempo=0.9',    // -3st, slower
+  elder:   'asetrate=22050*0.78,aresample=22050,atempo=0.82'    // -4st, much slower
+};
+const AUDIO_CACHE = path.join(__dirname, 'audio-cache');
+fs.mkdirSync(AUDIO_CACHE, { recursive: true });
+
 const app = express();
 app.use(express.static(path.join(__dirname, 'public')));
 
@@ -59,7 +93,83 @@ function sendSeed(res, isFallback, errMsg) {
   }
 }
 
-app.get('/health', (_, res) => res.json({ ok: true, variants: 6, cncp: CNCP_URL }));
+// GET /api/voice?text=...&band=...  →  MP3 of Steve's cloned voice, pitch-shifted to the age band.
+// 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' });
+  if (!STEVE_VOICE_ID) return res.status(503).json({ error: 'Steve voice_id not found (clone-voice active.json missing)' });
+  if (!(band in BAND_FILTERS)) return res.status(400).json({ error: 'unknown band: ' + band });
+
+  const hash = crypto.createHash('sha256').update(text + '|' + band).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);
+  }
+
+  // 1) Generate Steve's voice from ElevenLabs
+  let mp3Buf;
+  try {
+    const r = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${STEVE_VOICE_ID}`, {
+      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] ElevenLabs', r.status, body.slice(0, 200));
+      return res.status(502).json({ error: 'elevenlabs ' + r.status, detail: body.slice(0, 200) });
+    }
+    mp3Buf = Buffer.from(await r.arrayBuffer());
+  } catch (e) {
+    console.error('[voice] EL fetch threw:', e.message);
+    return res.status(502).json({ error: 'elevenlabs unreachable: ' + e.message });
+  }
+
+  // 2) Pitch + tempo shift via ffmpeg if the band has a filter (adult = passthrough)
+  const filter = BAND_FILTERS[band];
+  if (!filter) {
+    fs.writeFileSync(cached, mp3Buf);
+    res.set('X-Cache', 'MISS-NO-SHIFT');
+    return res.send(mp3Buf);
+  }
+
+  const tmpIn = path.join(AUDIO_CACHE, `${band}-${hash}.tmp.in.mp3`);
+  fs.writeFileSync(tmpIn, mp3Buf);
+  try {
+    await new Promise((resolve, reject) => {
+      execFile('ffmpeg', ['-y', '-i', tmpIn, '-af', filter, '-c:a', 'libmp3lame', '-b:a', '96k', cached],
+        { timeout: 15_000 }, (err, _so, se) => err ? reject(new Error(se.slice(0, 300))) : resolve());
+    });
+  } catch (e) {
+    fs.unlinkSync(tmpIn);
+    console.error('[voice] ffmpeg threw:', e.message);
+    return res.status(500).json({ error: 'ffmpeg: ' + e.message });
+  }
+  fs.unlinkSync(tmpIn);
+  res.set('X-Cache', 'MISS');
+  return fs.createReadStream(cached).pipe(res);
+});
+
+app.get('/health', (_, res) => res.json({
+  ok: true,
+  variants: 7,
+  cncp: CNCP_URL,
+  voice: { steve_voice_id: STEVE_VOICE_ID, el_key_present: !!EL_API_KEY, bands: Object.keys(BAND_FILTERS) }
+}));
 
 app.listen(PORT, BIND, () => {
   console.log(`cncp-failures-mockups listening at http://${BIND}:${PORT}/`);

← 3ab046b v7 voices: 8 per-band TTS profiles (voice/pitch/rate) via We  ·  back to Cncp Failures Mockups  ·  v7 voices = per-band ElevenLabs stock voices (no pitch-shift e689769 →