Restore full project to commit 3f1dfe42

#8
by bep40 - opened
Files changed (4) hide show
  1. README.md +13 -16
  2. index.html +2 -56
  3. index.ts +73 -2
  4. src/app.js +1 -1
README.md CHANGED
@@ -1,23 +1,19 @@
1
  ---
2
- title: Gemma Avatar + V.AI STUDIO — 3D Talking Head & Lip Sync AI
3
  emoji: 🗣️
4
  colorFrom: indigo
5
  colorTo: purple
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
9
- thumbnail: https://huggingface.co/spaces/bep40/gemma-avatar/resolve/main/thumbnail.webp
10
- short_description: 3D AI avatar talking-head + lip-sync & V.AI products
11
  models:
12
  - google/gemma-4-31B-it
13
  - nvidia/parakeet-tdt-1.1b
14
  - Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
15
  tags:
16
- - v-ai-studio
17
- - gemma-4
18
- - ai-avatar
19
- - lip-sync
20
- - voice-chat
21
  ---
22
 
23
  # Gemma Avatar
@@ -72,9 +68,9 @@ bun run dev # direct mode: paste a ws:/
72
 
73
  Open http://localhost:3000 and tap **Start talking**.
74
 
75
- `?fakemic=1` starts a session with a silent synthetic mic (useful for testing
76
  the full loop without a microphone — trigger a reply from the console with
77
- `getClient().requestResponse()`).
78
 
79
  ## Layout
80
 
@@ -83,20 +79,21 @@ index.ts Bun server: HTML import + /api/session proxy + stati
83
  index.html App shell (avatar hero, caption, subtitles, settings)
84
  src/app.js Session wiring, tool executor, UI state
85
  src/avatar.js AvatarStage: TalkingHead + HeadAudio + choreography
86
- src/s2s/s2s-ws-client.js Realtime WS client
 
87
  src/s2s/codec.js PCM/base64 + transcript helpers (vendored, unchanged)
88
  src/vendor/headaudio.min.mjs HeadAudio node class (bundled)
89
- public/worklets/ mic-capture + audio-playback AudioWorklets
90
- public/vendor/ HeadAudio worklet processor + viseme model
91
- public/avatars/brunette.glb Default avatar (Ready Player Me; CC BY-NC 4.0)
92
  ```
93
 
94
  ## Notes
95
 
96
  - The avatar GLB must have a Mixamo-compatible rig plus ARKit and Oculus-viseme
97
  blend shapes. Ready Player Me avatars work with
98
- `?morphTargets=ARKit,Oculus%20Visemes` on the GLB URL.
99
- - TalkingHead owns the AudioContext; the s2s client is handed `head.audioCtx`
100
  and never closes it.
101
  - Everything animation-related runs on requestAnimationFrame — a backgrounded
102
  tab freezes the avatar (audio keeps playing).
 
1
  ---
2
+ title: Gemma Avatar
3
  emoji: 🗣️
4
  colorFrom: indigo
5
  colorTo: purple
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
9
+ thumbnail: https://huggingface.co/spaces/victor/gemma-avatar/resolve/main/thumbnail.webp
10
+ short_description: Talk to Gemma 4 face to face, with a 3D lip-synced avatar
11
  models:
12
  - google/gemma-4-31B-it
13
  - nvidia/parakeet-tdt-1.1b
14
  - Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
15
  tags:
16
+ - ml-intern
 
 
 
 
17
  ---
18
 
19
  # Gemma Avatar
 
68
 
69
  Open http://localhost:3000 and tap **Start talking**.
70
 
71
+ \`?fakemic=1\` starts a session with a silent synthetic mic (useful for testing
72
  the full loop without a microphone — trigger a reply from the console with
73
+ \`getClient().requestResponse()\`).
74
 
75
  ## Layout
76
 
 
79
  index.html App shell (avatar hero, caption, subtitles, settings)
80
  src/app.js Session wiring, tool executor, UI state
81
  src/avatar.js AvatarStage: TalkingHead + HeadAudio + choreography
82
+ src/s2s/s2s-ws-client.js Realtime WS client (vendored from the Space; orb removed,
83
+ injectable output node + worklet base URL + shared-ctx close)
84
  src/s2s/codec.js PCM/base64 + transcript helpers (vendored, unchanged)
85
  src/vendor/headaudio.min.mjs HeadAudio node class (bundled)
86
+ public/worklets/ mic-capture + audio-playback AudioWorklets (vendored, unchanged)
87
+ public/vendor/ HeadAudio worklet processor + viseme model (runtime-loaded)
88
+ public/avatars/brunette.glb Default avatar (Ready Player Me; CC BY-NC 4.0 — non-commercial)
89
  ```
90
 
91
  ## Notes
92
 
93
  - The avatar GLB must have a Mixamo-compatible rig plus ARKit and Oculus-viseme
94
  blend shapes. Ready Player Me avatars work with
95
+ \`?morphTargets=ARKit,Oculus%20Visemes\` on the GLB URL.
96
+ - TalkingHead owns the AudioContext; the s2s client is handed \`head.audioCtx\`
97
  and never closes it.
98
  - Everything animation-related runs on requestAnimationFrame — a backgrounded
99
  tab freezes the avatar (audio keeps playing).
index.html CHANGED
@@ -3,61 +3,7 @@
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
-
7
- <!-- Page Title (keyword-rich, action-oriented) -->
8
- <title>Gemma Avatar + V.AI STUDIO -- Real-Time 3D Chat &amp; 8000+ Products</title>
9
-
10
- <!-- SEO: Canonical URL -->
11
- <link rel="canonical" href="https://huggingface.co/spaces/bep40/gemma-avatar" />
12
-
13
- <!-- SEO: Meta Description (155 chars, unique, actionable) -->
14
- <meta name="description" content="Gemma Avatar + V.AI STUDIO: Talk to Gemma 4 with a 3D talking-head avatar and real-time lip-sync. Search 8000+ kitchen appliances &amp; smart locks from Malloca, Eurogold, Grob, Canzy, Demax." />
15
-
16
- <!-- SEO: Keywords -->
17
- <meta name="keywords" content="Gemma Avatar, V.AI STUDIO, 3D avatar, lip-sync, talking head, voice AI, Gemma 4, real-time chat, kitchen appliances, smart locks, Malloca, Eurogold, Grob, Canzy, Demax" />
18
-
19
- <!-- SEO: Author & Publisher -->
20
- <meta name="author" content="bep40" />
21
- <meta name="publisher" content="Hugging Face" />
22
-
23
- <!-- Open Graph (Facebook, LinkedIn, Discord, WhatsApp) -->
24
- <meta property="og:type" content="website" />
25
- <meta property="og:locale" content="en_US" />
26
- <meta property="og:locale:alternate" content="vi_VN" />
27
- <meta property="og:site_name" content="Hugging Face Spaces" />
28
- <meta property="og:title" content="Gemma Avatar + V.AI STUDIO -- 3D Talking Avatar &amp; 8000+ Products" />
29
- <meta property="og:description" content="Talk to Gemma 4 face-to-face with a 3D talking-head avatar and real-time lip-sync. Search 8000+ kitchen appliances &amp; smart locks from Malloca, Eurogold, Grob, Canzy, Demax." />
30
- <meta property="og:url" content="https://huggingface.co/spaces/bep40/gemma-avatar" />
31
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/gemma-avatar/resolve/main/thumbnail.webp" />
32
- <meta property="og:image:width" content="1200" />
33
- <meta property="og:image:height" content="630" />
34
- <meta property="og:image:alt" content="Gemma Avatar + V.AI STUDIO -- Real-Time 3D Talking Avatar and 8000+ Product Search" />
35
- <meta property="og:image:type" content="image/webp" />
36
-
37
- <!-- Twitter Card (summary_large_image) -->
38
- <meta name="twitter:card" content="summary_large_image" />
39
- <meta name="twitter:site" content="@huggingface" />
40
- <meta name="twitter:creator" content="@bep40" />
41
- <meta name="twitter:title" content="Gemma Avatar + V.AI STUDIO -- 3D Talking Avatar &amp; 8000+ Products" />
42
- <meta name="twitter:description" content="Talk to Gemma 4 face-to-face with a 3D talking-head avatar and real-time lip-sync. Search 8000+ kitchen appliances &amp; smart locks." />
43
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/gemma-avatar/resolve/main/thumbnail.webp" />
44
- <meta name="twitter:image:alt" content="Gemma Avatar + V.AI STUDIO -- Real-Time 3D Talking Avatar and 8000+ Product Search" />
45
-
46
- <!-- SEO: Robots -->
47
- <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
48
-
49
- <!-- SEO: Language -->
50
- <link rel="alternate" hreflang="en" href="https://huggingface.co/spaces/bep40/gemma-avatar" />
51
- <link rel="alternate" hreflang="vi" href="https://huggingface.co/spaces/bep40/gemma-avatar" />
52
- <meta name="language" content="English, Vietnamese" />
53
-
54
- <!-- SEO: Theme & App -->
55
- <meta name="theme-color" content="#4f46e5" />
56
- <meta name="msapplication-TileColor" content="#4f46e5" />
57
- <meta name="apple-mobile-web-app-title" content="Gemma Avatar" />
58
- <meta name="apple-mobile-web-app-capable" content="yes" />
59
- <meta name="apple-mobile-web-app-status-bar-style" content="default" />
60
-
61
  <link rel="preconnect" href="https://fonts.googleapis.com" />
62
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
63
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Geist+Mono:wght@500&display=swap" rel="stylesheet" />
@@ -77,7 +23,7 @@
77
  <button id="settings-btn" class="icon-btn" aria-label="Settings" title="Settings">
78
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
79
  <circle cx="12" cy="12" r="3"></circle>
80
- <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06.06a2 2 0 1 1-2.83 2.83l-.06.06a2 2 0 1 1-2.83 2.83l-.06.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83 2.83l-.06.06a2 2 0 1 1-2.83 2.83l-.06.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06.06a2 2 0 1 1-2.83 2.83l-.06.06a2 2 0 1 1-2.83 2.83l-.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06.06a2 2 0 1 1-2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0-1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
81
  </svg>
82
  </button>
83
  </header>
 
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>Gemma Avatar + V.AI STUDIO - Chat & Voice</title>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Geist+Mono:wght@500&display=swap" rel="stylesheet" />
 
23
  <button id="settings-btn" class="icon-btn" aria-label="Settings" title="Settings">
24
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
25
  <circle cx="12" cy="12" r="3"></circle>
26
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06-.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
27
  </svg>
28
  </button>
29
  </header>
index.ts CHANGED
@@ -7,12 +7,13 @@
7
  import index from "./index.html";
8
  import { readdir } from "fs/promises";
9
  import { join } from "path";
10
- import { existsSync } from "fs";
11
 
12
  const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, "");
13
  const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://smolagents-hf-realtime-voice.hf.space/api").trim().replace(/\/$/, "");
14
  const UPSTREAM = LOAD_BALANCER_URL || SESSION_PROXY_URL;
15
  const PORT = Number(Bun.env.PORT ?? 7860);
 
16
 
17
  // ── JSON file from dataset (33.5MB, 23.4K rows) — kept for backend API compat ──
18
  const JSON_URL =
@@ -146,10 +147,80 @@ async function sessionHandler(req: Request): Promise<Response> {
146
  }
147
  }
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  const server = Bun.serve({
150
  port: PORT,
151
  routes: {
152
- "/": index,
153
  "/api/config": { GET: () => Response.json({ lb: Boolean(UPSTREAM), allowDirect: !UPSTREAM }) },
154
  "/api/avatars": { GET: async () => Response.json({ avatars: await listAvatars() }) },
155
  "/api/vaix/products": { GET: async (req: Request) => {
 
7
  import index from "./index.html";
8
  import { readdir } from "fs/promises";
9
  import { join } from "path";
10
+ import { existsSync, readFileSync } from "fs";
11
 
12
  const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, "");
13
  const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://smolagents-hf-realtime-voice.hf.space/api").trim().replace(/\/$/, "");
14
  const UPSTREAM = LOAD_BALANCER_URL || SESSION_PROXY_URL;
15
  const PORT = Number(Bun.env.PORT ?? 7860);
16
+ const INDEX_HTML = readFileSync(join(import.meta.dir, "index.html"), "utf-8");
17
 
18
  // ── JSON file from dataset (33.5MB, 23.4K rows) — kept for backend API compat ──
19
  const JSON_URL =
 
147
  }
148
  }
149
 
150
+ // ── OG tags for product share links ──
151
+ function escapeHtml(s: string): string {
152
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
153
+ }
154
+
155
+ function truncate(s: string, n: number): string {
156
+ return s ? s.slice(0, n) : "";
157
+ }
158
+
159
+ function findProductInCache(query: string): any | null {
160
+ if (!VAIX_PRODUCTS?.length) return null;
161
+ const q = query.trim().toLowerCase();
162
+ for (const p of VAIX_PRODUCTS) {
163
+ const name = (p.n || p.name || "").toLowerCase();
164
+ const sku = (p.sku || "").toLowerCase();
165
+ const slug = (p.slug || "").toLowerCase();
166
+ if (name === q || sku === q || slug === q) return p;
167
+ if (name.includes(q) || slug.includes(q)) return p;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ function injectProductOGTags(html: string, product: any): string {
173
+ const name = escapeHtml(product.n || product.name || "Sản phẩm");
174
+ const brand = escapeHtml(product.brand || "");
175
+ const price = product.pn ? Number(product.pn).toLocaleString("vi-VN") + "₫" : "Liên hệ";
176
+ const image = product.i || (Array.isArray(product.imgs) ? product.imgs[0] : "") || "";
177
+ const desc = escapeHtml(truncate(product.sum || product.desc || name, 300));
178
+ const mod = escapeHtml(product.mod || product.model || "");
179
+ const specs = product.specs && typeof product.specs === "object"
180
+ ? Object.entries(product.specs).slice(0, 6).map(([k, v]) => `${k}: ${v}`).join(" | ")
181
+ : "";
182
+ const fullDesc = desc + (mod ? ` | Model: ${mod}` : "") + (specs ? ` | ${specs}` : "");
183
+
184
+ const ogTitle = `${name}${brand ? " - " + brand : ""} | ${price}`;
185
+ const ogImage = image || "https://huggingface.co/spaces/bep40/gemma-avatar/resolve/main/thumbnail.webp";
186
+
187
+ const ogTags = `
188
+ <meta property="og:title" content="${ogTitle}" />
189
+ <meta property="og:description" content="${truncate(fullDesc, 400)}" />
190
+ <meta property="og:image" content="${ogImage}" />
191
+ <meta property="og:image:width" content="600" />
192
+ <meta property="og:image:height" content="600" />
193
+ <meta property="og:type" content="website" />
194
+ <meta property="og:site_name" content="Gemma Avatar + V.AI STUDIO" />
195
+ <meta name="twitter:card" content="summary_large_image" />
196
+ <meta name="twitter:title" content="${ogTitle}" />
197
+ <meta name="twitter:description" content="${truncate(fullDesc, 400)}" />
198
+ <meta name="twitter:image" content="${ogImage}" />
199
+ `;
200
+
201
+ return html.replace("</head>", ogTags + "\n </head>");
202
+ }
203
+
204
+ function serveIndex(req: Request): Response {
205
+ const url = new URL(req.url);
206
+ const productSlug = url.searchParams.get("product");
207
+ if (productSlug && VAIX_PRODUCTS?.length) {
208
+ const p = findProductInCache(productSlug);
209
+ if (p) {
210
+ return new Response(injectProductOGTags(INDEX_HTML, p), {
211
+ headers: { "Content-Type": "text/html; charset=utf-8" },
212
+ });
213
+ }
214
+ }
215
+ return new Response(INDEX_HTML, {
216
+ headers: { "Content-Type": "text/html; charset=utf-8" },
217
+ });
218
+ }
219
+
220
  const server = Bun.serve({
221
  port: PORT,
222
  routes: {
223
+ "/": serveIndex,
224
  "/api/config": { GET: () => Response.json({ lb: Boolean(UPSTREAM), allowDirect: !UPSTREAM }) },
225
  "/api/avatars": { GET: async () => Response.json({ avatars: await listAvatars() }) },
226
  "/api/vaix/products": { GET: async (req: Request) => {
src/app.js CHANGED
@@ -28,7 +28,7 @@ const DEFAULT_INSTRUCTIONS = [
28
  "NEVER guess facts. Use search_web/wikipedia. Get datetime first. Never mention tools.",
29
  ].join(" ");
30
 
31
- const GREETING_INSTRUCTIONS = "You are Gemma. Say exactly: 'Xin chào! Em là Gemma, trợ lý AI thân thiện của Vương V AI STUDIO. Em đây để giúp anh chị trò chuyện, trả lời câu hỏi, xem tin tức, hoặc tìm sản phẩm trong V.AI STUDIO. Rất vui được gặp anh chị!' Then end. No tools.";
32
 
33
  const STORAGE_KEYS = { voice:"avatar.voice", avatar:"avatar.model", instructions:"avatar.instructions", directUrl:"avatar.directUrl", subtitles:"avatar.subtitles" };
34
 
 
28
  "NEVER guess facts. Use search_web/wikipedia. Get datetime first. Never mention tools.",
29
  ].join(" ");
30
 
31
+ const GREETING_INSTRUCTIONS = "You are V, trợ lý AI đến từ V.AI STUDIO. Say exactly: 'Xin chào! Em là V, trợ lý AI đến từ V.AI STUDIO. Em thể giúp anh chị tìm kiếm sản phẩm, trò chuyện, tra cứu thông tin. Anh chị nhấn nút tròn để nói chuyện hoặc nhấn icon chat để nhắn tin với em nhé!' Then end. No tools.";
32
 
33
  const STORAGE_KEYS = { voice:"avatar.voice", avatar:"avatar.model", instructions:"avatar.instructions", directUrl:"avatar.directUrl", subtitles:"avatar.subtitles" };
34