[object Object]

← back to Cncp Failures Mockups

v7 voices = per-band ElevenLabs stock voices (no pitch-shift) + age-on-login modal with localStorage persistence; 8 voices pre-warmed

e6897693b9073a3b44c0b20002cd92fe39021f75 · 2026-05-11 16:24:56 -0700 · SteveStudio2

Files touched

Diff

commit e6897693b9073a3b44c0b20002cd92fe39021f75
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Mon May 11 16:24:56 2026 -0700

    v7 voices = per-band ElevenLabs stock voices (no pitch-shift) + age-on-login modal with localStorage persistence; 8 voices pre-warmed
---
 public/v7-age-adaptive.html | 74 +++++++++++++++++++++++++++++++++++++++++++
 server.js                   | 77 +++++++++++++++------------------------------
 2 files changed, 99 insertions(+), 52 deletions(-)

diff --git a/public/v7-age-adaptive.html b/public/v7-age-adaptive.html
index 841ae4c..9de2483 100644
--- a/public/v7-age-adaptive.html
+++ b/public/v7-age-adaptive.html
@@ -198,6 +198,31 @@
 <!-- Toast for transient feedback -->
 <div class="toast" id="toast"></div>
 
+<!-- First-visit age prompt (skipped on returning visits via localStorage) -->
+<div class="modal-back" id="ageGate" style="display:none; align-items:center; justify-content:center; background:rgba(0,0,0,0.85);">
+  <div class="modal" style="max-width:560px; text-align:center; padding:32px 28px;">
+    <h2 style="margin:0 0 6px; font-size:24px;">Welcome to the age-adaptive UI</h2>
+    <div style="color:var(--muted); font-size:14px; margin-bottom:22px; line-height:1.5;">
+      This page changes the entire interface — colors, type, density, copy, voice — based on the viewer's age.<br>
+      <span style="color:#9cb6dd;">How old are you?</span> (You can change this any time with the slider.)
+    </div>
+    <input id="ageGateSlider" type="range" min="3" max="95" value="30" style="width:100%; accent-color:var(--accent); margin-bottom:8px;" />
+    <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
+      <span style="font-size:11px; color:var(--muted);">3</span>
+      <div style="font-size:48px; font-weight:700; color:var(--accent); line-height:1;"><span id="ageGateNum">30</span> <span style="font-size:18px; color:var(--muted); font-weight:400;">yr</span></div>
+      <span style="font-size:11px; color:var(--muted);">95</span>
+    </div>
+    <div id="ageGateBand" style="color:#9cb6dd; font-size:13px; text-transform:uppercase; letter-spacing:2px; margin-bottom:4px;">adult</div>
+    <div id="ageGateTagline" style="color:var(--muted); font-size:12px; margin-bottom:22px; min-height:32px;">Modern dashboard. Sort + density + full Hermes panel.</div>
+    <div style="display:flex; gap:10px; justify-content:center; flex-wrap:wrap; margin-bottom:14px;" id="ageGatePresets"></div>
+    <div style="display:flex; gap:10px; justify-content:center;">
+      <button class="action-btn" onclick="acceptAge()" style="padding:12px 32px; font-size:15px;">Use this age</button>
+      <button class="action-btn" onclick="acceptAge(30, true)" style="background:transparent; color:var(--muted); border:1px solid var(--border); padding:12px 24px; font-size:13px;">Skip (use 30)</button>
+    </div>
+    <div style="font-size:10px; color:var(--muted); margin-top:14px;">Saved on this device. Clear in browser data to ask again.</div>
+  </div>
+</div>
+
 <div class="container">
   <h1 id="hed">Failures</h1>
   <div class="sub" id="dek">A reading of what's going wrong, tuned to your age.</div>
@@ -619,6 +644,55 @@ renderGrid = function(b) {
   _origRenderGrid(b);
 };
 
+// ===== Age-on-login gate =====
+const AGE_PREF_KEY = 'v7-age-pref';
+function showAgeGate() {
+  const gate = document.getElementById('ageGate');
+  if (!gate) return;
+  gate.style.display = 'flex';
+  const g = document.getElementById('ageGateSlider');
+  const num = document.getElementById('ageGateNum');
+  const bandEl = document.getElementById('ageGateBand');
+  const taglineEl = document.getElementById('ageGateTagline');
+  const presetsEl = document.getElementById('ageGatePresets');
+
+  presetsEl.innerHTML = BAND_PRESETS.map(p =>
+    `<button class="pill-btn" onclick="document.getElementById('ageGateSlider').value=${p.age}; document.getElementById('ageGateSlider').dispatchEvent(new Event('input'));">${p.label}</button>`
+  ).join('');
+
+  function update() {
+    const a = Number(g.value);
+    const b = bandFor(a);
+    num.textContent = a;
+    bandEl.textContent = b.label;
+    taglineEl.textContent = b.tagline;
+  }
+  g.addEventListener('input', update);
+  update();
+}
+
+function acceptAge(forced, skip) {
+  const age = forced || Number(document.getElementById('ageGateSlider').value);
+  try { localStorage.setItem(AGE_PREF_KEY, String(age)); } catch {}
+  document.getElementById('ageGate').style.display = 'none';
+  slider.value = age;
+  applyBand(age);
+}
+
+// On load: read saved age pref, or show gate.
+(function initAgePref(){
+  let saved = null;
+  try { saved = localStorage.getItem(AGE_PREF_KEY); } catch {}
+  if (saved && !isNaN(Number(saved))) {
+    slider.value = saved;
+    // applyBand will run when loadData → renderGrid finishes; set body immediately too.
+    document.body.dataset.band = bandFor(Number(saved)).id;
+  } else {
+    // first-time visit
+    setTimeout(showAgeGate, 250);
+  }
+})();
+
 loadData();
 </script>
 </body></html>
diff --git a/server.js b/server.js
index 90c33a3..7f37dbc 100644
--- a/server.js
+++ b/server.js
@@ -28,18 +28,18 @@ function readEnvFromSecrets(key) {
   } 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
+// 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 });
@@ -93,17 +93,17 @@ function sendSeed(res, isFallback, errMsg) {
   }
 }
 
-// GET /api/voice?text=...&band=...  →  MP3 of Steve's cloned voice, pitch-shifted to the age band.
+// 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' });
-  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 voiceId = BAND_VOICE_IDS[band];
+  if (!voiceId) return res.status(400).json({ error: 'unknown band: ' + band });
 
-  const hash = crypto.createHash('sha256').update(text + '|' + band).digest('hex').slice(0, 24);
+  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');
@@ -112,16 +112,10 @@ app.get('/api/voice', async (req, res) => {
     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}`, {
+    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'
-      },
+      headers: { 'xi-api-key': EL_API_KEY, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
       body: JSON.stringify({
         text,
         model_id: 'eleven_turbo_v2_5',
@@ -130,45 +124,24 @@ app.get('/api/voice', async (req, res) => {
     });
     if (!r.ok) {
       const body = await r.text().catch(() => '');
-      console.error('[voice] ElevenLabs', r.status, body.slice(0, 200));
+      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) });
     }
-    mp3Buf = Buffer.from(await r.arrayBuffer());
+    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] EL fetch threw:', e.message);
+    console.error('[voice] 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) }
+  voice: { el_key_present: !!EL_API_KEY, bands: BAND_VOICE_IDS }
 }));
 
 app.listen(PORT, BIND, () => {

← 2f518c5 v7 voice: replace Web Speech with Steve's ElevenLabs cloned  ·  back to Cncp Failures Mockups  ·  cncp mockups: make entire card clickable to open variant + n 71f59dd →