feat(human): mascot tab with viseme-driven lipsync (staging only)#1127
Conversation
Ports the Remotion Ghosty SVG into the app, replacing useCurrentFrame with a RAF-driven clock so it renders at runtime. Adds /human route and a "Human" entry in the bottom nav. Thread panel is a placeholder until the next phase embeds Conversations.
Adds 8 mouth shape presets and a parametric path renderer so the mascot can animate between visemes. useHumanMascot subscribes to chat events and drives face state plus a viseme picked from each text_delta — the mouth opens on each streamed token and decays back to rest, giving a "talking" feel without requiring TTS audio. Embeds Conversations in the right rail using its existing variant="sidebar" support.
Wires the mascot to the staging-admin /openai/v1/audio/speech endpoint introduced in tinyhumansai/backend#677. After chat_done lands, the full response is sent to ElevenLabs with with_visemes=true; the returned mp3 plays in the browser and the mouth tracks the Oculus 15-set viseme timeline keyed off audio.currentTime, falling back to neutral on error or when audio ends. A "Speak replies" toggle on /human (default on, persisted in localStorage) gates the TTS call. Token-stream pseudo-lipsync still covers the streaming-while-typing phase before audio arrives.
The sidebar was rendered as a full-height edge-to-edge rail, so its composer landed under the floating BottomTabBar and the input was unclickable. Switch to an absolute-positioned floating panel with a bottom margin tall enough for the nav, plus rounded corners and shadow to read as a card over the mascot stage.
The mascot is still WIP. Hide both the BottomTabBar entry and the React Router route entirely when APP_ENVIRONMENT is 'production' so shipped builds don't surface a half-finished feature; staging and local development still see it for iteration.
ESLint config flagged Audio, performance, requestAnimationFrame, and cancelAnimationFrame as undefined. Existing components reference these via window.* — match that convention. Also picks up lint:fix auto-formatting (import ordering, type-import position) on touched files.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
✅ Files skipped from review due to trivial changes (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughAdds a non-production "Human" feature: a Ghosty SVG mascot with viseme-driven mouth animation, TTS playback and viseme timeline handling, a useHumanMascot hook wired to chat events, a HumanPage UI, and conditional routing/tab visibility when APP_ENVIRONMENT !== 'production'. ChangesHuman Mascot Feature
sequenceDiagram
participant Chat as Chat/Stream
participant Hook as useHumanMascot
participant TTS as TTS API
participant Audio as Audio Player
participant Mascot as Ghosty Component
Chat->>Hook: onInferenceStart
Hook->>Mascot: face = 'thinking'
Chat->>Hook: onTextDelta(text segment)
Hook->>Hook: pickViseme(delta)
Hook->>Mascot: viseme = heuristic shape
Chat->>Hook: onDone(full response)
alt speakReplies enabled
Hook->>TTS: synthesizeSpeech(text, with_visemes: true)
TTS-->>Hook: { audio_base64, visemes[] }
Hook->>Audio: playBase64Audio(audio_base64)
Audio-->>Hook: PlaybackHandle
Hook->>Mascot: face = 'speaking'
par Playback / Viseme tracking
Audio->>Hook: time updates (currentMs)
Hook->>Hook: findActiveFrame(visemes, currentMs)
Hook->>Mascot: viseme = active frame shape
end
Audio->>Hook: onEnded/onError
Hook->>Mascot: face = 'normal', viseme = rest
else
Hook->>Mascot: viseme decays to rest
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Review rate limit: 1/5 review remaining, refill in 39 minutes and 3 seconds. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
app/src/features/human/Mascot/Defs.tsx (1)
1-8: ⚡ Quick winUse type-only React imports and a named props interface.
This component uses a runtime
Reactimport only for typing and defines props inline. Converting toimport type+ aGhostyDefsPropsinterface will align with repo TS rules and improve reuse/readability.♻️ Suggested refactor
-import React from 'react'; +import type { FC } from 'react'; import { BODY_PATH } from './paths'; -export const GhostyDefs: React.FC<{ idPrefix: string; bodyColor: string }> = ({ +interface GhostyDefsProps { + idPrefix: string; + bodyColor: string; +} + +export const GhostyDefs: FC<GhostyDefsProps> = ({ idPrefix, bodyColor, }) => {As per coding guidelines, "
**/*.{ts,tsx}: Useimport typefor type-only imports in TypeScript" and "Preferinterfacefor defining object shapes in TypeScript."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/features/human/Mascot/Defs.tsx` around lines 1 - 8, The GhostyDefs component currently imports React at runtime and uses an inline prop type; change the runtime import to a type-only import (e.g., import type { FC } from 'react') and introduce a named interface GhostyDefsProps for the props (idPrefix: string; bodyColor: string), then update the component signature to use GhostyDefsProps and the type-only FC where applicable (preserve existing references like GhostyDefs and BODY_PATH).app/src/features/human/HumanPage.tsx (1)
10-17: 🏗️ Heavy liftMove
speakRepliespersistence to Redux/persist instead of localStorage.Lines 10-17 introduce ad hoc localStorage-backed state inside
app/src. Please route this through the existing Redux/persist pattern for consistency and centralized state behavior.As per coding guidelines
app/src/**/*.{ts,tsx}: Use Redux and persist where configured for app state; prefer Redux over ad hoc localStorage for state management.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/features/human/HumanPage.tsx` around lines 10 - 17, The local localStorage-backed state in HumanPage (speakReplies, setSpeakReplies, SPEAK_REPLIES_KEY and the useEffect) should be removed and replaced with a Redux-persisted piece of state: create or extend a slice (e.g., uiPreferences or humanPage) with a speakReplies boolean, wire it into the store persistence config, then in HumanPage use useSelector to read speakReplies and useDispatch to update it (dispatching an action like setSpeakReplies) instead of useState/useEffect and direct localStorage access; ensure the slice action and persist config names match existing store conventions so the value is persisted automatically.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/src/features/human/useHumanMascot.ts`:
- Around line 116-135: The startTtsPlayback function uses a .then() chain and
has a race where an earlier audio's completion callback can clear state for a
newer playback; rewrite the completion handling to use async/await (avoid
.then()) and guard by comparing playbackRef.current === handle before clearing
visemeFramesRef, visemeCursorRef and resetting face; ensure you await
playBase64Audio to get the handle, set playbackRef.current = handle, then in a
try/finally (or after awaiting handle.ended) only clear state if
playbackRef.current === handle so stale callbacks don't override newer playbacks
(symbols: startTtsPlayback, synthesizeSpeech, playBase64Audio, playbackRef,
visemeFramesRef, visemeCursorRef, setFace).
In `@app/src/features/human/voice/audioPlayer.ts`:
- Line 47: audio.play() can reject before playback starts and leak the created
object URL and leave callers without a PlaybackHandle; wrap the await
audio.play() call in a try/catch, and in the catch revoke the created object URL
(the variable used as the audio.src/objectUrl) and clean up the audio element
before rethrowing the error so callers still receive the rejection and no blob
URL is leaked; update the code paths that would have returned a PlaybackHandle
to only do so after successful play() and ensure the catch does not return a
handle.
In `@app/src/features/human/voice/visemeMap.ts`:
- Around line 53-57: The code uses cursor directly to compute i which can be
negative and cause frames[i] to be undefined; fix by clamping cursor before
using it (e.g., compute i = Math.min(Math.max(0, cursor), frames.length - 1)) so
i is always within [0, frames.length-1], then proceed with the existing
rewind/forward loops that reference frames, affecting the logic around variables
cursor, i, frames, and ms.
---
Nitpick comments:
In `@app/src/features/human/HumanPage.tsx`:
- Around line 10-17: The local localStorage-backed state in HumanPage
(speakReplies, setSpeakReplies, SPEAK_REPLIES_KEY and the useEffect) should be
removed and replaced with a Redux-persisted piece of state: create or extend a
slice (e.g., uiPreferences or humanPage) with a speakReplies boolean, wire it
into the store persistence config, then in HumanPage use useSelector to read
speakReplies and useDispatch to update it (dispatching an action like
setSpeakReplies) instead of useState/useEffect and direct localStorage access;
ensure the slice action and persist config names match existing store
conventions so the value is persisted automatically.
In `@app/src/features/human/Mascot/Defs.tsx`:
- Around line 1-8: The GhostyDefs component currently imports React at runtime
and uses an inline prop type; change the runtime import to a type-only import
(e.g., import type { FC } from 'react') and introduce a named interface
GhostyDefsProps for the props (idPrefix: string; bodyColor: string), then update
the component signature to use GhostyDefsProps and the type-only FC where
applicable (preserve existing references like GhostyDefs and BODY_PATH).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 3f8ceb9b-022e-48c1-bbff-e21a57fe2ab9
📒 Files selected for processing (16)
app/src/AppRoutes.tsxapp/src/components/BottomTabBar.tsxapp/src/features/human/HumanPage.tsxapp/src/features/human/Mascot/Defs.tsxapp/src/features/human/Mascot/Ghosty.tsxapp/src/features/human/Mascot/index.tsapp/src/features/human/Mascot/paths.tsapp/src/features/human/Mascot/useMascotClock.tsapp/src/features/human/Mascot/visemes.test.tsapp/src/features/human/Mascot/visemes.tsapp/src/features/human/useHumanMascot.test.tsapp/src/features/human/useHumanMascot.tsapp/src/features/human/voice/audioPlayer.tsapp/src/features/human/voice/ttsClient.tsapp/src/features/human/voice/visemeMap.test.tsapp/src/features/human/voice/visemeMap.ts
Three fixes from CodeRabbit: - useHumanMascot: race condition in startTtsPlayback. A new chat_done could overlap an in-flight playback whose handle.ended callback would then unconditionally reset face/playbackRef back to idle, clobbering the newer run. Added a monotonic seq counter; only the current run mutates idle state. Also stops the previous playback on entry, converted .then() chain to async/await with try/finally so cleanup runs on every exit path, and bumps seq on unmount / onError so awaiters don't write to a torn-down component. - audioPlayer: audio.play() can reject (autoplay policy, codec) before any event handler fires, which leaked the blob URL and left callers without a PlaybackHandle. Wrapped in try/catch — cleanup() runs and the ended promise is rejected before re-throwing. - visemeMap.findActiveFrame: a negative cursor produced frames[i] = undefined and threw on the next access. Clamped the lower bound to 0; added a regression test.
* feat(remotion): Ghosty character library with transparent MOV variants (tinyhumansai#1059) Co-authored-by: WOZCODE <contact@withwoz.com> * feat(composio/gmail): sync into memory tree (Slack-parity) (tinyhumansai#1056) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(scheduler-gate): throttle background AI on battery / busy CPU (tinyhumansai#1062) * fix(core,cef): run core in-process and stop orphaning CEF helpers on Cmd+Q (tinyhumansai#1061) * ci: add dedicated staging release workflow (tinyhumansai#1066) * fix(sentry): Rust source context + per-release deploy marker (tinyhumansai#405) (tinyhumansai#1067) * fix(welcome): re-enable OAuth buttons with focus/timeout recovery (tinyhumansai#1049) (tinyhumansai#1069) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(dependencies): update pnpm-lock.yaml and Cargo.lock for package… (tinyhumansai#1082) * fix(onboarding): personalize welcome agent greeting with user identity (tinyhumansai#1078) * fix(chat): make agent message bubbles fit content width (tinyhumansai#1083) * Feat/dmg checks (tinyhumansai#1084) * fix(linux): Add X11 platform flags to .deb package launcher (tinyhumansai#1087) Co-authored-by: unn-Known1 <unn-known1@users.noreply.github.com> * fix(sentry): auto-send React events; collapse core→tauri for desktop (tinyhumansai#1086) Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai> * fix(cef): run blank reload guard on the CEF UI thread (tinyhumansai#1092) * fix(app): reload webview instead of restart_app in dev mode (tinyhumansai#1068) (tinyhumansai#1071) * fix(linux): deliver X11 ozone flags via custom .desktop template (tinyhumansai#1091) * fix(webview-accounts): retry data-dir purge so CEF handle race doesn't leak cookies (tinyhumansai#1076) (tinyhumansai#1081) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai> * fix(webview/slack): media perms + deep-link isolation (tinyhumansai#1074) (tinyhumansai#1080) Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai> * ci(release): split staging vs production workflows; promote staging tags (tinyhumansai#1094) * Update release-staging.yml (tinyhumansai#1097) * chore(staging): v0.53.5 * chore(staging): v0.53.6 * ci(staging): cut staging from main; add act local-debug helper (tinyhumansai#1099) * chore(staging): v0.53.7 * fix(ci): correct sentry-cli download URL and trap scope (tinyhumansai#1100) * chore(staging): v0.53.8 * feat(chat): forward thread_id to backend for KV cache locality (tinyhumansai#1095) * fix(ci): bump pinned sentry-cli to 3.4.1 (2.34.2 was never published) (tinyhumansai#1102) * chore(staging): v0.53.9 * fix(ci): drop bash trap in upload_sentry_symbols.sh; inline cleanup (tinyhumansai#1103) * chore(staging): v0.53.10 * refactor(session): flatten session_raw/, switch md to YYYY_MM_DD (tinyhumansai#1098) * Add full Composio managed-auth toolkit catalog (tinyhumansai#1093) * ci: add diff-aware 80% coverage gate (Vitest + cargo-llvm-cov) (tinyhumansai#1104) * feat(scripts): pnpm work + pnpm debug for agent-driven workflows (tinyhumansai#1105) * ci: pull pnpm into CI image, drop redundant setup steps (tinyhumansai#1107) * docs: add Cursor Cloud specific instructions to AGENTS.md (tinyhumansai#1106) Co-authored-by: Cursor Agent <cursoragent@cursor.com> * chore(staging): v0.53.11 * docs: surface 80% coverage gate and scripts/debug runners (tinyhumansai#1108) * feat(app): show Composio integrations as sorted icon grid on Skills (tinyhumansai#1109) Co-authored-by: Cursor Agent <cursoragent@cursor.com> * feat(composio): client-side trigger enable/disable toggles (tinyhumansai#1110) * feat(skills): channels grid + integrations card polish; tolerant Composio trigger decode (tinyhumansai#1112) * chore(staging): v0.53.12 * feat(home): early-bird banner + assistant→agent terminology (tinyhumansai#1113) * feat(updater): in-app auto-update with auto-download + restart prompt (tinyhumansai#677) (tinyhumansai#1114) * chore(claude): add ship-and-babysit slash command (tinyhumansai#1115) * feat(home): EarlyBirdyBanner + agent terminology + LinkedIn enrichment model pin (tinyhumansai#1118) * fix(chat): single onboarding thread in sidebar after wizard (tinyhumansai#1116) Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Steven Enamakel <senamakel@users.noreply.github.com> * fix: filter out global namespace from citation chips (tinyhumansai#1124) Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: senamakel-droid <281415773+senamakel-droid@users.noreply.github.com> * feat(nav): enable Memory tab in BottomTabBar (tinyhumansai#1125) * feat(memory): singleton ingestion + status RPC + UI pill (tinyhumansai#1126) * feat(human): mascot tab with viseme-driven lipsync (staging only) (tinyhumansai#1127) * Fix CEF zombie processes on full app close and restart (tinyhumansai#1128) Co-authored-by: senamakel-droid <281415773+senamakel-droid@users.noreply.github.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai> * Update issue templates for GitHub issue types (tinyhumansai#1146) * feat(human): expand mascot expressions and tighten reply-speech state machine (tinyhumansai#1147) * feat(memory): ingestion pipeline + tree-architecture docs + ops/schemas split (tinyhumansai#1142) * feat(threads): surface live subagent work in parent thread (tinyhumansai#1122) (tinyhumansai#1159) * fix(human): keep mascot mouth animating when TTS ships no viseme data (tinyhumansai#1160) * feat(composio): consume backend markdownFormatted for LLM output (tinyhumansai#1165) * fix(subagent): lazy-register toolkit actions filtered out of fuzzy top-K (tinyhumansai#1162) * feat(memory): user-facing long-term memory window preset (tinyhumansai#1137) (tinyhumansai#1161) * fix(tauri-shell): proactively kill stale openhuman RPC on startup (tinyhumansai#1166) * chore(staging): v0.53.13 * fix(composio): per-action tool consumes backend markdownFormatted (tinyhumansai#1167) * fix(threads): persist selectedThreadId across reloads (tinyhumansai#1168) * feat(memory_tree): switch embed model to bge-m3 (1024-dim, 8K context) (tinyhumansai#1174) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): drop redundant [Memory context] recall injection (tinyhumansai#1173) * chore(memory_tree): drop body-read timeouts on Ollama HTTP calls (tinyhumansai#1171) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(transcript): emit thread_id + fix orchestrator missing cost (tinyhumansai#1169) * fix(composio/gmail): phase out html2md, prefer text/plain MIME part (tinyhumansai#1170) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(tools): markdown output for internal tool results (tinyhumansai#1172) * feat(security): enforce prompt-injection guard before model and tool execution (tinyhumansai#1175) * fix(cef): popup paint dies after first frame — skip blank-page guard for popups (tinyhumansai#1079) (tinyhumansai#1182) Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com> * chore(sentry): rename OPENHUMAN_SENTRY_DSN → OPENHUMAN_CORE_SENTRY_DSN (tinyhumansai#1186) * feat(remotion): add yellow mascot character with all animation variants (tinyhumansai#1193) Co-authored-by: Neel Mistry <neelmistry@Neels-MacBook-Pro.local> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(composio): hide raw connection ID, derive friendly label (tinyhumansai#1153) (tinyhumansai#1185) Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> * fix(windows): align install.ps1 MSI with per-machine scope (tinyhumansai#913) (tinyhumansai#1187) Co-authored-by: Cursor <cursoragent@cursor.com> * fix(tauri): deterministic CEF teardown on full app close (tinyhumansai#1120) (tinyhumansai#1189) Co-authored-by: Cursor <cursoragent@cursor.com> * fix(composio): cap Gmail HTML body before strip (crash mitigation) (tinyhumansai#1191) Co-authored-by: Cursor <cursoragent@cursor.com> * fix(auth): stop stale chat threads after signup (tinyhumansai#1192) Co-authored-by: Cursor <cursoragent@cursor.com> * feat(sentry): staging-only "Trigger Sentry Test" button (tinyhumansai#1072) (tinyhumansai#1183) * chore(staging): v0.53.14 * chore(staging): v0.53.15 * feat(composio): format trigger slugs into human-readable labels (tinyhumansai#1129) (tinyhumansai#1179) Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> * fix(ui): hide unsupported permission UI on non-macOS for Screen Intelligence (tinyhumansai#1194) Co-authored-by: Cursor <cursoragent@cursor.com> * chore(tauri-shell): retire embedded Gmail webview-account flow (tinyhumansai#1181) * feat(onboarding): replace welcome-agent bot with react-joyride walkthrough (tinyhumansai#1180) * chore(release): v0.53.16 * fix(threads): preserve selectedThreadId on cold-boot identity hydration (tinyhumansai#1196) * feat(core): version/shutdown/update RPCs + mid-thread integration refresh (tinyhumansai#1195) * fix(mascot): swap to yellow mascot via @remotion/player (tinyhumansai#1200) * feat(memory_tree): cloud-default LLM, queue priority, entity filter, Memory tab UI (tinyhumansai#1198) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Persist turn state + restore conversation history on cold-boot (tinyhumansai#1202) * feat(mascot): floating desktop mascot via native NSPanel + WKWebView (macOS) (tinyhumansai#1203) * fix(memory/tree): emit summary children as Obsidian wikilinks (tinyhumansai#1210) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(tools): coding-harness baseline primitives (tinyhumansai#1205) (tinyhumansai#1208) * docs: add Codex PR checklist for remote agents --------- Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Co-authored-by: WOZCODE <contact@withwoz.com> Co-authored-by: sanil-23 <sanil@vezures.xyz> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Co-authored-by: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Co-authored-by: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Co-authored-by: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Co-authored-by: Gaurang Patel <ptelgm.yt@gmail.com> Co-authored-by: unn-Known1 <unn-known1@users.noreply.github.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Steven Enamakel <senamakel@users.noreply.github.com> Co-authored-by: Steven Enamakel's Droid <enamakel.agent@tinyhumans.ai> Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: senamakel-droid <281415773+senamakel-droid@users.noreply.github.com> Co-authored-by: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Co-authored-by: Neil <neil@maha.xyz> Co-authored-by: Neel Mistry <neelmistry@Neels-MacBook-Pro.local> Co-authored-by: obchain <167975049+obchain@users.noreply.github.com> Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
Summary
/humantab — Ghosty SVG mascot with idle bob, blink, and viseme-driven mouth, paired with a floating chat sidebar that reusesConversationsinvariant=\"sidebar\"mode.inference_start→ thinking,text_delta→ speaking with letter-driven pseudo-lipsync while the response streams,chat_done→ ElevenLabs TTS via the new backendPOST /openai/v1/audio/speech(tinyhumansai/backend#677) withwith_visemes:true, then real Oculus 15-set viseme timeline keyed offaudio.currentTime.Problem
We want the agent to feel like a person, not a chat log. Voice and a face you can watch are the entry points. This PR ships the visual+lipsync foundation so the next iteration (microphone input, persistent personality state) has somewhere to land.
Solution
app/src/features/human/Mascot/) — Ghosty SVG ported fromremotion/src/Ghosty, withuseCurrentFrameswapped for a RAF-drivenuseMascotClock. Mouth is parametric: 8 viseme presets (REST/A/E/I/O/U/M/F) generated from{openness, width}, with the resting smile preserved whenopenness < 0.05.useHumanMascot) — subscribes to chat events. During streaming, picks a viseme from the trailing letter of eachtext_deltaand decays back to rest (180 ms). Onchat_done, fires the TTS request, plays the returned mp3, and readsaudio.currentTimeagainst the Oculus viseme timeline via a sticky-cursorfindActiveFrameso playback stays O(1) per frame.features/human/voice/) — thinapiClient.post('/openai/v1/audio/speech', ...)wrapper, base64 → blob →HTMLAudioplayer, Oculus → mascot shape map.BottomTabBar. "Speak replies" toggle persists inlocalStorage.APP_ENVIRONMENT !== 'production'hides both the tab and the route in shipped builds.Submission Checklist
docs/TESTING-STRATEGY.mddocs/TESTING-STRATEGY.md)Impact
pnpm devwill show the tab too in dev). No new native dependencies; audio playback usesHTMLAudio.POST /openai/v1/audio/speechagainst the configured backend (VITE_BACKEND_URL, expected to point at staging-admin during testing). Backend endpoint added in tinyhumansai/backend#677.APP_ENVIRONMENT).Related
faceis computed but onlyvisemedrives the SVG), surface a per-message replay button so users can re-hear a reply.Summary by CodeRabbit
New Features
Tests