← 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
M .gitignoreM public/v7-age-adaptive.htmlM server.js
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 →