| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js"; |
| import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js"; |
|
|
| const VOICES = [ |
| "Aiden", |
| "Ryan", |
| "Dylan", |
| "Eric", |
| "Ono_Anna", |
| "Serena", |
| "Sohee", |
| "Uncle_Fu", |
| "Vivian", |
| ]; |
| const DEFAULT_VOICE = "Sohee"; |
|
|
| const DEFAULT_INSTRUCTIONS = [ |
| "You are a friendly voice assistant with a visible, human-like 3D avatar: the user", |
| "sees you as a person on their screen. This is a spoken conversation: keep replies", |
| "short, natural and warm, never list-like.", |
| "You can control your avatar body with tools: set_mood changes your overall emotional", |
| "state, make_hand_gesture plays a hand gesture, make_facial_expression makes a quick", |
| "facial expression from a single face emoji. Use them naturally and sparingly to", |
| "express yourself: smile when greeting, shrug when unsure, thumbs up when agreeing.", |
| "Never mention the tools or that you are controlling an avatar.", |
| ].join(" "); |
|
|
| const STORAGE_KEYS = { |
| voice: "avatar.voice", |
| instructions: "avatar.instructions", |
| directUrl: "avatar.directUrl", |
| subtitles: "avatar.subtitles", |
| }; |
|
|
| |
| const TOOL_DEFS = [ |
| { |
| type: "function", |
| name: "set_mood", |
| description: "Change your avatar's overall mood/emotional state.", |
| parameters: { |
| type: "object", |
| properties: { |
| mood: { type: "string", enum: AVATAR_MOODS, description: "Mood name." }, |
| }, |
| required: ["mood"], |
| }, |
| }, |
| { |
| type: "function", |
| name: "make_hand_gesture", |
| description: "Make a hand gesture with your avatar.", |
| parameters: { |
| type: "object", |
| properties: { |
| gesture: { type: "string", enum: AVATAR_GESTURES, description: "Gesture name." }, |
| }, |
| required: ["gesture"], |
| }, |
| }, |
| { |
| type: "function", |
| name: "make_facial_expression", |
| description: "Make a quick facial expression with your avatar, given as a single face emoji (e.g. 😊, 😮, 🤔).", |
| parameters: { |
| type: "object", |
| properties: { |
| emoji: { type: "string", description: "A single face emoji." }, |
| }, |
| required: ["emoji"], |
| }, |
| }, |
| ]; |
|
|
| |
| const $ = (sel) => (document.querySelector(sel)); |
| const stageNode = $("#stage"); |
| const mainBtn = ($("#main-btn")); |
| const mainBtnLabel = $("#main-btn-label"); |
| const muteBtn = ($("#mute-btn")); |
| const caption = $("#caption"); |
| const subtitles = $("#subtitles"); |
| const loading = $("#loading"); |
| const settingsBtn = ($("#settings-btn")); |
| const settingsDialog = ($("#settings")); |
| const inputVoice = ($("#voice")); |
| const inputInstructions = ($("#instructions")); |
| const inputDirectUrl = ($("#direct-url")); |
| const inputSubtitles = ($("#subtitles-toggle")); |
| const directUrlRow = $("#direct-url-row"); |
|
|
| |
| const stage = new AvatarStage(stageNode); |
| |
| let client = null; |
| let muted = false; |
| let subtitleTimer = 0; |
| |
| let config = { lb: false, allowDirect: true }; |
|
|
| function loadSettings() { |
| return { |
| voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE, |
| instructions: localStorage.getItem(STORAGE_KEYS.instructions) || "", |
| directUrl: localStorage.getItem(STORAGE_KEYS.directUrl) || "", |
| |
| subtitles: localStorage.getItem(STORAGE_KEYS.subtitles) === "1", |
| }; |
| } |
| let settings = loadSettings(); |
|
|
| function saveSettings() { |
| localStorage.setItem(STORAGE_KEYS.voice, settings.voice); |
| localStorage.setItem(STORAGE_KEYS.instructions, settings.instructions); |
| localStorage.setItem(STORAGE_KEYS.directUrl, settings.directUrl); |
| localStorage.setItem(STORAGE_KEYS.subtitles, settings.subtitles ? "1" : "0"); |
| } |
|
|
| |
| function effectiveInstructions() { |
| const extra = settings.instructions.trim(); |
| return extra ? `${DEFAULT_INSTRUCTIONS}\n\nAdditional instructions from the user:\n${extra}` : DEFAULT_INSTRUCTIONS; |
| } |
|
|
| |
| |
| function setCaption(text, kind = "") { |
| caption.textContent = text; |
| caption.className = kind; |
| } |
|
|
| |
| function showSubtitles(text) { |
| if (!settings.subtitles) return; |
| clearTimeout(subtitleTimer); |
| subtitles.textContent = text; |
| subtitles.classList.add("visible"); |
| } |
|
|
| function fadeSubtitles(delayMs = 2600) { |
| clearTimeout(subtitleTimer); |
| subtitleTimer = window.setTimeout(() => subtitles.classList.remove("visible"), delayMs); |
| } |
|
|
| |
| |
| let mainAction = "start"; |
|
|
| |
| function setMainButton(action, label) { |
| mainAction = action; |
| mainBtnLabel.textContent = label; |
| mainBtn.disabled = action === "busy"; |
| mainBtn.classList.toggle("live", action === "stop"); |
| muteBtn.hidden = action !== "stop"; |
| } |
|
|
| |
| const CAPTIONS = { |
| idle: "TAP TO TALK", |
| "creating-session": "REQUESTING A SLOT…", |
| queued: "WAITING IN LINE…", |
| "your-turn": "YOUR TURN, TAP TO JOIN", |
| connecting: "CONNECTING…", |
| connected: "GO AHEAD, I'M LISTENING", |
| "user-speaking": "LISTENING", |
| processing: "THINKING…", |
| "ai-speaking": "SPEAKING", |
| closed: "TAP TO TALK", |
| error: "SOMETHING BROKE, TAP TO RETRY", |
| }; |
|
|
| |
| function onStatus(status) { |
| stage.setConversationState(status); |
| setCaption(CAPTIONS[status] ?? status, status === "error" ? "error" : status === "idle" || status === "closed" ? "" : "live"); |
|
|
| switch (status) { |
| case "idle": |
| case "closed": |
| setMainButton("start", "Start talking"); |
| break; |
| case "error": |
| setMainButton("start", "Retry"); |
| break; |
| case "creating-session": |
| case "connecting": |
| setMainButton("busy", "Connecting…"); |
| break; |
| case "queued": |
| setMainButton("stop", "Leave queue"); |
| break; |
| case "your-turn": |
| setMainButton("join", "Join now"); |
| break; |
| default: |
| |
| setMainButton("stop", "End conversation"); |
| break; |
| } |
|
|
| if (status === "user-speaking") { |
| subtitles.classList.remove("visible"); |
| } |
| } |
|
|
| |
| |
| function runTool(name, argsJson, callId) { |
| if (!client) return; |
| |
| let args = {}; |
| try { |
| args = JSON.parse(argsJson || "{}"); |
| } catch { |
| |
| } |
| const result = stage.runTool(name, args) ?? `Unknown tool: ${name}`; |
| client.sendToolOutput(callId, result); |
| |
| client.requestResponse(); |
| } |
|
|
| |
| async function startSession() { |
| |
| |
| stage.resume(); |
|
|
| let micStream; |
| if (new URLSearchParams(location.search).has("fakemic")) { |
| |
| |
| |
| const ctx = (stage.audioCtx); |
| micStream = ctx.createMediaStreamDestination().stream; |
| } else { |
| try { |
| micStream = await navigator.mediaDevices.getUserMedia({ |
| audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, |
| }); |
| } catch { |
| setCaption("MIC BLOCKED, ALLOW IT IN THE BROWSER AND RETRY", "error"); |
| return; |
| } |
| } |
|
|
| const audioCtx = stage.audioCtx; |
| const voiceSink = stage.voiceSink; |
| if (!audioCtx || !voiceSink) return; |
|
|
| const c = new S2sWsRealtimeClient({ |
| ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }), |
| voice: settings.voice, |
| instructions: effectiveInstructions(), |
| micStream, |
| audioContext: audioCtx, |
| outputNode: voiceSink, |
| workletBaseUrl: "/worklets/", |
| tools: TOOL_DEFS, |
| }); |
| client = c; |
|
|
| c.addEventListener("status", (e) => onStatus( (e).detail.status)); |
|
|
| c.addEventListener("queue", (e) => { |
| const { position } = (e).detail; |
| setCaption(position > 0 ? `#${position} IN LINE…` : "ALMOST THERE…", "live"); |
| }); |
|
|
| c.addEventListener("transcript", (e) => { |
| const { role, text } = (e).detail; |
| if (role === "assistant" && text) showSubtitles(text); |
| }); |
|
|
| c.addEventListener("response-finished", () => { |
| fadeSubtitles(); |
| }); |
|
|
| c.addEventListener("toolcall", (e) => { |
| const { name, arguments: args, callId } = (e).detail; |
| runTool(name, args, callId); |
| }); |
|
|
| c.addEventListener("server-error", (e) => { |
| console.warn("server error:", (e).detail.error); |
| }); |
|
|
| c.addEventListener("error", () => { |
| void endSession(); |
| }); |
|
|
| try { |
| await c.connect(); |
| } catch (err) { |
| const code = (err)?.code; |
| if (code === "limit") { |
| setCaption("DAILY CONVERSATION LIMIT REACHED, TRY AGAIN TOMORROW", "error"); |
| } else if (code === "queue-full") { |
| setCaption("EVERY SEAT IS TAKEN, TRY AGAIN SHORTLY", "error"); |
| } else if (code === "join-expired") { |
| setCaption("YOUR SPOT EXPIRED, TAP TO TRY AGAIN", "error"); |
| } else if (code !== "aborted") { |
| console.error(err); |
| setCaption("COULD NOT CONNECT, TAP TO RETRY", "error"); |
| } |
| await endSession(true); |
| return; |
| } |
| } |
|
|
| |
| async function endSession(silent = false) { |
| const c = client; |
| client = null; |
| if (c) { |
| for (const track of c.options.micStream?.getTracks() ?? []) track.stop(); |
| await c.close().catch(() => {}); |
| } |
| stage.setConversationState("idle"); |
| subtitles.classList.remove("visible"); |
| if (!silent) setCaption(CAPTIONS.idle); |
| setMainButton("start", "Start talking"); |
| } |
|
|
| |
| mainBtn.addEventListener("click", () => { |
| if (mainAction === "start") void startSession(); |
| else if (mainAction === "join") { |
| stage.resume(); |
| client?.join(); |
| } else if (mainAction === "stop") void endSession(); |
| }); |
|
|
| muteBtn.addEventListener("click", () => { |
| muted = !muted; |
| client?.setMuted(muted); |
| muteBtn.classList.toggle("active", muted); |
| muteBtn.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone"); |
| }); |
|
|
| settingsBtn.addEventListener("click", () => { |
| inputVoice.value = settings.voice; |
| inputInstructions.value = settings.instructions; |
| inputDirectUrl.value = settings.directUrl; |
| inputSubtitles.checked = settings.subtitles; |
| settingsDialog.showModal(); |
| }); |
|
|
| settingsDialog.addEventListener("close", () => { |
| settings = { |
| voice: inputVoice.value || DEFAULT_VOICE, |
| instructions: inputInstructions.value, |
| directUrl: inputDirectUrl.value.trim(), |
| subtitles: inputSubtitles.checked, |
| }; |
| saveSettings(); |
| if (!settings.subtitles) subtitles.classList.remove("visible"); |
| |
| client?.updateSession({ voice: settings.voice, instructions: effectiveInstructions() }); |
| }); |
|
|
| window.addEventListener("beforeunload", () => { |
| client?.close(); |
| }); |
|
|
| |
| async function boot() { |
| for (const v of VOICES) { |
| const o = document.createElement("option"); |
| o.value = v; |
| o.textContent = v.replaceAll("_", " "); |
| inputVoice.append(o); |
| } |
|
|
| try { |
| const resp = await fetch("api/config"); |
| if (resp.ok) config = { ...config, ...(await resp.json()) }; |
| } catch { |
| |
| } |
| directUrlRow.hidden = !config.allowDirect; |
|
|
| setCaption("WAKING HER UP…"); |
| setMainButton("busy", "Loading…"); |
| try { |
| await stage.init({ |
| onprogress: (ev) => { |
| if (ev.lengthComputable) { |
| const pct = Math.min(100, Math.round((ev.loaded / ev.total) * 100)); |
| loading.textContent = `Loading avatar ${pct}%`; |
| } |
| }, |
| }); |
| } catch (err) { |
| console.error(err); |
| loading.textContent = "The avatar failed to load. Check the console and reload."; |
| setCaption("AVATAR FAILED TO LOAD", "error"); |
| return; |
| } |
| loading.classList.add("done"); |
| setCaption(CAPTIONS.idle); |
| setMainButton("start", "Start talking"); |
|
|
| |
| Object.assign(window, { stage, getClient: () => client }); |
| } |
|
|
| void boot(); |