Skip to content

Commit c701fda

Browse files
go4casclaude
andcommitted
1.9.0 — talking avatars speak (cast-avatar/speak)
Add an opt-in cast-avatar/speak module: speak(target, text, opts) reads text with the native Web Speech API and flaps the .cast-mouth rig (from animate:'talk') in time; stopSpeaking() cancels. SSR-safe no-op without a speech engine; respects prefers-reduced-motion. <cast-avatar> gains a .speak() method and an `animate` attribute. No new dependency; core renderers untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a3ac4a3 commit c701fda

9 files changed

Lines changed: 278 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ change — see "Stability and versioning" in the README.
99

1010
## [Unreleased]
1111

12+
## [1.9.0] - 2026-06-27
13+
14+
### Added
15+
16+
- `cast-avatar/speak` — make an avatar talk out loud. `speak(target, text, opts?)` reads `text` with the browser's native speech engine (Web Speech API, no dependency) and flaps the mouth of an avatar rendered with `animate: 'talk'` in time with it; `stopSpeaking(target?)` cancels. Returns a `Promise` that resolves when speech ends. SSR-safe (a no-op with no speech engine) and respects `prefers-reduced-motion` (speaks, but skips mouth motion).
17+
- `<cast-avatar>` gains a `.speak(text, opts?)` method and now accepts an `animate` attribute (so `<cast-avatar animate="talk">` is speakable).
18+
1219
## [1.8.0] - 2026-06-27
1320

1421
### Added

README.md

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,45 @@ A zero-dependency `<cast-avatar>` custom element is available from the
296296
```
297297

298298
Attributes: `seed`, `variant` (the style name — `style` is reserved by HTML),
299-
`size`, and `background`. The element re-renders when any of these change. For
300-
full trait control, render with the JavaScript API instead.
299+
`size`, `background`, and `animate`. The element re-renders when any of these
300+
change. For full trait control, render with the JavaScript API instead.
301+
302+
## Talking avatars
303+
304+
An avatar rendered with `animate: 'talk'` (face styles `portrait`, `studio`,
305+
`cartoon`) can speak text aloud with its mouth moving in time, via the browser's
306+
native speech engine — no dependency. Import the opt-in `cast-avatar/speak`
307+
helper:
308+
309+
```js
310+
import { createAvatar } from 'cast-avatar';
311+
import { speak } from 'cast-avatar/speak';
312+
313+
document.body.innerHTML = createAvatar('ada', { style: 'studio', animate: 'talk' });
314+
const avatar = document.body.querySelector('svg');
315+
316+
await speak(avatar, 'Hello, I am Ada.', { rate: 1.1 });
317+
```
318+
319+
`speak(target, text, opts?)` accepts the avatar `<svg>`, a `<cast-avatar>`, or any
320+
container of one. It returns a `Promise` that resolves when speech ends. `opts`
321+
(`voice`, `rate`, `pitch`, `volume`, `lang`) pass through to the utterance.
322+
`stopSpeaking(target?)` cancels and rests the mouth.
323+
324+
The `<cast-avatar>` element exposes the same thing as a method:
325+
326+
```html
327+
<cast-avatar seed="ada" variant="studio" animate="talk"></cast-avatar>
328+
<script type="module">
329+
import 'cast-avatar/element';
330+
document.querySelector('cast-avatar').speak('Hi there!');
331+
</script>
332+
```
333+
334+
Notes: requires `animate: 'talk'` (that's what emits the mouth rig). Respects
335+
`prefers-reduced-motion` — it still speaks, but skips mouth motion. Importing the
336+
module is SSR-safe; with no speech engine `speak()` resolves as a no-op. Speech
337+
support and available voices vary by browser/OS.
301338

302339
## Options
303340

element.d.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
// Type declarations for the cast-avatar custom element (cast-avatar/element).
22

3+
import type { SpeakOptions } from './speak.js';
4+
35
/** `<cast-avatar>` custom element. Renders an avatar via createAvatar. */
46
export class CastAvatarElement extends HTMLElement {
5-
static readonly observedAttributes: readonly ['seed', 'variant', 'size', 'background'];
7+
static readonly observedAttributes: readonly ['seed', 'variant', 'size', 'background', 'animate'];
68
connectedCallback(): void;
79
attributeChangedCallback(): void;
810
/** Render the avatar into the element from its current attributes. */
911
render(): void;
12+
/**
13+
* Speak `text` aloud, flapping the mouth in time. Requires the element to be
14+
* rendered with `animate="talk"` so the mouth rig is present. Resolves when
15+
* speech ends; resolves immediately (no-op) outside a speech-capable browser.
16+
*/
17+
speak(text: string, opts?: SpeakOptions): Promise<void>;
1018
}
1119

1220
declare global {

index.html

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@
368368
</svg>
369369
</span>
370370
<span class="logo-word">cast</span>
371-
<span class="version-pill">v1.8.0</span>
371+
<span class="version-pill">v1.9.0</span>
372372
</a>
373373
<div class="nav-links">
374374
<a href="#playground">Playground</a>
@@ -431,6 +431,11 @@ <h2>Playground</h2>
431431
<button class="btn btn-ghost btn-sm" id="btn-png" type="button">PNG</button>
432432
<button class="btn btn-ghost btn-sm" id="btn-link" type="button">Share</button>
433433
</div>
434+
<div class="seed-field">
435+
<span class="field-label">Say</span>
436+
<input id="say-input" spellcheck="false" autocomplete="off" aria-label="Text to speak" value="Hello, I am a cast avatar.">
437+
<button class="btn btn-ghost btn-sm" id="btn-speak" type="button">Speak</button>
438+
</div>
434439
</div>
435440
</div>
436441
<div class="pg-controls" id="pg-controls"></div>
@@ -550,6 +555,7 @@ <h2>Drop it in</h2>
550555
avatarOptions,
551556
toDataUri,
552557
} from './src/avatar.js';
558+
import { speak } from './src/speak.js';
553559

554560
/* ---------------- helpers ---------------- */
555561

@@ -878,6 +884,15 @@ <h2>Drop it in</h2>
878884
copyText(url, 'Link copied');
879885
});
880886

887+
$('#btn-speak').addEventListener('click', () => {
888+
const svg = $('#preview-stage svg');
889+
if (!svg) return;
890+
if (!svg.querySelector('.cast-mouth')) {
891+
toast('Set Animate → talk on a face style to see the lips move');
892+
}
893+
speak(svg, $('#say-input').value).catch(() => toast('Speech unavailable in this browser'));
894+
});
895+
881896
$('#hero-install').addEventListener('click', () => copyText('npm install cast-avatar', 'Install command copied'));
882897

883898
document.querySelectorAll('.code-copy').forEach((btn) => {

package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cast-avatar",
3-
"version": "1.8.0",
3+
"version": "1.9.0",
44
"description": "Dependency-free deterministic SVG avatar generator for browsers.",
55
"type": "module",
66
"main": "./src/avatar.js",
@@ -14,6 +14,10 @@
1414
"./element": {
1515
"types": "./element.d.ts",
1616
"import": "./src/element.js"
17+
},
18+
"./speak": {
19+
"types": "./speak.d.ts",
20+
"import": "./src/speak.js"
1721
}
1822
},
1923
"sideEffects": [
@@ -22,7 +26,8 @@
2226
"files": [
2327
"src",
2428
"avatar.d.ts",
25-
"element.d.ts"
29+
"element.d.ts",
30+
"speak.d.ts"
2631
],
2732
"scripts": {
2833
"test": "node test/avatar.test.mjs",

speak.d.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Type declarations for the talking-avatar helper (cast-avatar/speak).
2+
3+
/** Options forwarded to the underlying SpeechSynthesisUtterance. */
4+
export interface SpeakOptions {
5+
voice?: SpeechSynthesisVoice;
6+
/** Speed, 0.1–10 (default 1). */
7+
rate?: number;
8+
/** Pitch, 0–2 (default 1). */
9+
pitch?: number;
10+
/** Volume, 0–1 (default 1). */
11+
volume?: number;
12+
/** BCP-47 language tag, e.g. `'en-GB'`. */
13+
lang?: string;
14+
}
15+
16+
/** An avatar `<svg>`, a `<cast-avatar>`, the `.cast-mouth` group, or a container of one. */
17+
export type SpeakTarget = Element | null | undefined;
18+
19+
/** Resolve a target to its `.cast-mouth` group, or `null` if absent. */
20+
export function findMouth(target: SpeakTarget): Element | null;
21+
22+
/**
23+
* Speak `text` aloud and flap the avatar's mouth while it plays. The avatar must
24+
* be rendered with `animate: 'talk'` for the mouth to move. Resolves when speech
25+
* ends, rejects on a speech error. Outside a speech-capable browser (Node/SSR)
26+
* it resolves immediately as a no-op. Respects `prefers-reduced-motion` (speaks
27+
* the audio but skips mouth movement).
28+
*/
29+
export function speak(target: SpeakTarget, text: string, opts?: SpeakOptions): Promise<void>;
30+
31+
/** Cancel any in-flight speech and rest the avatar's mouth. */
32+
export function stopSpeaking(target?: SpeakTarget): void;

src/element.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createAvatar } from './avatar.js';
2+
import { speak } from './speak.js';
23

34
// `<cast-avatar seed="ada" variant="face" size="96">` — a zero-dependency
45
// custom element wrapper around createAvatar. The style name is exposed as the
@@ -10,7 +11,7 @@ const Base = typeof HTMLElement !== 'undefined' ? HTMLElement : class {};
1011

1112
export class CastAvatarElement extends Base {
1213
static get observedAttributes() {
13-
return ['seed', 'variant', 'size', 'background'];
14+
return ['seed', 'variant', 'size', 'background', 'animate'];
1415
}
1516

1617
connectedCallback() {
@@ -36,8 +37,18 @@ export class CastAvatarElement extends Base {
3637
options.background = this.getAttribute('background');
3738
}
3839

40+
if (this.hasAttribute('animate')) {
41+
options.animate = this.getAttribute('animate');
42+
}
43+
3944
this.innerHTML = createAvatar(this.getAttribute('seed') || '', options);
4045
}
46+
47+
// Speak `text` aloud, flapping the mouth in time. Requires the avatar to be
48+
// rendered with `variant`/`animate: 'talk'` so the `.cast-mouth` rig is present.
49+
speak(text, opts) {
50+
return speak(this, text, opts);
51+
}
4152
}
4253

4354
if (typeof customElements !== 'undefined' && !customElements.get('cast-avatar')) {

src/speak.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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

Comments
 (0)