diff --git a/src/components/voice-demo.tsx b/src/components/voice-demo.tsx index d8edba4..a7d9956 100644 --- a/src/components/voice-demo.tsx +++ b/src/components/voice-demo.tsx @@ -8,7 +8,7 @@ import "@moq/publish/element"; import type MoqPublish from "@moq/publish/element"; import "@moq/watch/element"; import type MoqWatch from "@moq/watch/element"; -import { createSignal, For, Index, onCleanup, Show } from "solid-js"; +import { createEffect, createSignal, For, Index, onCleanup, Show } from "solid-js"; import Cat from "./voice/cat"; type Turnstile = { @@ -207,6 +207,14 @@ export default function VoiceDemo() { let widget: { api: Turnstile; id: string } | undefined; let turnstileContainer!: HTMLDivElement; + // The transcript follows new words unless the reader has scrolled up to reread. + let transcriptList: HTMLOListElement | undefined; + let following = true; + createEffect(() => { + transcript(); + if (transcriptList && following) transcriptList.scrollTop = transcriptList.scrollHeight; + }); + const removeWidget = () => { widget?.api.remove(widget.id); widget = undefined; @@ -516,7 +524,18 @@ export default function VoiceDemo() {
0}> -
    +
      { + transcriptList = el; + following = true; + }} + onScroll={(e) => { + const el = e.currentTarget; + following = el.scrollHeight - el.scrollTop - el.clientHeight < 24; + }} + class="mt-4 mb-0 max-h-80 list-none space-y-3 overflow-y-auto p-0" + aria-label="Transcript" + > {(turn) => (
    1. diff --git a/src/components/voice/cat.tsx b/src/components/voice/cat.tsx index 3c3cd77..c6b46b0 100644 --- a/src/components/voice/cat.tsx +++ b/src/components/voice/cat.tsx @@ -1,11 +1,10 @@ -// The moq.pro cat, lip-syncing to whatever `voice` plays. It is drawn on twos with a boiling -// outline, like a chalk cartoon. +// The moq.pro cat, lip-syncing to whatever `voice` plays, with mouths on twos like a cartoon. // // Mouths are separate files so they can be redrawn by hand: public/voice/mouth/.svg, one // per name in SHAPES. Each is a 200x120 box whose point (100,50) is the center of the mouth, and // the box sits at (108,265) in the cat's 420x420 favicon box, just under the eyes. Draw on -// transparent: the cat's slate shows through. The boil distorts the mouth along with the cat. -import { createEffect, createSignal, createUniqueId, For, onCleanup, onMount } from "solid-js"; +// transparent: the cat's slate shows through. +import { createEffect, createSignal, For, onCleanup, onMount } from "solid-js"; import { centroid, ease, follow, level, pick, SHAPES, type Shape } from "./mouth"; // moq.pro's favicon (app/static/favicon.svg), in its own upside-down tenths. @@ -19,19 +18,15 @@ const FAVICON = "translate(0 420) scale(0.1 -0.1)"; // Squash the eyes toward their middle row. const BLINK = "translate(0 230) scale(1 0.15) translate(0 -230)"; -// Animation timing: mouths on twos at 24 fps, a new boil every third frame. +// Mouths on twos at 24 fps. const MOUTH_MS = 83; -const BOIL_MS = 125; const BLINK_MS = 120; type Tap = { analyser: AnalyserNode; samples: Float32Array; spectrum: Float32Array }; export default function Cat(props: { voice?: AudioNode; class?: string }) { - const id = createUniqueId(); const [shape, setShape] = createSignal("rest"); const [blink, setBlink] = createSignal(false); - const [boil, setBoil] = createSignal(false); - let turbulence!: SVGFETurbulenceElement; let tap: Tap | undefined; // A side branch off the player's output: nothing downstream of the analyser, so playback is @@ -54,16 +49,12 @@ export default function Cat(props: { voice?: AudioNode; class?: string }) { }); onMount(() => { - // Reduced motion keeps the lip sync and blinks but drops the boil, which never stops. - const reduce = matchMedia("(prefers-reduced-motion: reduce)"); - let open = 0; // The voice's brightness now and on average, in Hz, to tell its dark sounds from its bright. let bright = 0; let usual = 0; let last = performance.now(); let mouthAt = 0; - let boilAt = 0; let blinkAt = last + 2500; let frame = 0; @@ -89,13 +80,6 @@ export default function Cat(props: { voice?: AudioNode; class?: string }) { mouthAt = now; setShape(pick(open, usual ? bright / usual : 1)); } - // Read live, so a preference changed mid-call applies on the next frame. - const still = reduce.matches; - setBoil(!still); - if (!still && now - boilAt >= BOIL_MS) { - boilAt = now; - turbulence.setAttribute("seed", String(Math.floor(Math.random() * 997))); - } setBlink(now >= blinkAt); if (now >= blinkAt + BLINK_MS) blinkAt = now + 2000 + Math.random() * 4000; @@ -107,34 +91,26 @@ export default function Cat(props: { voice?: AudioNode; class?: string }) { return ( - - - - - - - - - - - {(d) => } - + + + + {(d) => } - {/* All five stay loaded so a swap never waits on the network. */} - - {(s) => ( - - )} - + {/* All five stay loaded so a swap never waits on the network. */} + + {(s) => ( + + )} + ); }