feat(web): aurora voice stage with live word-by-word caption (#100)
Replace the full-bleed cyan/violet/emerald bloom field with one dark glass panel: the Oo, a live caption whose new words fade in, and a compact control row (phase label, transcript, interrupt, hang up). An iridescent aurora rises from the panel's bottom edge, follows mic energy while listening, drifts while thinking and blooms while speaking. Its spectrum is the shared bot palette rotated so the active Oo's color sits in the middle, so no new hex values. Voice entry buttons drop the hardcoded cyan/amber styling for neutral tokens plus a thin aurora rim when voice is ready. Dead voice CSS is removed and the voice e2e now captures the stage. Claude-Session: https://claude.ai/code/session_012G3dUMVDCDL3PwQE4WTQxL Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4c619b25b9
commit
0beed6ad98
23
VOICE.md
23
VOICE.md
@ -42,11 +42,11 @@ Voice is the **immersive primary mode** for 1:1 bot conversations. Text chat use
|
|||||||
| Composer **Voice** pill | Opens Voice Stage when a provider is connected; otherwise opens Voice settings |
|
| Composer **Voice** pill | Opens Voice Stage when a provider is connected; otherwise opens Voice settings |
|
||||||
| Transcript “Speak this reply” | Reads one message aloud (text-first affordance, unchanged) |
|
| Transcript “Speak this reply” | Reads one message aloud (text-first affordance, unchanged) |
|
||||||
|
|
||||||
The composer Voice control uses `h-control-lg`, cyan bloom when ready, and amber styling when setup is needed.
|
The composer and stage Voice controls use `h-control-lg` in neutral tokens; when voice is ready an iridescent aurora rim (`VoiceAuroraRim`) runs along the bottom edge.
|
||||||
|
|
||||||
## Voice Stage
|
## Voice Stage
|
||||||
|
|
||||||
Full-bleed immersive layer (`VoiceStage` + `VoiceCanvas`) replaces the modal call dialog.
|
Full-screen dark layer with one glass panel (`VoiceStage`): the Oo, the live caption, and a control row. An iridescent aurora (`VoiceAurora`) rises from the panel's bottom edge. Its spectrum is the bot palette (`botColors` from `@rakazo/ui-tokens`) rotated so the active Oo's own color sits in the center.
|
||||||
|
|
||||||
### Persona & consciousness layer
|
### Persona & consciousness layer
|
||||||
|
|
||||||
@ -72,14 +72,16 @@ Phase labels, idle prompts, and thinking lines are **character-specific** (DE/EN
|
|||||||
|
|
||||||
| Phase | Visual | Audio |
|
| Phase | Visual | Audio |
|
||||||
|-------|--------|-------|
|
|-------|--------|-------|
|
||||||
| **Listening** | Cyan dual-mind bloom, mic-reactive energy bars | Dictation active; silence endpoint sends |
|
| **Listening** | Aurora rises and brightens with mic energy, breathing | Dictation active; silence endpoint sends |
|
||||||
| **Thinking** | Violet orbital ring, calm bars | TTS idle; awaiting bot reply |
|
| **Thinking** | Aurora settles low and drifts side to side | TTS idle; awaiting bot reply |
|
||||||
| **Speaking** | Emerald bloom, conductor mascot pose | TTS playback; caption shown live |
|
| **Speaking** | Full aurora bloom, conductor mascot pose | TTS playback; caption shown live |
|
||||||
|
|
||||||
Presence visuals sync to `dictation.energy` (0–1) while listening. Mascot uses `EvooMascot` poses: idle / thinking / wave.
|
Aurora height and opacity follow `dictation.energy` (0–1) while listening (`voiceAuroraBlobs` in `apps/web/src/lib/voice-aurora.ts`). The caption (`VoiceLiveText`) fades each new word in; words already on screen stay put while an utterance grows and the caption restarts for a new one. Mascot uses `EvooMascot` poses: idle / thinking / wave.
|
||||||
|
|
||||||
### Controls
|
### Controls
|
||||||
|
|
||||||
|
The control row holds the persona phase label (live region), Transcript, Interrupt (mic), and Hang up (×).
|
||||||
|
|
||||||
| Input | Action |
|
| Input | Action |
|
||||||
|-------|--------|
|
|-------|--------|
|
||||||
| **Space** | Interrupt (stop TTS or cancel dictation, resume listening) |
|
| **Space** | Interrupt (stop TTS or cancel dictation, resume listening) |
|
||||||
@ -92,18 +94,19 @@ Collapsible panel shows the last messages from the thread plus the live utteranc
|
|||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
- `prefers-reduced-motion: reduce` disables bloom drift, ring spin, and composer pulse; static gradients remain.
|
- `prefers-reduced-motion: reduce` disables aurora breathing/drift, the panel entrance, and word fade-in; the static aurora remains.
|
||||||
- Dialog semantics: `role="dialog"`, `aria-modal`, labelled bot name.
|
- Dialog semantics: `role="dialog"`, `aria-modal`, labelled bot name.
|
||||||
- Hang up exposes `aria-label="Hang up"`.
|
- Hang up exposes `aria-label="Hang up"`.
|
||||||
- Keyboard exit paths documented in-stage.
|
- Shortcuts are exposed through `aria-keyshortcuts` on the matching controls instead of on-screen hint text.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Shell (composer Voice)
|
Shell (composer Voice)
|
||||||
└─ CallView (orchestration: dictation + speaker + thread send)
|
└─ CallView (orchestration: dictation + speaker + thread send)
|
||||||
└─ VoiceStage (layout, controls, transcript drawer)
|
└─ VoiceStage (panel, controls, transcript drawer)
|
||||||
└─ VoiceCanvas (ambient bloom field)
|
├─ VoiceAurora (bottom-edge glow, phase + energy)
|
||||||
|
└─ VoiceLiveText (word-by-word caption)
|
||||||
```
|
```
|
||||||
|
|
||||||
Shared singletons: `dictation` (`apps/web/src/lib/dictation.ts`), `speaker` (`apps/web/src/lib/tts.ts`).
|
Shared singletons: `dictation` (`apps/web/src/lib/dictation.ts`), `speaker` (`apps/web/src/lib/tts.ts`).
|
||||||
|
|||||||
@ -79,7 +79,10 @@ test("voice settings connect a key, speak a reply, and open a call", async ({ pa
|
|||||||
.getByRole("button", { name: "Voice", exact: true })
|
.getByRole("button", { name: "Voice", exact: true })
|
||||||
.click();
|
.click();
|
||||||
await expect(page.getByTestId("call-view")).toBeVisible();
|
await expect(page.getByTestId("call-view")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("voice-panel")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Interrupt" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Hang up" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Hang up" })).toBeVisible();
|
||||||
|
await captureScreenshot(page, testInfo, "voice-stage");
|
||||||
await page.getByRole("button", { name: "Hang up" }).click();
|
await page.getByRole("button", { name: "Hang up" }).click();
|
||||||
await expect(page.getByTestId("call-view")).toHaveCount(0);
|
await expect(page.getByTestId("call-view")).toHaveCount(0);
|
||||||
|
|
||||||
|
|||||||
@ -243,7 +243,11 @@ export function OoStage({
|
|||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
{onStartVoice ? (
|
{onStartVoice ? (
|
||||||
<StageVoiceEntry voiceReady={voiceReady} onStartVoice={onStartVoice} />
|
<StageVoiceEntry
|
||||||
|
voiceReady={voiceReady}
|
||||||
|
botColor={botColor}
|
||||||
|
onStartVoice={onStartVoice}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -550,7 +554,11 @@ export function OoGroupStage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{onStartVoice && !hasMessages ? (
|
{onStartVoice && !hasMessages ? (
|
||||||
<StageVoiceEntry voiceReady={voiceReady} onStartVoice={onStartVoice} />
|
<StageVoiceEntry
|
||||||
|
voiceReady={voiceReady}
|
||||||
|
botColor={speaker?.color}
|
||||||
|
onStartVoice={onStartVoice}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { Button } from "@rakazo/ui-web";
|
import { Button } from "@rakazo/ui-web";
|
||||||
import { Mic } from "lucide-react";
|
import { Mic } from "lucide-react";
|
||||||
|
import { VoiceAuroraRim } from "../voice/VoiceAurora";
|
||||||
|
|
||||||
export function StageVoiceEntry({
|
export function StageVoiceEntry({
|
||||||
voiceReady,
|
voiceReady,
|
||||||
|
botColor,
|
||||||
onStartVoice,
|
onStartVoice,
|
||||||
}: {
|
}: {
|
||||||
voiceReady: boolean;
|
voiceReady: boolean;
|
||||||
|
botColor?: string;
|
||||||
onStartVoice: () => void;
|
onStartVoice: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@ -22,10 +25,13 @@ export function StageVoiceEntry({
|
|||||||
title={voiceReady ? t`Sprechen starten` : t`Sprache einrichten`}
|
title={voiceReady ? t`Sprechen starten` : t`Sprache einrichten`}
|
||||||
className={`tactile-press h-control-lg w-full max-w-sm gap-2.5 rounded-full px-6 text-ui-body font-semibold transition-all ${
|
className={`tactile-press h-control-lg w-full max-w-sm gap-2.5 rounded-full px-6 text-ui-body font-semibold transition-all ${
|
||||||
voiceReady
|
voiceReady
|
||||||
? "border-cyan-400/40 bg-gradient-to-b from-cyan-500/25 to-cyan-950/50 text-cyan-50 shadow-[0_0_32px_rgba(34,211,238,0.28),inset_0_1px_0_rgba(255,255,255,0.18)] hover:border-cyan-300/55 hover:from-cyan-400/32 hover:to-cyan-950/55 voice-primary-ready"
|
? "relative overflow-hidden border-border bg-foreground/5 text-foreground hover:bg-foreground/10"
|
||||||
: "border-amber-400/30 bg-white/[0.06] text-foreground/90 hover:bg-white/[0.12]"
|
: "border-border bg-foreground/5 text-foreground/80 hover:bg-foreground/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{voiceReady ? (
|
||||||
|
<VoiceAuroraRim botColor={botColor} className="inset-x-[12%] h-0.5 blur-[3px]" />
|
||||||
|
) : null}
|
||||||
<Mic strokeWidth={2} className="size-icon-lg shrink-0" aria-hidden="true" />
|
<Mic strokeWidth={2} className="size-icon-lg shrink-0" aria-hidden="true" />
|
||||||
{voiceReady ? <Trans>Sprechen starten</Trans> : <Trans>Sprache einrichten</Trans>}
|
{voiceReady ? <Trans>Sprechen starten</Trans> : <Trans>Sprache einrichten</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
88
apps/web/src/components/voice/VoiceAurora.tsx
Normal file
88
apps/web/src/components/voice/VoiceAurora.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { botColors } from "@rakazo/ui-tokens";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { auroraSpectrum, voiceAuroraBlobs } from "../../lib/voice-aurora";
|
||||||
|
import type { VoicePhase } from "./VoiceStage";
|
||||||
|
|
||||||
|
/** Iridescent glow rising from the bottom edge; reacts to phase and mic energy. */
|
||||||
|
export function VoiceAurora({
|
||||||
|
botColor,
|
||||||
|
phase,
|
||||||
|
energy,
|
||||||
|
reducedMotion,
|
||||||
|
}: {
|
||||||
|
botColor: string;
|
||||||
|
phase: VoicePhase;
|
||||||
|
energy: number;
|
||||||
|
reducedMotion: boolean;
|
||||||
|
}) {
|
||||||
|
const blobs = useMemo(() => voiceAuroraBlobs(botColor, phase, energy), [botColor, phase, energy]);
|
||||||
|
const motion = reducedMotion
|
||||||
|
? ""
|
||||||
|
: phase === "thinking"
|
||||||
|
? "voice-aurora-drift"
|
||||||
|
: "voice-aurora-breathe";
|
||||||
|
const transition = reducedMotion ? undefined : "height 180ms ease-out, opacity 240ms ease-out";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
|
{blobs.map((blob, index) => (
|
||||||
|
<div
|
||||||
|
key={blob.x}
|
||||||
|
className="absolute bottom-0 w-[62%] -translate-x-1/2 translate-y-1/2 mix-blend-screen"
|
||||||
|
style={{
|
||||||
|
left: `${blob.x}%`,
|
||||||
|
height: `${blob.rise * 2}%`,
|
||||||
|
opacity: blob.opacity,
|
||||||
|
transition,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`size-full rounded-full blur-[36px] ${motion}`}
|
||||||
|
style={{
|
||||||
|
background: `radial-gradient(closest-side, ${blob.color}, ${blob.color} 18%, transparent)`,
|
||||||
|
animationDelay: `${index * -0.7}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<VoiceAuroraRim
|
||||||
|
botColor={botColor}
|
||||||
|
className="inset-x-0 h-24 translate-y-1/2 blur-[26px] mix-blend-screen"
|
||||||
|
style={{ opacity: blobs[0]?.opacity, transition }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-1/2 h-12 w-[56%] -translate-x-1/2 translate-y-1/2 rounded-full bg-foreground blur-[20px]"
|
||||||
|
style={{ opacity: (blobs[0]?.opacity ?? 0) * 0.32, transition }}
|
||||||
|
/>
|
||||||
|
<VoiceAuroraRim
|
||||||
|
botColor={botColor}
|
||||||
|
className="inset-x-[6%] h-[3px] blur-[2px] mix-blend-screen"
|
||||||
|
style={{ opacity: Math.min(1, (blobs[0]?.opacity ?? 0) + 0.2), transition }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thin iridescent line along a bottom edge, brightest in the bot's own color. */
|
||||||
|
export function VoiceAuroraRim({
|
||||||
|
botColor = botColors[0],
|
||||||
|
className = "",
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
botColor?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}) {
|
||||||
|
const [center, left, right, farLeft, farRight] = auroraSpectrum(botColor);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`pointer-events-none absolute bottom-0 rounded-full ${className}`}
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(90deg, transparent, ${farLeft}, ${left}, ${center}, ${right}, ${farRight}, transparent)`,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,111 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
export type VoicePhase = "listening" | "thinking" | "speaking";
|
|
||||||
|
|
||||||
const PHASE_PALETTE: Record<
|
|
||||||
VoicePhase,
|
|
||||||
{ primary: string; secondary: string; accent: string; glow: string }
|
|
||||||
> = {
|
|
||||||
listening: {
|
|
||||||
primary: "rgba(6,182,212,0.55)",
|
|
||||||
secondary: "rgba(14,165,233,0.35)",
|
|
||||||
accent: "rgba(34,211,238,0.22)",
|
|
||||||
glow: "voice-bloom-cyan",
|
|
||||||
},
|
|
||||||
speaking: {
|
|
||||||
primary: "rgba(16,185,129,0.58)",
|
|
||||||
secondary: "rgba(52,211,153,0.38)",
|
|
||||||
accent: "rgba(110,231,183,0.24)",
|
|
||||||
glow: "voice-bloom-emerald",
|
|
||||||
},
|
|
||||||
thinking: {
|
|
||||||
primary: "rgba(139,92,246,0.52)",
|
|
||||||
secondary: "rgba(167,139,250,0.34)",
|
|
||||||
accent: "rgba(196,181,253,0.22)",
|
|
||||||
glow: "voice-bloom-violet",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ACCENT_TINT: Record<string, string> = {
|
|
||||||
cyan: "rgba(6,182,212,0.42)",
|
|
||||||
emerald: "rgba(16,185,129,0.42)",
|
|
||||||
violet: "rgba(139,92,246,0.42)",
|
|
||||||
amber: "rgba(245,158,11,0.38)",
|
|
||||||
rose: "rgba(244,63,94,0.36)",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function VoiceCanvas({
|
|
||||||
phase,
|
|
||||||
energy = 0,
|
|
||||||
reducedMotion = false,
|
|
||||||
personaAccent,
|
|
||||||
}: {
|
|
||||||
phase: VoicePhase;
|
|
||||||
energy?: number;
|
|
||||||
reducedMotion?: boolean;
|
|
||||||
personaAccent?: keyof typeof ACCENT_TINT;
|
|
||||||
}) {
|
|
||||||
const palette = PHASE_PALETTE[phase];
|
|
||||||
const personaTint = personaAccent ? ACCENT_TINT[personaAccent] : null;
|
|
||||||
const bloomScale = useMemo(() => 1 + energy * 0.28, [energy]);
|
|
||||||
const ringOpacity = useMemo(() => 0.35 + energy * 0.45, [energy]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden bg-[#030508]">
|
|
||||||
{/* Deep field */}
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_120%_80%_at_50%_100%,rgba(8,12,20,0.95)_0%,#030508_55%)]" />
|
|
||||||
|
|
||||||
{/* Dual-mind bloom orbs */}
|
|
||||||
<div
|
|
||||||
className={`absolute -left-[18%] top-[8%] h-[62vmin] w-[62vmin] rounded-full blur-[80px] ${reducedMotion ? "" : palette.glow}`}
|
|
||||||
style={{
|
|
||||||
background: `radial-gradient(circle, ${palette.primary} 0%, transparent 68%)`,
|
|
||||||
opacity: reducedMotion ? 0.55 : 0.75,
|
|
||||||
transform: `scale(${bloomScale})`,
|
|
||||||
transition: reducedMotion ? undefined : "transform 120ms ease-out",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`absolute -right-[14%] top-[22%] h-[54vmin] w-[54vmin] rounded-full blur-[72px] ${reducedMotion ? "" : "voice-bloom-secondary"}`}
|
|
||||||
style={{
|
|
||||||
background: personaTint
|
|
||||||
? `radial-gradient(circle, ${personaTint} 0%, ${palette.secondary} 45%, transparent 70%)`
|
|
||||||
: `radial-gradient(circle, ${palette.secondary} 0%, transparent 70%)`,
|
|
||||||
opacity: reducedMotion ? 0.45 : 0.65,
|
|
||||||
animationDelay: "-1.4s",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`absolute left-1/2 top-[38%] h-[48vmin] w-[48vmin] -translate-x-1/2 rounded-full blur-[96px] ${reducedMotion ? "" : palette.glow}`}
|
|
||||||
style={{
|
|
||||||
background: `radial-gradient(circle, ${palette.accent} 0%, transparent 72%)`,
|
|
||||||
opacity: 0.5 + energy * 0.25,
|
|
||||||
transform: `translateX(-50%) scale(${0.92 + energy * 0.18})`,
|
|
||||||
transition: reducedMotion
|
|
||||||
? undefined
|
|
||||||
: "transform 140ms ease-out, opacity 140ms ease-out",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Liquid glass rings */}
|
|
||||||
<div className="absolute left-1/2 top-[42%] size-[min(72vmin,520px)] -translate-x-1/2 -translate-y-1/2">
|
|
||||||
<div
|
|
||||||
className={`size-full rounded-full border border-white/[0.07] ${reducedMotion ? "" : "voice-glass-ring"}`}
|
|
||||||
style={{ opacity: ringOpacity }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="absolute left-1/2 top-[42%] size-[min(58vmin,420px)] -translate-x-1/2 -translate-y-1/2">
|
|
||||||
<div
|
|
||||||
className={`size-full rounded-full border border-white/[0.05] ${reducedMotion ? "" : "voice-glass-ring-inner"}`}
|
|
||||||
style={{ opacity: ringOpacity * 0.85 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Fine grain for depth */}
|
|
||||||
<div className="absolute inset-0 opacity-[0.035] mix-blend-overlay voice-grain" />
|
|
||||||
|
|
||||||
{/* Horizon line */}
|
|
||||||
<div className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
30
apps/web/src/components/voice/VoiceLiveText.tsx
Normal file
30
apps/web/src/components/voice/VoiceLiveText.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { captionWords, continuesCaption } from "../../lib/voice-aurora";
|
||||||
|
|
||||||
|
/** Live caption whose new words fade in as they are heard or spoken. */
|
||||||
|
export function VoiceLiveText({ text, reducedMotion }: { text: string; reducedMotion: boolean }) {
|
||||||
|
const shown = useRef({ text: "", utterance: 0 });
|
||||||
|
if (shown.current.text !== text) {
|
||||||
|
shown.current = {
|
||||||
|
text,
|
||||||
|
utterance: continuesCaption(shown.current.text, text)
|
||||||
|
? shown.current.utterance
|
||||||
|
: shown.current.utterance + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { utterance } = shown.current;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
dir="auto"
|
||||||
|
className="text-balance text-center text-ui-title font-medium leading-snug text-foreground"
|
||||||
|
>
|
||||||
|
{captionWords(text).map((word, index) => (
|
||||||
|
<span key={`${utterance}-${index}`} className={reducedMotion ? undefined : "voice-word-in"}>
|
||||||
|
{index ? " " : ""}
|
||||||
|
{word}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,13 +1,15 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { BotAvatar, Button } from "@rakazo/ui-web";
|
import { BotAvatar, Button } from "@rakazo/ui-web";
|
||||||
import { PhoneOff, ScrollText } from "lucide-react";
|
import { Mic, ScrollText, X } from "lucide-react";
|
||||||
import { useEffect, useId, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
import type { BotPersonaProfile } from "../../lib/bot-persona";
|
import type { BotPersonaProfile, VoiceAwarenessEvent } from "../../lib/bot-persona";
|
||||||
import { cadencePulseMs, personaMascotPose } from "../../lib/bot-persona";
|
import { personaMascotPose } from "../../lib/bot-persona";
|
||||||
|
import { VoiceAurora } from "./VoiceAurora";
|
||||||
import { VoiceAwarenessStrip } from "./VoiceAwarenessStrip";
|
import { VoiceAwarenessStrip } from "./VoiceAwarenessStrip";
|
||||||
import { VoiceCanvas, type VoicePhase } from "./VoiceCanvas";
|
import { VoiceLiveText } from "./VoiceLiveText";
|
||||||
|
import "./voice-aurora.css";
|
||||||
|
|
||||||
export type { VoicePhase };
|
export type VoicePhase = "listening" | "thinking" | "speaking";
|
||||||
|
|
||||||
export interface VoiceTranscriptLine {
|
export interface VoiceTranscriptLine {
|
||||||
role: "user" | "bot";
|
role: "user" | "bot";
|
||||||
@ -15,6 +17,9 @@ export interface VoiceTranscriptLine {
|
|||||||
speakerName?: string;
|
speakerName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROUND_CONTROL =
|
||||||
|
"tactile-press size-control-md shrink-0 rounded-full bg-foreground/10 p-0 text-foreground backdrop-blur-md hover:bg-foreground/15";
|
||||||
|
|
||||||
export function VoiceStage({
|
export function VoiceStage({
|
||||||
phase,
|
phase,
|
||||||
phaseLabel,
|
phaseLabel,
|
||||||
@ -45,7 +50,7 @@ export function VoiceStage({
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
energy?: number;
|
energy?: number;
|
||||||
awarenessLabel?: string | null;
|
awarenessLabel?: string | null;
|
||||||
awarenessEvent?: import("../../lib/bot-persona").VoiceAwarenessEvent | null;
|
awarenessEvent?: VoiceAwarenessEvent | null;
|
||||||
transcriptLines?: VoiceTranscriptLine[];
|
transcriptLines?: VoiceTranscriptLine[];
|
||||||
onInterrupt: () => void;
|
onInterrupt: () => void;
|
||||||
onHangUp: () => void;
|
onHangUp: () => void;
|
||||||
@ -84,232 +89,137 @@ export function VoiceStage({
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cadenceMs = cadencePulseMs(persona.cadence);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="call-view"
|
data-testid="call-view"
|
||||||
|
data-theme="dark"
|
||||||
|
data-phase={phase}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
className="fixed inset-0 z-40 flex flex-col text-white"
|
className="fixed inset-0 z-40 flex flex-col items-center justify-center gap-3 overflow-y-auto bg-background px-4 pt-[max(1rem,env(safe-area-inset-top))] pb-[max(1rem,env(safe-area-inset-bottom))] text-foreground"
|
||||||
>
|
>
|
||||||
<VoiceCanvas
|
<h1 id={titleId} className="sr-only">
|
||||||
phase={phase}
|
{botName}
|
||||||
energy={energy}
|
</h1>
|
||||||
reducedMotion={reducedMotion}
|
|
||||||
personaAccent={persona.bloomAccent}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex min-h-0 flex-1 flex-col">
|
{awarenessLabel && awarenessEvent ? (
|
||||||
<header className="flex items-center justify-between gap-4 px-5 pt-[max(1rem,env(safe-area-inset-top))] pb-3 md:px-8">
|
<div className="w-full max-w-md">
|
||||||
<div className="min-w-0">
|
<VoiceAwarenessStrip
|
||||||
<div
|
event={awarenessEvent}
|
||||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-ui-label font-semibold uppercase tracking-wider text-zinc-200 backdrop-blur-md"
|
label={awarenessLabel}
|
||||||
style={
|
botColor={botColor}
|
||||||
reducedMotion
|
botId={botId}
|
||||||
? undefined
|
/>
|
||||||
: { animation: `voicePrimaryPulse ${cadenceMs}ms ease-in-out infinite` }
|
</div>
|
||||||
}
|
) : null}
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`size-2 rounded-full ${
|
|
||||||
phase === "listening"
|
|
||||||
? "bg-cyan-400 shadow-[0_0_10px_#22d3ee]"
|
|
||||||
: phase === "speaking"
|
|
||||||
? "bg-emerald-400 shadow-[0_0_10px_#34d399]"
|
|
||||||
: "bg-violet-400 shadow-[0_0_10px_#a78bfa]"
|
|
||||||
} ${reducedMotion ? "" : "animate-pulse"}`}
|
|
||||||
/>
|
|
||||||
<span>{phaseLabel}</span>
|
|
||||||
</div>
|
|
||||||
<h1
|
|
||||||
id={titleId}
|
|
||||||
className="mt-2 truncate text-ui-title font-semibold tracking-tight text-white"
|
|
||||||
>
|
|
||||||
{botName}
|
|
||||||
</h1>
|
|
||||||
<p className="truncate text-ui-body-sm text-zinc-400">{botTitle}</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setTranscriptOpen((open) => !open)}
|
|
||||||
aria-expanded={transcriptOpen}
|
|
||||||
aria-controls="voice-transcript-panel"
|
|
||||||
className="tactile-press h-control-sm gap-2 rounded-full border-white/12 bg-white/[0.06] px-4 text-ui-body text-zinc-100 hover:bg-white/[0.12]"
|
|
||||||
>
|
|
||||||
<ScrollText strokeWidth={1.8} className="size-icon-md" />
|
|
||||||
<span className="hidden sm:inline">
|
|
||||||
<Trans>Transcript</Trans>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{awarenessLabel && awarenessEvent ? (
|
<section
|
||||||
<div className="px-5 pb-2 md:px-8">
|
data-testid="voice-panel"
|
||||||
<VoiceAwarenessStrip
|
className={`relative isolate flex min-h-[min(68vh,30rem)] w-full max-w-md flex-col overflow-hidden rounded-[2rem] border border-border bg-card shadow-2xl ${reducedMotion ? "" : "voice-panel-in"}`}
|
||||||
event={awarenessEvent}
|
>
|
||||||
label={awarenessLabel}
|
<VoiceAurora
|
||||||
botColor={botColor}
|
botColor={botColor}
|
||||||
botId={botId}
|
phase={phase}
|
||||||
|
energy={energy}
|
||||||
|
reducedMotion={reducedMotion}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex flex-1 flex-col items-center justify-center gap-6 px-7 pt-10 pb-4">
|
||||||
|
<div title={`${botName} · ${botTitle}`}>
|
||||||
|
<BotAvatar
|
||||||
|
color={botColor}
|
||||||
|
identity={botId}
|
||||||
|
status={botStatus}
|
||||||
|
pose={mascotPose}
|
||||||
|
size={88}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<VoiceLiveText text={subtitle} reducedMotion={reducedMotion} />
|
||||||
|
{error ? (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center px-6 pb-4">
|
<p role="alert" className="text-center text-ui-body-sm font-medium text-destructive">
|
||||||
<div className="relative flex flex-col items-center">
|
{error}
|
||||||
{phase === "thinking" && !reducedMotion ? (
|
|
||||||
<div
|
|
||||||
className="absolute size-[min(52vmin,380px)] rounded-full border border-violet-400/25 border-t-violet-300/70"
|
|
||||||
style={{ animation: "orbitalSpin 2.8s linear infinite" }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="relative flex size-[min(44vmin,320px)] items-center justify-center rounded-full border border-white/18 bg-slate-950/55 shadow-[0_24px_80px_rgba(0,0,0,0.65),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl"
|
|
||||||
style={
|
|
||||||
reducedMotion
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
boxShadow: `0 24px 80px rgba(0,0,0,0.65), inset 0 1px 0 rgba(255,255,255,0.14), 0 0 ${Math.round(24 + energy * 40)}px ${personaGlow(persona.bloomAccent, energy)}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<BotAvatar
|
|
||||||
color={botColor}
|
|
||||||
identity={botId}
|
|
||||||
status={botStatus}
|
|
||||||
pose={mascotPose}
|
|
||||||
size={128}
|
|
||||||
className="drop-shadow-[0_8px_32px_rgba(0,0,0,0.45)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 flex h-10 items-end justify-center gap-1.5" aria-hidden>
|
|
||||||
{Array.from({ length: 9 }, (_, index) => {
|
|
||||||
const barEnergy =
|
|
||||||
phase === "thinking"
|
|
||||||
? 0.35 + ((index % 3) + 1) * 0.08
|
|
||||||
: 0.25 + energy * 0.75 * (0.6 + (index % 5) * 0.1);
|
|
||||||
const height = `${Math.round(18 + barEnergy * 28)}px`;
|
|
||||||
const color =
|
|
||||||
phase === "listening"
|
|
||||||
? "bg-cyan-400/90"
|
|
||||||
: phase === "speaking"
|
|
||||||
? "bg-emerald-400/90"
|
|
||||||
: "bg-violet-400/70";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`w-1.5 rounded-full ${color} ${reducedMotion ? "" : `wave-bar-${(index % 7) + 1}`}`}
|
|
||||||
style={{
|
|
||||||
height: reducedMotion ? "24px" : height,
|
|
||||||
opacity: 0.55 + barEnergy * 0.45,
|
|
||||||
transition: reducedMotion ? undefined : "height 90ms ease-out",
|
|
||||||
animationDuration: reducedMotion ? undefined : `${cadenceMs / 1000}s`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-6 pb-2 md:px-10">
|
|
||||||
<div className="mx-auto max-w-2xl rounded-[24px] border border-white/10 bg-black/35 px-5 py-4 text-center backdrop-blur-xl">
|
|
||||||
<p
|
|
||||||
key={subtitle}
|
|
||||||
className={`text-ui-reading font-medium leading-relaxed text-zinc-50 ${reducedMotion ? "" : "animate-blur-in"}`}
|
|
||||||
>
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
</p>
|
||||||
{error ? (
|
) : null}
|
||||||
<p className="mt-2 text-ui-body-sm font-medium text-rose-400">{error}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="relative flex items-center gap-2 px-4 pb-4">
|
||||||
id="voice-transcript-panel"
|
<span
|
||||||
className={`mx-auto w-full max-w-2xl overflow-hidden px-6 transition-[max-height,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] md:px-10 ${
|
aria-live="polite"
|
||||||
transcriptOpen ? "max-h-48 opacity-100 pb-2" : "max-h-0 opacity-0"
|
className="mr-auto truncate rounded-full bg-foreground/10 px-3.5 py-2 text-ui-body-sm font-medium text-foreground/85 backdrop-blur-md"
|
||||||
}`}
|
>
|
||||||
>
|
{phaseLabel}
|
||||||
<div className="apple-glass-card max-h-44 overflow-y-auto rounded-2xl px-4 py-3 text-start rk-scroll">
|
</span>
|
||||||
{transcriptLines.length ? (
|
<Button
|
||||||
<ul className="space-y-2">
|
type="button"
|
||||||
{transcriptLines.map((line, index) => (
|
variant="ghost"
|
||||||
<li
|
onClick={() => setTranscriptOpen((open) => !open)}
|
||||||
key={`${line.role}-${index}`}
|
aria-label={t`Transcript`}
|
||||||
className={`text-ui-body-sm leading-relaxed ${
|
title={t`Transcript`}
|
||||||
line.role === "user" ? "text-cyan-100/90" : "text-zinc-300"
|
aria-expanded={transcriptOpen}
|
||||||
}`}
|
aria-controls="voice-transcript-panel"
|
||||||
>
|
aria-keyshortcuts="T"
|
||||||
<span className="text-ui-label uppercase tracking-wide text-muted-foreground">
|
className={ROUND_CONTROL}
|
||||||
{line.role === "user" ? t`You` : (line.speakerName ?? botName)}
|
>
|
||||||
</span>
|
<ScrollText strokeWidth={1.8} className="size-icon-md" />
|
||||||
<span dir="auto" className="mt-0.5 block">
|
</Button>
|
||||||
{line.text}
|
<Button
|
||||||
</span>
|
type="button"
|
||||||
</li>
|
variant="ghost"
|
||||||
))}
|
onClick={onInterrupt}
|
||||||
</ul>
|
aria-label={t`Interrupt`}
|
||||||
) : (
|
title={t`Interrupt`}
|
||||||
<p className="text-ui-body-sm text-muted-foreground">
|
aria-keyshortcuts="Space"
|
||||||
<Trans>Your conversation will appear here.</Trans>
|
className={`${ROUND_CONTROL} ${phase === "listening" ? "bg-foreground/20" : ""}`}
|
||||||
</p>
|
>
|
||||||
)}
|
<Mic strokeWidth={1.9} className="size-icon-md" />
|
||||||
</div>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onHangUp}
|
||||||
|
aria-label={t`Hang up`}
|
||||||
|
title={t`Hang up`}
|
||||||
|
aria-keyshortcuts="Escape"
|
||||||
|
className={ROUND_CONTROL}
|
||||||
|
>
|
||||||
|
<X strokeWidth={2} className="size-icon-md" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<footer className="flex flex-col items-center gap-3 px-6 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-3 md:px-10">
|
<div
|
||||||
<div className="apple-island flex items-center gap-3 rounded-full px-3 py-2">
|
id="voice-transcript-panel"
|
||||||
<Button
|
className={`w-full max-w-md overflow-hidden transition-[max-height,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||||
type="button"
|
transcriptOpen ? "max-h-56 opacity-100" : "max-h-0 opacity-0"
|
||||||
variant="outline"
|
}`}
|
||||||
onClick={onInterrupt}
|
>
|
||||||
className="tactile-press h-control-md rounded-full border-white/15 bg-white/10 px-6 text-ui-body font-medium text-white hover:bg-white/18"
|
<div className="rk-scroll max-h-52 overflow-y-auto rounded-3xl border border-border bg-card px-5 py-4 text-start">
|
||||||
>
|
{transcriptLines.length ? (
|
||||||
<Trans>Interrupt</Trans>
|
<ul className="space-y-2.5">
|
||||||
</Button>
|
{transcriptLines.map((line, index) => (
|
||||||
<Button
|
<li
|
||||||
type="button"
|
key={`${line.role}-${index}`}
|
||||||
variant="destructive"
|
className={`text-ui-body-sm leading-relaxed ${
|
||||||
onClick={onHangUp}
|
line.role === "user" ? "text-foreground" : "text-muted-foreground"
|
||||||
aria-label={t`Hang up`}
|
}`}
|
||||||
title={t`Hang up`}
|
>
|
||||||
className="tactile-press size-control-lg rounded-full bg-rose-500 p-0 text-white shadow-[0_0_24px_rgba(244,63,94,0.45)] hover:bg-rose-600"
|
<span className="text-ui-label text-muted-foreground">
|
||||||
>
|
{line.role === "user" ? t`You` : (line.speakerName ?? botName)}
|
||||||
<PhoneOff strokeWidth={1.8} className="size-icon-lg" />
|
</span>
|
||||||
</Button>
|
<span dir="auto" className="mt-0.5 block">
|
||||||
</div>
|
{line.text}
|
||||||
<p className="flex items-center gap-1 text-ui-caption text-zinc-400">
|
</span>
|
||||||
<Trans>Space interrupts</Trans>
|
</li>
|
||||||
<span aria-hidden>·</span>
|
))}
|
||||||
<Trans>Esc hangs up</Trans>
|
</ul>
|
||||||
<span aria-hidden>·</span>
|
) : (
|
||||||
<Trans>T transcript</Trans>
|
<p className="text-ui-body-sm text-muted-foreground">
|
||||||
</p>
|
<Trans>Your conversation will appear here.</Trans>
|
||||||
</footer>
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function personaGlow(accent: BotPersonaProfile["bloomAccent"], energy: number): string {
|
|
||||||
const alpha = 0.15 + energy * 0.35;
|
|
||||||
switch (accent) {
|
|
||||||
case "violet":
|
|
||||||
return `rgba(167,139,250,${alpha})`;
|
|
||||||
case "amber":
|
|
||||||
return `rgba(251,191,36,${alpha})`;
|
|
||||||
case "rose":
|
|
||||||
return `rgba(251,113,133,${alpha})`;
|
|
||||||
case "cyan":
|
|
||||||
return `rgba(34,211,238,${alpha})`;
|
|
||||||
default:
|
|
||||||
return `rgba(52,211,153,${alpha})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
71
apps/web/src/components/voice/voice-aurora.css
Normal file
71
apps/web/src/components/voice/voice-aurora.css
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/* Voice Stage — aurora rising from the panel's bottom edge.
|
||||||
|
Positioning lives on the wrapper; these animations only touch the inner
|
||||||
|
layer so they never fight the energy-driven size. */
|
||||||
|
|
||||||
|
@keyframes voice-aurora-breathe {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1, 1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.08, 1.14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes voice-aurora-drift {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(-9%) scale(0.96);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateX(9%) scale(1.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes voice-panel-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px) scale(0.97);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes voice-word-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(6px);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-aurora-breathe {
|
||||||
|
animation: voice-aurora-breathe 3.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-aurora-drift {
|
||||||
|
animation: voice-aurora-drift 4.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-panel-in {
|
||||||
|
animation: voice-panel-in 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-word-in {
|
||||||
|
animation: voice-word-in 420ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.voice-aurora-breathe,
|
||||||
|
.voice-aurora-drift,
|
||||||
|
.voice-panel-in,
|
||||||
|
.voice-word-in {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
apps/web/src/lib/voice-aurora.test.ts
Normal file
63
apps/web/src/lib/voice-aurora.test.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { botColors } from "@rakazo/ui-tokens";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { auroraSpectrum, captionWords, continuesCaption, voiceAuroraBlobs } from "./voice-aurora";
|
||||||
|
|
||||||
|
describe("auroraSpectrum", () => {
|
||||||
|
it("leads with the bot's own color and keeps the whole palette", () => {
|
||||||
|
const color = botColors[3];
|
||||||
|
const spectrum = auroraSpectrum(color.toLowerCase());
|
||||||
|
expect(spectrum[0]).toBe(color);
|
||||||
|
expect([...spectrum].sort()).toEqual([...botColors].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts an unknown color first, ahead of the palette", () => {
|
||||||
|
expect(auroraSpectrum("#123456")).toEqual(["#123456", ...botColors]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("voiceAuroraBlobs", () => {
|
||||||
|
it("centers the bot color and fans out symmetrically", () => {
|
||||||
|
const blobs = voiceAuroraBlobs(botColors[0], "speaking", 0);
|
||||||
|
expect(blobs.map((blob) => blob.x)).toEqual([50, 32, 68, 14, 86]);
|
||||||
|
expect(blobs[0]?.color).toBe(botColors[0]);
|
||||||
|
expect(blobs[1]?.rise).toBe(blobs[2]?.rise);
|
||||||
|
expect(blobs[0]?.rise).toBeGreaterThan(blobs[3]?.rise ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rises with mic energy while listening", () => {
|
||||||
|
const quiet = voiceAuroraBlobs(botColors[0], "listening", 0)[0];
|
||||||
|
const loud = voiceAuroraBlobs(botColors[0], "listening", 1)[0];
|
||||||
|
expect(loud?.rise).toBeGreaterThan(quiet?.rise ?? 0);
|
||||||
|
expect(loud?.opacity).toBeGreaterThan(quiet?.opacity ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores energy outside listening and clamps bad input", () => {
|
||||||
|
expect(voiceAuroraBlobs(botColors[0], "thinking", 1)).toEqual(
|
||||||
|
voiceAuroraBlobs(botColors[0], "thinking", 0),
|
||||||
|
);
|
||||||
|
expect(voiceAuroraBlobs(botColors[0], "listening", 5)).toEqual(
|
||||||
|
voiceAuroraBlobs(botColors[0], "listening", 1),
|
||||||
|
);
|
||||||
|
expect(voiceAuroraBlobs(botColors[0], "listening", Number.NaN)).toEqual(
|
||||||
|
voiceAuroraBlobs(botColors[0], "listening", 0),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("live caption", () => {
|
||||||
|
it("splits on any whitespace", () => {
|
||||||
|
expect(captionWords(" Set a\ttimer \n")).toEqual(["Set", "a", "timer"]);
|
||||||
|
expect(captionWords(" ")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats appended words and a completing last word as a continuation", () => {
|
||||||
|
expect(continuesCaption("Set a", "Set a timer for ten")).toBe(true);
|
||||||
|
expect(continuesCaption("remind me to wat", "remind me to water the plants")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts for a new utterance", () => {
|
||||||
|
expect(continuesCaption("", "Hello")).toBe(false);
|
||||||
|
expect(continuesCaption("Set a timer", "Set a")).toBe(false);
|
||||||
|
expect(continuesCaption("Set a timer", "Get a timer now")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
apps/web/src/lib/voice-aurora.ts
Normal file
74
apps/web/src/lib/voice-aurora.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { botColors } from "@rakazo/ui-tokens";
|
||||||
|
import type { VoicePhase } from "../components/voice/VoiceStage";
|
||||||
|
|
||||||
|
/** One blurred light source rising from the bottom edge of the voice panel. */
|
||||||
|
export type AuroraBlob = {
|
||||||
|
color: string;
|
||||||
|
/** Horizontal center, percent of the panel width. */
|
||||||
|
x: number;
|
||||||
|
/** Height the glow reaches up the panel, percent. */
|
||||||
|
rise: number;
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Center first, then alternating outward, so the speaking Oo's color sits in
|
||||||
|
// the middle of the arc and the rest of the bot palette fans out around it.
|
||||||
|
const SLOTS = [
|
||||||
|
{ x: 50, weight: 1 },
|
||||||
|
{ x: 32, weight: 0.86 },
|
||||||
|
{ x: 68, weight: 0.86 },
|
||||||
|
{ x: 14, weight: 0.64 },
|
||||||
|
{ x: 86, weight: 0.64 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** The bot palette rotated so `botColor` leads. Unknown colors lead the full palette. */
|
||||||
|
export function auroraSpectrum(botColor: string): string[] {
|
||||||
|
const palette: string[] = [...botColors];
|
||||||
|
const index = palette.findIndex((color) => color.toLowerCase() === botColor.toLowerCase());
|
||||||
|
if (index < 0) return [botColor, ...palette];
|
||||||
|
return [...palette.slice(index), ...palette.slice(0, index)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listening follows the mic, thinking settles low while the arc drifts,
|
||||||
|
* speaking holds a full bloom. Energy is clamped to 0–1.
|
||||||
|
*/
|
||||||
|
export function voiceAuroraBlobs(
|
||||||
|
botColor: string,
|
||||||
|
phase: VoicePhase,
|
||||||
|
energy: number,
|
||||||
|
): AuroraBlob[] {
|
||||||
|
const level = Math.min(1, Math.max(0, Number.isFinite(energy) ? energy : 0));
|
||||||
|
const base =
|
||||||
|
phase === "listening"
|
||||||
|
? { rise: 44 + level * 40, opacity: 0.72 + level * 0.28 }
|
||||||
|
: phase === "thinking"
|
||||||
|
? { rise: 36, opacity: 0.6 }
|
||||||
|
: { rise: 66, opacity: 0.95 };
|
||||||
|
const spectrum = auroraSpectrum(botColor);
|
||||||
|
return SLOTS.map((slot, index) => ({
|
||||||
|
color: spectrum[index % spectrum.length] ?? botColor,
|
||||||
|
x: slot.x,
|
||||||
|
rise: Math.round(base.rise * slot.weight),
|
||||||
|
opacity: Number((base.opacity * slot.weight).toFixed(3)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Words of the live caption; whitespace-only input yields none. */
|
||||||
|
export function captionWords(text: string): string[] {
|
||||||
|
return text.trim().split(/\s+/).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `next` extends `previous` word by word, so the words already on
|
||||||
|
* screen stay put and only the new tail animates in.
|
||||||
|
*/
|
||||||
|
export function continuesCaption(previous: string, next: string): boolean {
|
||||||
|
const before = captionWords(previous);
|
||||||
|
const after = captionWords(next);
|
||||||
|
if (!before.length || after.length < before.length) return false;
|
||||||
|
// The last spoken word may still be completing ("wat" -> "water").
|
||||||
|
return before.every((word, index) =>
|
||||||
|
index === before.length - 1 ? after[index]?.startsWith(word) : after[index] === word,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -173,6 +173,7 @@ import { SkillDraftCard } from "../components/teach/SkillDraftCard";
|
|||||||
import { TeachCaptureOverlay } from "../components/teach/TeachCaptureOverlay";
|
import { TeachCaptureOverlay } from "../components/teach/TeachCaptureOverlay";
|
||||||
import { TeachComputerOverlayControl } from "../components/teach/TeachComputerOverlay";
|
import { TeachComputerOverlayControl } from "../components/teach/TeachComputerOverlay";
|
||||||
import { TeachRecordingChrome, TeachStopButton } from "../components/teach/TeachRecordingChrome";
|
import { TeachRecordingChrome, TeachStopButton } from "../components/teach/TeachRecordingChrome";
|
||||||
|
import { VoiceAuroraRim } from "../components/voice/VoiceAurora";
|
||||||
import { useSidebarResize } from "../hooks/useSidebarResize";
|
import { useSidebarResize } from "../hooks/useSidebarResize";
|
||||||
import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
|
import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
|
||||||
import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity";
|
import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity";
|
||||||
@ -4626,6 +4627,7 @@ export function ShellPage() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
voiceReady={Boolean(voiceStatus?.ready)}
|
voiceReady={Boolean(voiceStatus?.ready)}
|
||||||
|
voiceColor={voiceCallBot?.color}
|
||||||
replyTarget={activeReplyTarget}
|
replyTarget={activeReplyTarget}
|
||||||
replyQuote={activeReplyQuote}
|
replyQuote={activeReplyQuote}
|
||||||
replyTargetName={replyTargetName}
|
replyTargetName={replyTargetName}
|
||||||
@ -6362,6 +6364,7 @@ const Composer = memo(function Composer({
|
|||||||
onStop,
|
onStop,
|
||||||
onVoice,
|
onVoice,
|
||||||
voiceReady = false,
|
voiceReady = false,
|
||||||
|
voiceColor,
|
||||||
replyTarget,
|
replyTarget,
|
||||||
replyQuote,
|
replyQuote,
|
||||||
replyTargetName,
|
replyTargetName,
|
||||||
@ -6389,6 +6392,7 @@ const Composer = memo(function Composer({
|
|||||||
onStop: () => Promise<void>;
|
onStop: () => Promise<void>;
|
||||||
onVoice?: () => void;
|
onVoice?: () => void;
|
||||||
voiceReady?: boolean;
|
voiceReady?: boolean;
|
||||||
|
voiceColor?: string;
|
||||||
replyTarget?: ThreadMessage | null;
|
replyTarget?: ThreadMessage | null;
|
||||||
replyQuote?: string | null;
|
replyQuote?: string | null;
|
||||||
replyTargetName?: string;
|
replyTargetName?: string;
|
||||||
@ -7015,10 +7019,13 @@ const Composer = memo(function Composer({
|
|||||||
data-testid="composer-voice-primary"
|
data-testid="composer-voice-primary"
|
||||||
className={`tactile-press h-control-lg shrink-0 gap-2 rounded-full px-4 sm:px-5 flex items-center justify-center cursor-pointer transition-all ${
|
className={`tactile-press h-control-lg shrink-0 gap-2 rounded-full px-4 sm:px-5 flex items-center justify-center cursor-pointer transition-all ${
|
||||||
voiceReady
|
voiceReady
|
||||||
? "border-cyan-400/35 bg-gradient-to-b from-cyan-500/20 to-cyan-950/40 text-cyan-50 shadow-[0_0_28px_rgba(34,211,238,0.22),inset_0_1px_0_rgba(255,255,255,0.16)] hover:border-cyan-300/50 hover:from-cyan-400/28 hover:to-cyan-950/50 voice-primary-ready"
|
? "relative overflow-hidden border-border bg-foreground/5 text-foreground hover:bg-foreground/10"
|
||||||
: "border-amber-400/25 bg-white/[0.06] text-foreground/85 hover:bg-white/[0.12] hover:text-white"
|
: "border-border bg-foreground/5 text-foreground/80 hover:bg-foreground/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{voiceReady ? (
|
||||||
|
<VoiceAuroraRim botColor={voiceColor} className="inset-x-[14%] h-0.5 blur-[3px]" />
|
||||||
|
) : null}
|
||||||
<Mic strokeWidth={1.9} className="size-icon-lg shrink-0" />
|
<Mic strokeWidth={1.9} className="size-icon-lg shrink-0" />
|
||||||
<span className="hidden text-ui-body font-semibold sm:inline">
|
<span className="hidden text-ui-body font-semibold sm:inline">
|
||||||
<Trans>Voice</Trans>
|
<Trans>Voice</Trans>
|
||||||
|
|||||||
@ -261,49 +261,6 @@ body,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Voice Living Aura Animations */
|
|
||||||
@keyframes voiceAuraCyan {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 0.45;
|
|
||||||
filter: blur(32px);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.18);
|
|
||||||
opacity: 0.85;
|
|
||||||
filter: blur(44px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes voiceAuraViolet {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(0.95) rotate(0deg);
|
|
||||||
opacity: 0.5;
|
|
||||||
filter: blur(34px);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.12) rotate(180deg);
|
|
||||||
opacity: 0.85;
|
|
||||||
filter: blur(48px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes voiceAuraEmerald {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 0.5;
|
|
||||||
filter: blur(32px);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.22);
|
|
||||||
opacity: 0.9;
|
|
||||||
filter: blur(46px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes orbitalSpin {
|
@keyframes orbitalSpin {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
@ -369,7 +326,7 @@ body,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Voice Stage — dual-mind bloom field */
|
/* Text stage bloom */
|
||||||
@keyframes voiceBloomDrift {
|
@keyframes voiceBloomDrift {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
@ -382,18 +339,6 @@ body,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes voiceBloomSecondary {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(0.96) translate(0, 0);
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.1) translate(-2%, 1%);
|
|
||||||
opacity: 0.82;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes onboarding-oo-pop {
|
@keyframes onboarding-oo-pop {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(0.92);
|
transform: scale(0.92);
|
||||||
@ -409,30 +354,6 @@ body,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes voiceGlassRing {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 0.28;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.04);
|
|
||||||
opacity: 0.42;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes voiceGlassRingInner {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1) rotate(0deg);
|
|
||||||
opacity: 0.22;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(0.97) rotate(8deg);
|
|
||||||
opacity: 0.36;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes voicePrimaryPulse {
|
@keyframes voicePrimaryPulse {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
@ -447,46 +368,12 @@ body,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-primary-ready {
|
|
||||||
animation: voicePrimaryPulse 3.6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-bloom-cyan {
|
|
||||||
animation: voiceBloomDrift 5.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-bloom-emerald {
|
.voice-bloom-emerald {
|
||||||
animation: voiceBloomDrift 4.4s ease-in-out infinite;
|
animation: voiceBloomDrift 4.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-bloom-violet {
|
|
||||||
animation: voiceBloomDrift 6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-bloom-secondary {
|
|
||||||
animation: voiceBloomSecondary 7.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-glass-ring {
|
|
||||||
animation: voiceGlassRing 8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-glass-ring-inner {
|
|
||||||
animation: voiceGlassRingInner 11s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-grain {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.voice-bloom-cyan,
|
.voice-bloom-emerald {
|
||||||
.voice-bloom-emerald,
|
|
||||||
.voice-bloom-violet,
|
|
||||||
.voice-bloom-secondary,
|
|
||||||
.voice-glass-ring,
|
|
||||||
.voice-glass-ring-inner,
|
|
||||||
.voice-primary-ready {
|
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user