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