[object Object]

← back to Cncp Failures Mockups

v7 voices: 8 per-band TTS profiles (voice/pitch/rate) via Web Speech API; greeting on band change, blurbs in modal open, toast narration; mute toggle

3ab046b2b87d64c103b3d8ceebd464992690a829 · 2026-05-11 16:12:18 -0700 · SteveStudio2

Files touched

Diff

commit 3ab046b2b87d64c103b3d8ceebd464992690a829
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Mon May 11 16:12:18 2026 -0700

    v7 voices: 8 per-band TTS profiles (voice/pitch/rate) via Web Speech API; greeting on band change, blurbs in modal open, toast narration; mute toggle
---
 public/v7-age-adaptive.html | 94 +++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 91 insertions(+), 3 deletions(-)

diff --git a/public/v7-age-adaptive.html b/public/v7-age-adaptive.html
index 7ab906d..7bfbd82 100644
--- a/public/v7-age-adaptive.html
+++ b/public/v7-age-adaptive.html
@@ -182,6 +182,7 @@
     <div class="age-band-label" id="bandLabel">toddler</div>
   </div>
   <input id="ageSlider" type="range" min="3" max="95" value="3" />
+  <button id="voiceToggle" class="pill-btn" onclick="toggleVoice()" title="Toggle voice narration" style="margin-left:auto;">🔊 voice on</button>
   <div class="tagline" id="tagline">Primary colors, huge taps. One face = good. One face = sad.</div>
   <div class="pills" id="bandPills"></div>
 </div>
@@ -228,6 +229,7 @@ const bandLabel = document.getElementById('bandLabel');
 const tagline = document.getElementById('tagline');
 const hed = document.getElementById('hed');
 
+let _lastSpokenBand = null;
 function applyBand(age) {
   const b = bandFor(age);
   document.body.dataset.band = b.id;
@@ -237,6 +239,21 @@ function applyBand(age) {
   hed.textContent = b.hed;
   renderPills(b.id);
   renderGrid(b);
+  // Voice greeting on band CHANGE (skip on initial render + don't spam every slider tick).
+  if (_lastSpokenBand !== b.id) {
+    _lastSpokenBand = b.id;
+    const greetings = {
+      toddler: 'Hi friend! Tap the face!',
+      kid:     `Hi! You're in kid mode. Click a card to learn more.`,
+      tween:   `Tween view. Open any item for details.`,
+      teen:    `Teen mode. Dense and dark. Click open for the full record.`,
+      adult:   `Adult dashboard. Click open for full details and actions.`,
+      mature:  `Mature view. Calmer pacing, larger type.`,
+      senior:  `Senior view. High contrast and large buttons. Click view details when ready.`,
+      elder:   `Elder view. One item at a time. Click done when you've handled it.`
+    };
+    speak(greetings[b.id] || '', b.id);
+  }
 }
 
 slider.addEventListener('input', e => applyBand(Number(e.target.value)));
@@ -383,6 +400,66 @@ function renderGrid(b) {
   }
 }
 
+// ===== VOICE — per-band TTS via browser Web Speech API =====
+// Each band picks a voice by ordered name preferences, plus pitch + rate, so the same toast
+// sounds different at every age. Falls back to the default voice if none of the preferences
+// are installed on the user's OS.
+const BAND_VOICES = {
+  toddler: { prefs:['Princess','Junior','Kathy','Samantha','Karen'], pitch:1.6, rate:1.05, volume:1 },
+  kid:     { prefs:['Karen','Samantha','Tessa','Moira','Veena'],     pitch:1.35, rate:1.0,  volume:1 },
+  tween:   { prefs:['Samantha','Allison','Karen','Tessa'],            pitch:1.15, rate:1.05, volume:1 },
+  teen:    { prefs:['Daniel','Aaron','Fred','Tom','Alex'],            pitch:1.0,  rate:1.08, volume:1 },
+  adult:   { prefs:['Samantha','Alex','Allison','Karen'],             pitch:1.0,  rate:1.0,  volume:1 },
+  mature:  { prefs:['Veena','Moira','Tessa','Samantha','Karen'],      pitch:0.98, rate:0.95, volume:1 },
+  senior:  { prefs:['Alex','Tom','Daniel','Aaron','Fred'],            pitch:0.95, rate:0.88, volume:1 },
+  elder:   { prefs:['Bruce','Tom','Fred','Alex'],                     pitch:0.85, rate:0.78, volume:1 }
+};
+let _voiceCache = null;
+let _voiceEnabled = true;
+let _selectedVoices = {};   // band -> SpeechSynthesisVoice
+
+function loadVoices() {
+  if (!('speechSynthesis' in window)) return [];
+  _voiceCache = window.speechSynthesis.getVoices();
+  // Match each band's preferences against installed voices
+  _selectedVoices = {};
+  for (const [band, cfg] of Object.entries(BAND_VOICES)) {
+    let chosen = null;
+    for (const pref of cfg.prefs) {
+      chosen = _voiceCache.find(v => v.name === pref || v.name.startsWith(pref + ' '));
+      if (chosen) break;
+    }
+    _selectedVoices[band] = chosen || _voiceCache.find(v => v.lang.startsWith('en')) || _voiceCache[0] || null;
+  }
+  return _voiceCache;
+}
+// Voices populate async on first page load.
+if ('speechSynthesis' in window) {
+  loadVoices();
+  window.speechSynthesis.onvoiceschanged = loadVoices;
+}
+
+function speak(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);
+  if (_selectedVoices[band]) u.voice = _selectedVoices[band];
+  u.pitch = cfg.pitch;
+  u.rate = cfg.rate;
+  u.volume = cfg.volume;
+  window.speechSynthesis.speak(u);
+}
+
+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);
+}
+
 // ===== UI HOOKS — every band's primary action wired here. =====
 // In-memory state for the demo (no API writes); persists for the page session.
 const _localActions = new Map();   // failureId -> { resolved, dismissed, refined, opened, celebrated }
@@ -392,6 +469,7 @@ function toast(msg, ms = 1800) {
   el.textContent = msg;
   el.classList.add('show');
   setTimeout(() => el.classList.remove('show'), ms);
+  speak(msg, bandFor(Number(slider.value)).id);
 }
 
 function openModal(html) {
@@ -463,13 +541,12 @@ function onAction(band, id, btn) {
   const f = findFailure(id);
   if (!f) { toast('item not found'); return; }
   if (band === 'toddler') {
-    // celebrate on tap
     _localActions.set(id, { ...(_localActions.get(id)||{}), celebrated: true });
     openModal(`<div class="toddler-celebrate">🎉</div><div style="text-align:center; font-size:1.3em; font-weight:700; margin-top:14px;">Good job!</div><div class="modal-actions" style="justify-content:center;"><button class="action-btn" onclick="closeModal()">Okay!</button></div>`);
+    speak('Good job!', 'toddler');
     return;
   }
   if (band === 'elder') {
-    // confirm-resolve in one step
     openModal(`
       <h3 style="font-size:1.4em;">Mark as done?</h3>
       <div style="margin:14px 0; font-size:1.15em;">${f.suggestedAction || f.processName}</div>
@@ -477,10 +554,21 @@ function onAction(band, id, btn) {
         <button class="action-btn" onclick="markResolved('${id}')">Yes, done</button>
         <button class="action-btn" style="background:transparent; color:var(--fg); border:2px solid var(--border);" onclick="closeModal()">Not yet</button>
       </div>`);
+    speak(`Mark as done? ${f.suggestedAction || f.processName}`, 'elder');
     return;
   }
-  // all other bands → detail modal
+  // all other bands → detail modal + spoken summary
   openModal(detailHTML(f, band));
+  // Band-tuned spoken summary
+  const blurbs = {
+    kid:    `Looking at ${f.processName}. ${plainLabel(f)}`,
+    tween:  `${f.processName}. Severity ${f.severity || 'unset'}.`,
+    teen:   `${f.processName}. ${f.severity || '?'} severity. ${f.eventCount} events.`,
+    adult:  `${f.processName}. ${f.severity || 'unset'} severity. ${f.suggestedAction || ''}`,
+    mature: `${f.processName}. Severity ${f.severity || 'unset'}. ${f.suggestedAction || ''}`,
+    senior: `${f.processName}. ${f.severity || 'unset'} priority. ${f.suggestedAction || 'No action required.'}`
+  };
+  speak(blurbs[band] || f.processName, band);
 }
 
 // Re-render needs to respect _localActions so resolved items show as resolved.

← 788f2be v7 UI hooks: 8 band quick-jump pills, per-band modal/toast h  ·  back to Cncp Failures Mockups  ·  v7 voice: replace Web Speech with Steve's ElevenLabs cloned 2f518c5 →