UNPKG

pishposh

Version:
444 lines (372 loc) 16.6 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Wireframe Face with Lip Sync</title> <style> body { margin: 0; padding: 20px; background: #000; color: #0f0; font-family: 'Courier New', monospace; display: flex; flex-direction: column; align-items: center; min-height: 100vh; } .container { text-align: center; max-width: 600px; } h1 { color: #0f0; margin-bottom: 20px; text-shadow: 0 0 10px #0f0; } .controls { margin-bottom: 20px; } button { background: #000; color: #0f0; border: 2px solid #0f0; padding: 10px 20px; margin: 5px; cursor: pointer; font-family: inherit; transition: all 0.3s; } button:hover { background: #0f0; color: #000; box-shadow: 0 0 15px #0f0; } button:disabled { opacity: 0.5; cursor: not-allowed; } .status { margin: 10px 0; font-size: 14px; } .level-indicator { width: 300px; height: 20px; border: 2px solid #0f0; margin: 10px auto; position: relative; background: #000; } .level-bar { height: 100%; background: linear-gradient(90deg, #0f0, #ff0, #f00); width: 0%; transition: width 0.1s; } svg { border: 2px solid #0f0; margin: 20px 0; filter: drop-shadow(0 0 10px #0f0); } .wireframe { fill: none; stroke: #0f0; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } .eyes { fill: #0f0; stroke: #0f0; stroke-width: 1; } .mouth { fill: none; stroke: #0f0; stroke-width: 3; stroke-linecap: round; } .info { margin-top: 20px; font-size: 12px; color: #0a0; line-height: 1.4; } </style> </head> <body> <div class="container"> <h1>◊ WIREFRAME FACE LIP SYNC ◊</h1> <div class="controls"> <button id="startBtn">START MICROPHONE</button> <button id="stopBtn" disabled>STOP MICROPHONE</button> </div> <div class="status" id="status">Click START to begin</div> <div class="level-indicator"> <div class="level-bar" id="levelBar"></div> </div> <svg width="400" height="500" viewBox="0 0 400 500"> <!-- Face outline --> <ellipse cx="200" cy="220" rx="140" ry="180" class="wireframe" /> <!-- Forehead lines --> <path d="M 120 150 Q 200 140 280 150" class="wireframe" /> <path d="M 130 170 Q 200 160 270 170" class="wireframe" /> <!-- Eyes --> <circle cx="160" cy="190" r="8" class="eyes" /> <circle cx="240" cy="190" r="8" class="eyes" /> <!-- Eye sockets --> <ellipse cx="160" cy="190" rx="25" ry="15" class="wireframe" /> <ellipse cx="240" cy="190" rx="25" ry="15" class="wireframe" /> <!-- Nose --> <path d="M 200 210 L 200 240" class="wireframe" /> <path d="M 190 235 Q 200 240 210 235" class="wireframe" /> <circle cx="195" cy="235" r="2" class="wireframe" /> <circle cx="205" cy="235" r="2" class="wireframe" /> <!-- Cheekbones --> <path d="M 120 220 Q 140 210 160 220" class="wireframe" /> <path d="M 240 220 Q 260 210 280 220" class="wireframe" /> <!-- Jaw structure --> <path d="M 100 280 Q 120 290 140 285" class="wireframe" /> <path d="M 260 285 Q 280 290 300 280" class="wireframe" /> <!-- Mouth - this will be animated --> <g id="mouth"> <!-- Upper lip --> <path id="upperLip" d="M 170 270 Q 200 265 230 270" class="mouth" /> <!-- Lower lip --> <path id="lowerLip" d="M 170 270 Q 200 275 230 270" class="mouth" /> <!-- Mouth corners --> <circle cx="170" cy="270" r="2" class="mouth" /> <circle cx="230" cy="270" r="2" class="mouth" /> </g> <!-- Chin --> <path d="M 180 350 Q 200 360 220 350" class="wireframe" /> <!-- Neck --> <path d="M 180 380 L 180 480" class="wireframe" /> <path d="M 220 380 L 220 480" class="wireframe" /> <path d="M 180 480 L 220 480" class="wireframe" /> <!-- Additional face structure --> <path d="M 140 300 Q 160 310 180 300" class="wireframe" /> <path d="M 220 300 Q 240 310 260 300" class="wireframe" /> </svg> <div class="info"> <p>▸ Microphone analyzes audio frequency and volume</p> <p>▸ Lip movements synchronized to speech patterns</p> <p>▸ Green wireframe aesthetic with real-time animation</p> </div> </div> <script> class LipSyncFace { constructor() { this.audioContext = null; this.analyser = null; this.microphone = null; this.dataArray = null; this.isListening = false; this.upperLip = document.getElementById('upperLip'); this.lowerLip = document.getElementById('lowerLip'); this.levelBar = document.getElementById('levelBar'); this.status = document.getElementById('status'); this.startBtn = document.getElementById('startBtn'); this.stopBtn = document.getElementById('stopBtn'); this.setupEventListeners(); this.animationId = null; // Mouth shape states - more pronounced movements this.mouthStates = { closed: { upper: 'M 170 270 Q 200 268 230 270', lower: 'M 170 270 Q 200 272 230 270' }, open: { upper: 'M 170 270 Q 200 250 230 270', lower: 'M 170 270 Q 200 295 230 270' }, wide: { upper: 'M 160 270 Q 200 265 240 270', lower: 'M 160 270 Q 200 285 240 270' }, round: { upper: 'M 180 265 Q 200 245 220 265', lower: 'M 180 275 Q 200 300 220 275' }, extraWide: { upper: 'M 155 270 Q 200 268 245 270', lower: 'M 155 270 Q 200 288 245 270' }, extraOpen: { upper: 'M 170 270 Q 200 240 230 270', lower: 'M 170 270 Q 200 310 230 270' } }; } setupEventListeners() { this.startBtn.addEventListener('click', () => this.startListening()); this.stopBtn.addEventListener('click', () => this.stopListening()); } async startListening() { try { this.status.textContent = 'Requesting microphone access...'; const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); this.analyser = this.audioContext.createAnalyser(); this.microphone = this.audioContext.createMediaStreamSource(stream); this.analyser.fftSize = 512; this.analyser.smoothingTimeConstant = 0.3; const bufferLength = this.analyser.frequencyBinCount; this.dataArray = new Uint8Array(bufferLength); this.microphone.connect(this.analyser); this.isListening = true; this.startBtn.disabled = true; this.stopBtn.disabled = false; this.status.textContent = 'Listening... Speak to see lip sync!'; this.animate(); } catch (error) { console.error('Error accessing microphone:', error); this.status.textContent = 'Error: Could not access microphone'; } } stopListening() { this.isListening = false; if (this.animationId) { cancelAnimationFrame(this.animationId); } if (this.audioContext) { this.audioContext.close(); } this.startBtn.disabled = false; this.stopBtn.disabled = true; this.status.textContent = 'Stopped listening'; this.levelBar.style.width = '0%'; // Reset mouth to closed position this.setMouthShape('closed'); } animate() { if (!this.isListening) return; this.analyser.getByteFrequencyData(this.dataArray); // Calculate average volume let sum = 0; for (let i = 0; i < this.dataArray.length; i++) { sum += this.dataArray[i]; } const average = sum / this.dataArray.length; // Calculate frequency-based characteristics const lowFreq = this.getFrequencyRange(0, 32); // Bass const midFreq = this.getFrequencyRange(32, 96); // Mids const highFreq = this.getFrequencyRange(96, 128); // Highs // Update level indicator const level = (average / 255) * 100; this.levelBar.style.width = `${level}%`; // Determine mouth shape based on audio characteristics this.updateMouthShape(average, lowFreq, midFreq, highFreq); this.animationId = requestAnimationFrame(() => this.animate()); } getFrequencyRange(start, end) { let sum = 0; for (let i = start; i < end && i < this.dataArray.length; i++) { sum += this.dataArray[i]; } return sum / (end - start); } updateMouthShape(volume, low, mid, high) { const threshold = 15; if (volume < threshold) { this.setMouthShape('closed'); } else { // Amplify the intensity for more dramatic movements const intensity = Math.pow((volume - threshold) / (255 - threshold), 0.7); // Power curve for more dramatic effect const freqDiff = Math.abs(high - low); // Add micro-movements for subtle changes if (intensity < 0.2) { // Very quiet speech - slight mouth opening this.setMouthShapeInterpolated('closed', 'open', intensity * 4); } else if (intensity > 0.8) { // Very loud speech - exaggerated movements if (low > mid * 1.3 && low > high * 1.3) { this.setMouthShapeInterpolated('round', 'extraOpen', intensity); } else if (high > mid * 1.3 && high > low * 1.3) { this.setMouthShapeInterpolated('wide', 'extraWide', intensity); } else { this.setMouthShape('extraOpen'); } } else if (low > mid * 1.2 && low > high * 1.2) { // Low frequencies - rounder mouth shapes with more dramatic opening this.setMouthShapeInterpolated('round', 'extraOpen', intensity * 1.5); } else if (high > mid * 1.2 && high > low * 1.2) { // High frequencies - wider mouth shapes with more stretch this.setMouthShapeInterpolated('wide', 'extraWide', intensity * 1.5); } else if (freqDiff < 10) { // Balanced frequencies - general open mouth with more pronounced opening this.setMouthShapeInterpolated('open', 'extraOpen', intensity * 1.3); } else { // Dynamic between shapes based on frequency dominance const ratio = high / (low + 1); if (ratio > 1.5) { this.setMouthShapeInterpolated('wide', 'extraWide', intensity * 1.4); } else if (ratio < 0.7) { this.setMouthShapeInterpolated('round', 'extraOpen', intensity * 1.4); } else { this.setMouthShapeInterpolated('open', 'extraOpen', intensity * 1.2); } } } } setMouthShape(shapeName) { const shape = this.mouthStates[shapeName]; if (shape) { this.upperLip.setAttribute('d', shape.upper); this.lowerLip.setAttribute('d', shape.lower); } } setMouthShapeInterpolated(shape1, shape2, factor) { // Interpolate between two mouth shapes for smoother transitions factor = Math.max(0, Math.min(1, factor)); const s1 = this.mouthStates[shape1]; const s2 = this.mouthStates[shape2]; if (!s1 || !s2) { this.setMouthShape(shape1); return; } // Simple interpolation for the Y coordinates in the path const interpolateY = (y1, y2, f) => y1 + (y2 - y1) * f; // Parse and interpolate the upper lip const upperInterp = this.interpolatePath(s1.upper, s2.upper, factor); const lowerInterp = this.interpolatePath(s1.lower, s2.lower, factor); this.upperLip.setAttribute('d', upperInterp); this.lowerLip.setAttribute('d', lowerInterp); } interpolatePath(path1, path2, factor) { // Simple path interpolation - works for our specific path structure const extractY = (path, index) => { const matches = path.match(/\d+/g); return matches ? parseInt(matches[index]) : 270; }; const y1_1 = extractY(path1, 1); // First Y in path1 const y1_2 = extractY(path1, 3); // Second Y in path1 const y2_1 = extractY(path2, 1); // First Y in path2 const y2_2 = extractY(path2, 3); // Second Y in path2 const newY1 = Math.round(y1_1 + (y2_1 - y1_1) * factor); const newY2 = Math.round(y1_2 + (y2_2 - y1_2) * factor); // Reconstruct the path with interpolated Y values if (path1.includes('265') || path1.includes('260')) { // Upper lip return `M 170 270 Q 200 ${newY1} 230 270`; } else { // Lower lip return `M 170 270 Q 200 ${newY2} 230 270`; } } } // Initialize the lip sync face when the page loads document.addEventListener('DOMContentLoaded', () => { new LipSyncFace(); }); </script> </body> </html>