Here is a detailed prompt you can use with any code-generation AI to build a fully functional, self-contained talking avatar:
I will now generate the complete, self-contained single-file HTML application featuring an animated 2D SVG talking avatar with real-time lip-sync, expression movements, natural idle behaviors, and a Web Speech TTS dashboard.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive 2D Talking Avatar (Web Speech TTS)</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
background: radial-gradient(circle at 10% 20%, rgb(18, 22, 38) 0%, rgb(8, 10, 18) 90.2%);
color: #e2e8f0;
min-height: 100vh;
overflow-x: hidden;
}
h1, h2, h3, .font-heading {
font-family: 'Outfit', sans-serif;
}
/* Ambient glow elements */
.glow-sphere {
position: absolute;
border-radius: 9999px;
filter: blur(80px);
pointer-events: none;
z-index: 0;
opacity: 0.35;
animation: floatGlow 12s ease-in-out infinite alternate;
}
@keyframes floatGlow {
0% { transform: translate(0, 0) scale(1); }
100% { transform: translate(30px, -20px) scale(1.15); }
}
/* Avatar idle breathing & gentle sway */
.avatar-wrapper {
animation: avatarBreath 5.5s ease-in-out infinite alternate;
transform-origin: bottom center;
}
@keyframes avatarBreath {
0% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-7px) rotate(0.4deg); }
100% { transform: translateY(-3px) rotate(-0.3deg); }
}
/* Natural idle blinking */
.eye-lid {
transform-origin: center;
transition: transform 0.08s ease-in-out;
}
.blinking .eye-lid {
transform: scaleY(0.05);
}
/* Pupils smooth shifting */
.pupil-group {
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
}
/* Eyebrow expressive animation */
.eyebrow-left, .eyebrow-right {
transition: transform 0.25s ease-out;
transform-origin: center;
}
.eyebrows-expressive-up .eyebrow-left {
transform: translateY(-5px) rotate(-3deg);
}
.eyebrows-expressive-up .eyebrow-right {
transform: translateY(-5px) rotate(3deg);
}
.eyebrows-expressive-down .eyebrow-left {
transform: translateY(2px) rotate(3deg);
}
.eyebrows-expressive-down .eyebrow-right {
transform: translateY(2px) rotate(-3deg);
}
/* Viseme Mouth Transitions */
.viseme-path {
transition: d 0.08s cubic-bezier(0.2, 0.8, 0.4, 1.2), fill 0.15s ease;
}
/* Custom Glassmorphism */
.glass-card {
background: rgba(22, 28, 45, 0.72);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
/* Audio Equalizer Wave Bar Animation */
.audio-bar {
transition: height 0.1s ease;
}
.speaking .audio-bar:nth-child(1) { animation: soundBar 0.5s ease-in-out infinite alternate; }
.speaking .audio-bar:nth-child(2) { animation: soundBar 0.38s ease-in-out infinite alternate 0.1s; }
.speaking .audio-bar:nth-child(3) { animation: soundBar 0.6s ease-in-out infinite alternate 0.2s; }
.speaking .audio-bar:nth-child(4) { animation: soundBar 0.42s ease-in-out infinite alternate 0.15s; }
.speaking .audio-bar:nth-child(5) { animation: soundBar 0.52s ease-in-out infinite alternate 0.05s; }
@keyframes soundBar {
0% { height: 4px; }
100% { height: 22px; }
}
/* Range slider customizations */
input[type=range] {
-webkit-appearance: none;
background: #1e293b;
border-radius: 9999px;
height: 6px;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 18px;
width: 18px;
border-radius: 50%;
background: #6366f1;
box-shadow: 0 0 12px rgba(99, 102, 241, 0.8);
cursor: pointer;
transition: transform 0.1s ease, background 0.2s;
}
input[type=range]::-webkit-slider-thumb:hover {
transform: scale(1.15);
background: #818cf8;
}
</style>
</head>
<body class="relative flex flex-col justify-between p-4 md:p-8">
<div class="glow-sphere w-96 h-96 bg-indigo-600 top-4 left-1/4"></div>
<div class="glow-sphere w-80 h-80 bg-purple-600 bottom-10 right-1/4"></div>
<!-- Header Section -->
<header class="relative z-10 max-w-6xl mx-auto w-full flex items-center justify-between py-2 border-b border-slate-800/80 mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-indigo-500 to-purple-500 flex items-center justify-center shadow-lg shadow-indigo-500/25">
<svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 100-6 3 3 0 000 6z" />
</svg>
</div>
<div>
<h1 class="text-xl font-bold tracking-tight text-white flex items-center gap-2">
VoxAvatar <span class="text-xs px-2 py-0.5 rounded-full font-medium bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">TTS Engine</span>
</h1>
<p class="text-xs text-slate-400">Web Speech API • Real-Time Lip-Sync & Micro Expressions</p>
</div>
</div>
<!-- Live Status Pill -->
<div id="statusBadge" class="flex items-center gap-2 px-3 py-1.5 rounded-full glass-card text-xs font-medium text-slate-300">
<span id="statusIndicatorDot" class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
<span id="statusText">Ready</span>
<div id="audioWave" class="hidden items-center gap-0.5 ml-1 h-5">
<div class="audio-bar w-1 bg-indigo-400 rounded-full h-1"></div>
<div class="audio-bar w-1 bg-indigo-400 rounded-full h-2"></div>
<div class="audio-bar w-1 bg-indigo-400 rounded-full h-3"></div>
<div class="audio-bar w-1 bg-indigo-400 rounded-full h-2"></div>
<div class="audio-bar w-1 bg-indigo-400 rounded-full h-1"></div>
</div>
</div>
</header>
<main class="relative z-10 max-w-6xl mx-auto w-full grid grid-cols-1 lg:grid-cols-12 gap-8 items-center flex-grow">
<!-- Left Column: Interactive Avatar Display Card -->
<section class="lg:col-span-5 flex flex-col items-center">
<div class="w-full relative flex flex-col items-center p-6 md:p-8 rounded-3xl glass-card">
<!-- Subtle Avatar Stage Spotlight -->
<div class="absolute -top-10 w-44 h-44 bg-indigo-500/20 rounded-full filter blur-2xl pointer-events-none"></div>
<!-- SVG 2D Talking Avatar -->
<div class="avatar-wrapper relative w-64 h-72 sm:w-72 sm:h-80 flex items-center justify-center">
<svg id="avatarSvg" viewBox="0 0 320 380" class="w-full h-full drop-shadow-2xl overflow-visible" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Skin Gradient -->
<linearGradient id="skinGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#fed7aa"/>
<stop offset="100%" stop-color="#fba879"/>
</linearGradient>
<!-- Hair Gradient -->
<linearGradient id="hairGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#4f46e5"/>
<stop offset="60%" stop-color="#2e1065"/>
<stop offset="100%" stop-color="#1e1b4b"/>
</linearGradient>
<!-- Torso Gradient -->
<linearGradient id="clothGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#1e293b"/>
</linearGradient>
<!-- Cheek Blush -->
<radialGradient id="blushGrad">
<stop offset="0%" stop-color="#f43f5e" stop-opacity="0.3"/>
<stop offset="100%" stop-color="#f43f5e" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- Torso & Shoulders -->
<path d="M70,360 Q160,335 250,360 L270,400 L50,400 Z" fill="url(#clothGrad)" stroke="#334155" stroke-width="2"/>
<path d="M125,348 Q160,375 195,348 Z" fill="#fba879"/>
<!-- Collar accent -->
<path d="M110,352 Q160,380 210,352" stroke="#6366f1" stroke-width="2" fill="none" opacity="0.8"/>
<!-- Neck -->
<rect x="135" y="240" width="50" height="70" rx="10" fill="#fba879"/>
<path d="M135,275 C145,290 175,290 185,275" stroke="#ea580c" stroke-width="2" fill="none" opacity="0.25"/>
<!-- Head Base -->
<ellipse cx="160" cy="180" rx="82" ry="98" fill="url(#skinGrad)" />
<!-- Soft Blush Marks -->
<circle cx="106" cy="198" r="16" fill="url(#blushGrad)"/>
<circle cx="214" cy="198" r="16" fill="url(#blushGrad)"/>
<!-- Eyes Group with Blink & Pupil Tracker -->
<g id="eyesGroup">
<!-- Left Eye Outer -->
<g class="eye-lid" id="eyeLeftContainer" style="transform-origin: 114px 165px;">
<ellipse cx="114" cy="165" rx="17" ry="12" fill="#ffffff" stroke="#cbd5e1" stroke-width="1"/>
<!-- Left Pupil & Iris -->
<g class="pupil-group" id="pupilLeft">
<circle cx="114" cy="165" r="7.5" fill="#312e81"/>
<circle cx="114" cy="165" r="4.5" fill="#0f172a"/>
<circle cx="112" cy="162" r="2.2" fill="#ffffff"/>
</g>
<!-- Upper Eyelash line -->
<path d="M96,165 Q114,151 132,165" fill="none" stroke="#1e293b" stroke-width="2.5" stroke-linecap="round"/>
</g>
<!-- Right Eye Outer -->
<g class="eye-lid" id="eyeRightContainer" style="transform-origin: 206px 165px;">
<ellipse cx="206" cy="165" rx="17" ry="12" fill="#ffffff" stroke="#cbd5e1" stroke-width="1"/>
<!-- Right Pupil & Iris -->
<g class="pupil-group" id="pupilRight">
<circle cx="206" cy="165" r="7.5" fill="#312e81"/>
<circle cx="206" cy="165" r="4.5" fill="#0f172a"/>
<circle cx="204" cy="162" r="2.2" fill="#ffffff"/>
</g>
<!-- Upper Eyelash line -->
<path d="M188,165 Q206,151 224,165" fill="none" stroke="#1e293b" stroke-width="2.5" stroke-linecap="round"/>
</g>
</g>
<!-- Eyebrows (Dynamic Expressions) -->
<g id="eyebrowsGroup">
<path class="eyebrow-left" d="M95,142 Q114,134 133,142" stroke="#1e1b4b" stroke-width="3.5" stroke-linecap="round" fill="none"/>
<path class="eyebrow-right" d="M187,142 Q206,134 225,142" stroke="#1e1b4b" stroke-width="3.5" stroke-linecap="round" fill="none"/>
</g>
<!-- Cute Minimal Nose -->
<path d="M157,178 Q160,192 165,190" fill="none" stroke="#ea580c" stroke-width="2.2" stroke-linecap="round" opacity="0.6"/>
<g id="mouthGroup" transform="translate(0, 0)">
<!-- Mouth Interior Cavity -->
<path id="mouthCavity" class="viseme-path"
d="M136,224 Q160,228 184,224 Q160,230 136,224 Z"
fill="#881337"
/>
<!-- Teeth top -->
<path id="mouthTeeth" class="viseme-path"
d="M142,224 Q160,225 178,224 Q160,226 142,224 Z"
fill="#ffffff"
/>
<!-- Tongue bottom -->
<path id="mouthTongue" class="viseme-path"
d="M148,228 Q160,226 172,228 Q160,230 148,228 Z"
fill="#f43f5e"
/>
<!-- Lip Outline / Smile line -->
<path id="mouthLips" class="viseme-path"
d="M136,224 Q160,232 184,224"
fill="none"
stroke="#9f1239"
stroke-width="2.8"
stroke-linecap="round"
/>
</g>
<!-- Stylish Modern Haircut & Bangs -->
<!-- Back Hair Shadow -->
<path d="M80,160 Q70,90 160,70 Q250,90 240,160 Q260,220 250,250 Q230,265 220,240 Q250,110 160,95 Q70,110 100,240 Q90,265 70,250 Z" fill="#1e1b4b" opacity="0.4"/>
<!-- Front Swoop Hair -->
<path d="M80,160 C65,100 110,65 160,65 C220,65 255,100 240,160 C235,120 205,95 160,98 C120,95 85,115 80,160 Z" fill="url(#hairGrad)" />
<!-- Stylish Bangs overlapping face -->
<path d="M84,140 Q130,85 185,120 Q140,105 110,140 Z" fill="#4338ca"/>
<path d="M236,140 Q200,85 145,115 Q185,105 210,140 Z" fill="#3730a3"/>
</svg>
</div>
<!-- Viseme State Indicator Badge -->
<div class="mt-3 flex items-center justify-between w-full px-4 text-xs font-mono text-slate-400">
<span>Viseme State:</span>
<span id="visemeName" class="text-indigo-400 font-semibold uppercase bg-indigo-950/60 px-2 py-0.5 rounded border border-indigo-800/40">REST</span>
</div>
</div>
</section>
<!-- Right Column: Speech Control Dashboard -->
<section class="lg:col-span-7 flex flex-col gap-5">
<div class="p-6 md:p-7 rounded-3xl glass-card flex flex-col gap-5">
<!-- Speech Text Input -->
<div>
<div class="flex justify-between items-center mb-2">
<label for="textInput" class="text-sm font-semibold tracking-wide text-slate-200">Spoken Script</label>
<span id="charCount" class="text-xs text-slate-400 font-mono">0 chars</span>
</div>
<textarea id="textInput" rows="4"
class="w-full bg-slate-900/80 border border-slate-700/80 rounded-2xl p-4 text-sm md:text-base text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-none shadow-inner"
placeholder="Type anything here for the avatar to say with animated lip synchronization...">Hello there! I am your interactive speech synthesis avatar. You can type anything you want, adjust my pitch and speaking rate, or try the quick sample presets below. Watch my mouth sync to every word!</textarea>
</div>
<!-- Quick Sample Presets -->
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-medium text-slate-400 mr-1">Quick Presets:</span>
<button type="button" class="preset-btn px-2.5 py-1 text-xs font-medium rounded-lg bg-slate-800 hover:bg-indigo-600/30 text-indigo-300 border border-slate-700 hover:border-indigo-500/50 transition">
👋 Welcome
</button>
<button type="button" class="preset-btn px-2.5 py-1 text-xs font-medium rounded-lg bg-slate-800 hover:bg-indigo-600/30 text-indigo-300 border border-slate-700 hover:border-indigo-500/50 transition">
😄 Tell a joke
</button>
<button type="button" class="preset-btn px-2.5 py-1 text-xs font-medium rounded-lg bg-slate-800 hover:bg-indigo-600/30 text-indigo-300 border border-slate-700 hover:border-indigo-500/50 transition">
🚀 Tech quote
</button>
<button type="button" class="preset-btn px-2.5 py-1 text-xs font-medium rounded-lg bg-slate-800 hover:bg-indigo-600/30 text-indigo-300 border border-slate-700 hover:border-indigo-500/50 transition">
⚡ Tongue twister
</button>
</div>
<!-- Voice Selection -->
<div>
<label for="voiceSelect" class="block text-xs font-semibold uppercase tracking-wider text-slate-400 mb-2">Synthetic Voice</label>
<div class="relative">
<select id="voiceSelect" class="w-full bg-slate-900/90 border border-slate-700 rounded-xl px-4 py-3 text-sm text-slate-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 appearance-none pr-10 cursor-pointer">
<option value="">Searching for available system voices...</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-slate-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</div>
</div>
</div>
<!-- Sliders: Rate & Pitch -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Speed / Rate Slider -->
<div class="bg-slate-900/50 p-3.5 rounded-2xl border border-slate-800">
<div class="flex justify-between items-center mb-2">
<span class="text-xs font-medium text-slate-300">Speaking Rate</span>
<span id="rateVal" class="text-xs font-mono font-bold text-indigo-400">1.0x</span>
</div>
<input id="rateSlider" type="range" min="0.5" max="2.0" step="0.1" value="1.0" class="w-full">
<div class="flex justify-between text-[10px] text-slate-500 mt-1 font-mono">
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
<!-- Pitch Slider -->
<div class="bg-slate-900/50 p-3.5 rounded-2xl border border-slate-800">
<div class="flex justify-between items-center mb-2">
<span class="text-xs font-medium text-slate-300">Voice Pitch</span>
<span id="pitchVal" class="text-xs font-mono font-bold text-indigo-400">1.0</span>
</div>
<input id="pitchSlider" type="range" min="0.5" max="2.0" step="0.1" value="1.0" class="w-full">
<div class="flex justify-between text-[10px] text-slate-500 mt-1 font-mono">
<span>Low (0.5)</span>
<span>Normal</span>
<span>High (2.0)</span>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-3 pt-2">
<!-- Play / Speak Button -->
<button id="speakBtn" class="flex-1 bg-gradient-to-r from-indigo-500 to-indigo-600 hover:from-indigo-600 hover:to-indigo-700 text-white font-semibold py-3 px-6 rounded-2xl shadow-lg shadow-indigo-500/25 flex items-center justify-center gap-2 transition transform active:scale-95">
<svg class="w-5 h-5 fill-current" viewBox="0 0 20 20">
<path d="M4 4l12 6-12 6V4z" />
</svg>
<span id="speakBtnText">Speak Script</span>
</button>
<!-- Pause / Resume Button -->
<button id="pauseBtn" class="px-5 py-3 rounded-2xl bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 font-medium flex items-center justify-center gap-2 transition active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed" disabled>
<svg class="w-5 h-5 fill-current" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<span id="pauseBtnText">Pause</span>
</button>
<!-- Stop Button -->
<button id="stopBtn" class="px-5 py-3 rounded-2xl bg-rose-950/40 hover:bg-rose-900/60 text-rose-300 border border-rose-800/40 font-medium flex items-center justify-center gap-2 transition active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed" disabled>
<svg class="w-5 h-5 fill-current" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd" />
</svg>
<span>Stop</span>
</button>
</div>
</div>
</section>
</main>
<!-- Notification Toast message box (No alert used) -->
<div id="toastMessage" class="fixed bottom-6 left-1/2 -translate-x-1/2 px-4 py-2.5 rounded-xl bg-slate-900 border border-indigo-500/40 text-indigo-300 text-xs shadow-2xl transition-all duration-300 opacity-0 pointer-events-none z-50 flex items-center gap-2">
<svg class="w-4 h-4 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span id="toastText">TTS message</span>
</div>
<footer class="relative z-10 max-w-6xl mx-auto w-full text-center text-xs text-slate-500 py-4 mt-6">
Powered by HTML5 Web Speech Synthesis API & SVG morphing • Responsive & self-contained
</footer>
<script>
/* =========================================================
1. VISEME SHAPE DEFINITIONS (SVG PATHS FOR MOUTH STATES)
========================================================= */
const VISEMES = {
rest: {
name: 'rest',
cavity: 'M136,224 Q160,228 184,224 Q160,230 136,224 Z',
teeth: 'M142,224 Q160,225 178,224 Q160,226 142,224 Z',
tongue: 'M148,228 Q160,226 172,228 Q160,230 148,228 Z',
lips: 'M136,224 Q160,232 184,224'
},
smallOpen: {
name: 'small (b, m, p, d)',
cavity: 'M136,221 Q160,215 184,221 Q160,236 136,221 Z',
teeth: 'M140,220 Q160,220 180,220 Q160,226 140,220 Z',
tongue: 'M146,233 Q160,229 174,233 Q160,237 146,233 Z',
lips: 'M134,221 Q160,216 186,221 Q160,238 134,221 Z'
},
wideAh: {
name: 'open ah (a, i)',
cavity: 'M133,217 Q160,207 187,217 Q160,252 133,217 Z',
teeth: 'M139,216 Q160,216 181,216 Q160,226 139,216 Z',
tongue: 'M143,246 Q160,236 177,246 Q160,253 143,246 Z',
lips: 'M131,217 Q160,206 189,217 Q160,256 131,217 Z'
},
roundOh: {
name: 'round oh (o, u, w)',
cavity: 'M145,217 Q160,212 175,217 Q178,241 160,243 Q142,241 145,217 Z',
teeth: 'M148,217 Q160,216 172,217 Q160,222 148,217 Z',
tongue: 'M150,238 Q160,235 170,238 Q160,243 150,238 Z',
lips: 'M143,217 Q160,210 177,217 Q181,244 160,246 Q139,244 143,217 Z'
},
grinEe: {
name: 'grin ee (e, s, t)',
cavity: 'M130,222 Q160,216 190,222 Q160,238 130,222 Z',
teeth: 'M134,221 Q160,222 186,221 Q160,230 134,221 Z',
tongue: 'M142,233 Q160,230 178,233 Q160,237 142,233 Z',
lips: 'M128,222 Q160,216 192,222 Q160,240 128,222 Z'
}
};
// DOM Elements
const mouthCavity = document.getElementById('mouthCavity');
const mouthTeeth = document.getElementById('mouthTeeth');
const mouthTongue = document.getElementById('mouthTongue');
const mouthLips = document.getElementById('mouthLips');
const visemeName = document.getElementById('visemeName');
const eyeLeftContainer = document.getElementById('eyeLeftContainer');
const eyeRightContainer = document.getElementById('eyeRightContainer');
const pupilLeft = document.getElementById('pupilLeft');
const pupilRight = document.getElementById('pupilRight');
const eyebrowsGroup = document.getElementById('eyebrowsGroup');
const textInput = document.getElementById('textInput');
const voiceSelect = document.getElementById('voiceSelect');
const rateSlider = document.getElementById('rateSlider');
const pitchSlider = document.getElementById('pitchSlider');
const rateVal = document.getElementById('rateVal');
const pitchVal = document.getElementById('pitchVal');
const charCount = document.getElementById('charCount');
const speakBtn = document.getElementById('speakBtn');
const speakBtnText = document.getElementById('speakBtnText');
const pauseBtn = document.getElementById('pauseBtn');
const pauseBtnText = document.getElementById('pauseBtnText');
const stopBtn = document.getElementById('stopBtn');
const statusBadge = document.getElementById('statusBadge');
const statusText = document.getElementById('statusText');
const statusIndicatorDot = document.getElementById('statusIndicatorDot');
const audioWave = document.getElementById('audioWave');
const toast = document.getElementById('toastMessage');
const toastText = document.getElementById('toastText');
const presetButtons = document.querySelectorAll('.preset-btn');
// Synthesis and cadence variables
let voices = [];
let currentUtterance = null;
let isSpeaking = false;
let isPaused = false;
let lipSyncInterval = null;
let eyebrowInterval = null;
let blinkInterval = null;
let glanceInterval = null;
function applyViseme(shapeKey) {
const v = VISEMES[shapeKey] || VISEMES.rest;
mouthCavity.setAttribute('d', v.cavity);
mouthTeeth.setAttribute('d', v.teeth);
mouthTongue.setAttribute('d', v.tongue);
mouthLips.setAttribute('d', v.lips);
visemeName.textContent = v.name;
}
// Reset face to calm resting neutral state
function resetFace() {
applyViseme('rest');
eyebrowsGroup.classList.remove('eyebrows-expressive-up', 'eyebrows-expressive-down');
}
// Dynamic lip cycle when speaking
const speechVisemes = ['smallOpen', 'wideAh', 'roundOh', 'grinEe'];
function stepLipMovement() {
if (!isSpeaking || isPaused) return;
// Weighted random viseme to mimic spoken syllables
const nextViseme = speechVisemes[Math.floor(Math.random() * speechVisemes.length)];
applyViseme(nextViseme);
}
function setupIdleAnimations() {
// Natural Blinking (every 3 to 6 seconds)
function scheduleNextBlink() {
const delay = Math.random() * 3000 + 2500;
blinkInterval = setTimeout(() => {
eyeLeftContainer.classList.add('blinking');
eyeRightContainer.classList.add('blinking');
setTimeout(() => {
eyeLeftContainer.classList.remove('blinking');
eyeRightContainer.classList.remove('blinking');
scheduleNextBlink();
}, 150);
}, delay);
}
scheduleNextBlink();
// Subtle Random Eye Glancing
function scheduleNextGlance() {
const delay = Math.random() * 4000 + 3000;
glanceInterval = setTimeout(() => {
if (!isSpeaking) {
const offsetX = (Math.random() - 0.5) * 8; // -4 to +4 px
const offsetY = (Math.random() - 0.5) * 4; // -2 to +2 px
pupilLeft.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
pupilRight.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
setTimeout(() => {
pupilLeft.style.transform = `translate(0px, 0px)`;
pupilRight.style.transform = `translate(0px, 0px)`;
}, 1200);
}
scheduleNextGlance();
}, delay);
}
scheduleNextGlance();
}
// Expressive eyebrow twitches during speech
function startEyebrowSync() {
stopEyebrowSync();
eyebrowInterval = setInterval(() => {
if (!isSpeaking || isPaused) return;
const rand = Math.random();
eyebrowsGroup.classList.remove('eyebrows-expressive-up', 'eyebrows-expressive-down');
if (rand > 0.6) {
eyebrowsGroup.classList.add('eyebrows-expressive-up');
} else if (rand > 0.4) {
eyebrowsGroup.classList.add('eyebrows-expressive-down');
}
}, 700);
}
function stopEyebrowSync() {
if (eyebrowInterval) clearInterval(eyebrowInterval);
eyebrowsGroup.classList.remove('eyebrows-expressive-up', 'eyebrows-expressive-down');
}
function populateVoiceList() {
if (!('speechSynthesis' in window)) {
showToast("Speech synthesis not supported in this browser.");
return;
}
voices = window.speechSynthesis.getVoices();
if (!voices || voices.length === 0) return;
voiceSelect.innerHTML = '';
// Sort voices: English first, then alphabetical by name
const sortedVoices = [...voices].sort((a, b) => {
const aIsEn = a.lang.startsWith('en');
const bIsEn = b.lang.startsWith('en');
if (aIsEn && !bIsEn) return -1;
if (!aIsEn && bIsEn) return 1;
return a.name.localeCompare(b.name);
});
let defaultSelectedIndex = 0;
sortedVoices.forEach((voice, index) => {
const option = document.createElement('option');
option.value = voice.name;
option.textContent = `${voice.name} (${voice.lang})${voice.default ? ' [System Default]' : ''}`;
// Prefer rich natural/Google/English voices if available
if (voice.lang.startsWith('en') && (voice.name.includes('Natural') || voice.name.includes('Google') || voice.name.includes('Samantha') || voice.default)) {
defaultSelectedIndex = index;
}
voiceSelect.appendChild(option);
});
voiceSelect.selectedIndex = defaultSelectedIndex;
}
// Web Speech API voices load asynchronously
if ('speechSynthesis' in window) {
populateVoiceList();
window.speechSynthesis.onvoiceschanged = populateVoiceList;
}
function setUIState(state) {
if (state === 'speaking') {
isSpeaking = true;
isPaused = false;
statusText.textContent = "Speaking...";
statusIndicatorDot.className = "w-2 h-2 rounded-full bg-indigo-400 animate-ping";
audioWave.classList.remove('hidden');
audioWave.classList.add('flex', 'speaking');
speakBtnText.textContent = "Restart Speech";
pauseBtn.disabled = false;
pauseBtnText.textContent = "Pause";
stopBtn.disabled = false;
} else if (state === 'paused') {
isSpeaking = true;
isPaused = true;
statusText.textContent = "Paused";
statusIndicatorDot.className = "w-2 h-2 rounded-full bg-amber-400";
audioWave.classList.remove('speaking');
pauseBtnText.textContent = "Resume";
} else {
// Idle
isSpeaking = false;
isPaused = false;
statusText.textContent = "Ready";
statusIndicatorDot.className = "w-2 h-2 rounded-full bg-emerald-500 animate-pulse";
audioWave.classList.add('hidden');
audioWave.classList.remove('flex', 'speaking');
speakBtnText.textContent = "Speak Script";
pauseBtn.disabled = true;
pauseBtnText.textContent = "Pause";
stopBtn.disabled = true;
resetFace();
clearInterval(lipSyncInterval);
stopEyebrowSync();
}
}
function showToast(message) {
toastText.textContent = message;
toast.classList.remove('opacity-0', 'pointer-events-none');
toast.classList.add('opacity-100');
setTimeout(() => {
toast.classList.remove('opacity-100');
toast.classList.add('opacity-0', 'pointer-events-none');
}, 3200);
}
function startSpeaking() {
if (!('speechSynthesis' in window)) {
showToast("Speech synthesis is unavailable in this environment.");
return;
}
const text = textInput.value.trim();
if (!text) {
showToast("Please enter some text for the avatar to say.");
return;
}
// Cancel any ongoing utterance
window.speechSynthesis.cancel();
clearInterval(lipSyncInterval);
stopEyebrowSync();
const utterance = new SpeechSynthesisUtterance(text);
currentUtterance = utterance;
// Assign selected voice
const selectedVoiceName = voiceSelect.value;
if (selectedVoiceName) {
const foundVoice = voices.find(v => v.name === selectedVoiceName);
if (foundVoice) utterance.voice = foundVoice;
}
// Configure Pitch and Speed Rate
const rate = parseFloat(rateSlider.value) || 1.0;
const pitch = parseFloat(pitchSlider.value) || 1.0;
utterance.rate = rate;
utterance.pitch = pitch;
// Syllable/Cadence timer: rate-adjusted mouth cycle interval
// Base interval ~130ms, scaled inversely with rate
const cadenceMs = Math.max(70, Math.min(220, Math.round(140 / rate)));
// Event handlers
utterance.onstart = () => {
setUIState('speaking');
startEyebrowSync();
// Continuous cadence loop to simulate mouth animation
lipSyncInterval = setInterval(() => {
if (isSpeaking && !isPaused) {
stepLipMovement();
}
}, cadenceMs);
};
// Word boundary triggers: instant mouth shape shift on spoken word
utterance.onboundary = (event) => {
if (event.name === 'word') {
stepLipMovement();
// Small head nod on specific words
const avatarSvg = document.getElementById('avatarSvg');
avatarSvg.style.transform = 'translateY(2px)';
setTimeout(() => {
avatarSvg.style.transform = 'translateY(0px)';
}, 80);
}
};
utterance.onpause = () => {
setUIState('paused');
};
utterance.onresume = () => {
setUIState('speaking');
};
utterance.onend = () => {
setUIState('idle');
};
utterance.onerror = (e) => {
console.warn("SpeechSynthesis error:", e);
setUIState('idle');
};
window.speechSynthesis.speak(utterance);
}
function togglePause() {
if (!isSpeaking) return;
if (window.speechSynthesis.paused) {
window.speechSynthesis.resume();
setUIState('speaking');
} else {
window.speechSynthesis.pause();
setUIState('paused');
}
}
function stopSpeaking() {
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel();
}
setUIState('idle');
}
// Slider live value displays
rateSlider.addEventListener('input', (e) => {
rateVal.textContent = `${parseFloat(e.target.value).toFixed(1)}x`;
});
pitchSlider.addEventListener('input', (e) => {
pitchVal.textContent = parseFloat(e.target.value).toFixed(1);
});
// Character counter
function updateCharCount() {
charCount.textContent = `${textInput.value.length} chars`;
}
textInput.addEventListener('input', updateCharCount);
updateCharCount();
// Buttons
speakBtn.addEventListener('click', startSpeaking);
pauseBtn.addEventListener('click', togglePause);
stopBtn.addEventListener('click', stopSpeaking);
// Preset script texts
const presets = {
"👋 Welcome": "Hello and welcome! I am your animated talking avatar, running purely in HTML, CSS, and modern JavaScript. Try altering my pitch and speed!",
"😄 Tell a joke": "Why do programmers prefer dark mode? Because light attracts bugs! Haha, classic computer humor never gets old.",
"🚀 Tech quote": "Any sufficiently advanced technology is indistinguishable from magic. Keep building, innovating, and pushing boundaries!",
"⚡ Tongue twister": "Peter Piper picked a peck of pickled peppers. How many pickled peppers did Peter Piper pick?"
};
presetButtons.forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.textContent.trim();
if (presets[key]) {
textInput.value = presets[key];
updateCharCount();
showToast(`Loaded preset: "${key}"`);
}
});
});
// Clean stop on page unload
window.addEventListener('beforeunload', () => {
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel();
}
});
// Initialize Idle animations on load
window.onload = function() {
setupIdleAnimations();
};
</script>
</body>
</html>
Comments
Post a Comment