Merge origin/main into claude/phase1-main-agent
Brings in the aurora voice stage so the Phase 1 workstreams integrate on current main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
8cfbca51b2
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