|
| 1 | +// Make an avatar talk out loud. An opt-in companion to the pure-SVG core (like |
| 2 | +// ./element): it drives the mouth of an avatar rendered with `animate: 'talk'` |
| 3 | +// from the browser's native speech engine (Web Speech API) — no dependency. |
| 4 | +// |
| 5 | +// SSR-safe: with no `window`/`speechSynthesis` (Node, SSR) `speak()` resolves |
| 6 | +// immediately and `stopSpeaking()` is a no-op, so importing it never throws. |
| 7 | + |
| 8 | +// Resolve a caller's target to the `.cast-mouth` group the face renderers emit |
| 9 | +// under `animate: 'talk'`. Accepts the group itself, the avatar's <svg>, a |
| 10 | +// <cast-avatar>, or any container that wraps one. |
| 11 | +export function findMouth(target) { |
| 12 | + if (!target) { |
| 13 | + return null; |
| 14 | + } |
| 15 | + if (target.classList && target.classList.contains('cast-mouth')) { |
| 16 | + return target; |
| 17 | + } |
| 18 | + return typeof target.querySelector === 'function' ? target.querySelector('.cast-mouth') : null; |
| 19 | +} |
| 20 | + |
| 21 | +function speechAvailable() { |
| 22 | + return typeof window !== 'undefined' && !!window.speechSynthesis; |
| 23 | +} |
| 24 | + |
| 25 | +function reducedMotion() { |
| 26 | + return typeof window !== 'undefined' |
| 27 | + && typeof window.matchMedia === 'function' |
| 28 | + && window.matchMedia('(prefers-reduced-motion: reduce)').matches; |
| 29 | +} |
| 30 | + |
| 31 | +// Speak `text` and flap the avatar's mouth while it plays. Resolves when speech |
| 32 | +// ends, rejects on a speech error. `opts` (voice/rate/pitch/volume/lang) pass |
| 33 | +// straight through to the utterance. |
| 34 | +export function speak(target, text, opts = {}) { |
| 35 | + if (!speechAvailable()) { |
| 36 | + return Promise.resolve(); |
| 37 | + } |
| 38 | + |
| 39 | + const synth = window.speechSynthesis; |
| 40 | + const utter = new SpeechSynthesisUtterance(text == null ? '' : String(text)); |
| 41 | + for (const key of ['voice', 'rate', 'pitch', 'volume', 'lang']) { |
| 42 | + if (opts[key] != null) { |
| 43 | + utter[key] = opts[key]; |
| 44 | + } |
| 45 | + } |
| 46 | + |
| 47 | + // Skip mouth motion under prefers-reduced-motion — still speak the audio. |
| 48 | + const mouth = reducedMotion() ? null : findMouth(target); |
| 49 | + let timer = 0; |
| 50 | + let open = false; |
| 51 | + |
| 52 | + // A timer flap, not the per-word `boundary` event: boundary support is patchy |
| 53 | + // across voices/browsers, so a steady 140ms toggle reads as talking everywhere. |
| 54 | + // ponytail: fixed cadence; switch to boundary-driven visemes if lip-sync fidelity matters. |
| 55 | + const startFlap = () => { |
| 56 | + if (!mouth) { |
| 57 | + return; |
| 58 | + } |
| 59 | + // Cancel Tier 1's idle CSS loop so our inline transform wins — a CSS |
| 60 | + // animation (even paused) overrides inline styles, so pausing isn't enough. |
| 61 | + // transform-box/origin still come from the stylesheet rule (fill-box/center). |
| 62 | + mouth.style.animation = 'none'; |
| 63 | + timer = setInterval(() => { |
| 64 | + open = !open; |
| 65 | + mouth.style.transform = `scaleY(${open ? 1.3 : 0.5})`; |
| 66 | + }, 140); |
| 67 | + }; |
| 68 | + |
| 69 | + const rest = () => { |
| 70 | + if (timer) { |
| 71 | + clearInterval(timer); |
| 72 | + timer = 0; |
| 73 | + } |
| 74 | + if (mouth) { |
| 75 | + mouth.style.transform = ''; |
| 76 | + // Leave the idle CSS loop cancelled so the mouth stays still once speech |
| 77 | + // ends — a spoken avatar shouldn't keep flapping on its own. |
| 78 | + mouth.style.animation = 'none'; |
| 79 | + } |
| 80 | + }; |
| 81 | + |
| 82 | + return new Promise((resolve, reject) => { |
| 83 | + utter.onstart = startFlap; |
| 84 | + utter.onend = () => { rest(); resolve(); }; |
| 85 | + utter.onerror = (event) => { |
| 86 | + rest(); |
| 87 | + reject(new Error((event && event.error) || 'speech failed')); |
| 88 | + }; |
| 89 | + synth.cancel(); // ponytail: one utterance at a time; cancels any in-flight speech |
| 90 | + synth.speak(utter); |
| 91 | + }); |
| 92 | +} |
| 93 | + |
| 94 | +// Cancel any in-flight speech and rest the avatar's mouth. |
| 95 | +export function stopSpeaking(target) { |
| 96 | + if (speechAvailable()) { |
| 97 | + window.speechSynthesis.cancel(); |
| 98 | + } |
| 99 | + const mouth = findMouth(target); |
| 100 | + if (mouth) { |
| 101 | + mouth.style.transform = ''; |
| 102 | + mouth.style.animation = 'none'; |
| 103 | + } |
| 104 | +} |
0 commit comments