A 3D AI English Tutor That Runs in a Single HTML File
@[toc]
I. The Gap Exposed: Where the "Last Mile" of AI Oral Practice Gets Stuck
Since domestic large models like DeepSeek, Qwen, and Kimi K2 have successively broken through reasoning limits, "everyone can have a private foreign tutor" seems like an easily attainable reality. However, anyone who has seriously used the "AI oral practice partner" products on the market can feel a subtle disconnect: The language ability of large models has far surpassed that of a competent foreign tutor, but the practice experience still remains at the primitive stage of "chat box + voice bar."
I've categorized mainstream solutions by their pain points:
Pure text LLM + TTS patchwork (the approach of most apps): The user types into a chat box, and the AI replies with a text that is then synthesized into speech by TTS. The whole process is no different from a "deaf-mute oral class"—you can't practice listening, and the AI can't read your mouth shape or emotions; error correction relies entirely on guesswork.
LLM + video stream digital human (cloud rendering type): It looks like a "real foreign tutor," but because the entire video must be rendered in the cloud and then pushed to your screen, you have to wait 3-5 seconds for a reaction after asking a question. Any slight network fluctuation causes audio and video to desynchronize—the mouth is still moving, but the sound has already finished. This delay is extremely fatal during actual practice, because oral training requires immediate feedback: the vowel you just mispronounced needs to be corrected by the tutor within half a second to build muscle memory.
Pure text LLM + browser TTS: Low latency but no expression, no eye contact, no body language; it's cold. You'll get bored after practicing twice and won't stick with it.
What a true oral practice experience lacks is never IQ, but "embodiment": eye contact, micro-expressions that change with semantics, and a conversational rhythm where the partner can finish speaking and then listen to your complete response. To cover this last mile, the large model must have a 3D embodied shell that can both respond in real-time and run cost-effectively in a browser.
II. Technical Selection: Why Take the "Parameter Stream + Client-Side Rendering" Path
To run a 3D digital human capable of real-time conversation in a browser, there are roughly two architectural paths:
- Cloud Rendering + Video Streaming: The 3D visuals are rendered into a video stream on the server side and pushed to the browser for playback. The advantage is no computational pressure on the client side; the disadvantages are high first-frame latency, bandwidth sensitivity, and mouth-shape clarity affected by encoding.
- Parameter Stream + Client-Side Rendering: The server only sends down driving parameters like skeletal data, mouth shapes, and facial muscles. The browser SDK renders them in real-time using the local graphics card. The transmission volume is very small, end-to-end latency is easier to reduce, and mouth shapes can retain their original precision.
For this experiment, Mofa Nebula was chosen, which takes the second path. It sends down a composite parameter stream (facial muscle parameters + skeletal animation parameters + PCM audio parameters). After receiving them, the SDK renders them in WebGL. Several points make this technical solution friendly for practice scenarios:
- Low end-to-end latency: The official figure is around 1200ms for two-way response. In actual testing, "you just finished speaking → the digital human starts to open its mouth to respond" is indeed almost seamless. Oral training fears "dead air" the most, and this latency is acceptable.
- Manageable client-side computational pressure: 3D skeletons and facial expressions are rendered on the local graphics card; even a regular laptop's Chrome can run it. This means you don't need to set up your own GPU server for a demo; a single HTML file is enough for personal use.
- Mouth-shape fidelity: Facial muscle parameters are sent directly, and with client-side rendering, you get clear mouth-shape movements. When imitating sounds like "th," "r," and "l," which native Chinese speakers often get wrong, you can see them quite clearly.
Next, we'll get this whole thing running with a single HTML file.
III. Registration and Account Setup: Get Your API Credentials in 3 Minutes
The platform's entry barrier is not high; just click through the web console, with no enterprise review process.
Step 1: Register an Account
Open the official website👇
https://xingyun3d.com/
Click "Login/Register" in the upper right corner. During registration, you can fill in the "Invitation Code" field with JM8ABNY834 to receive 1000 credits as a newcomer allowance, enough to run this demo and try it out for a long time.
Step 2: Create an Embodied Intelligence Application
After entering the console, click "Create Application."
Select in order:
- Avatar: The platform provides dozens of 3D digital human avatars, covering men, women, young and old, and various professional attires. For an oral English tutor, I recommend choosing an avatar with a "professional woman/white-collar" temperament to better fit the "tutor" role.
- Scene: Classrooms, offices, studies, and cafes are all suitable; avoid overly flashy stage scenes.
- Voice: Pay attention here—an application can only be bound to one voice. Once bound, no matter what language you make it speak in the System Prompt, the output audio will use this one accent. This is the fundamental reason why this article must lock in only one target language. Because we will not use the platform's built-in conversational LLM later, but instead use the "external LLM + SDK speak interface" model, the choice of voice will directly determine how authentic the practice partner's English reading sounds. This article uses English oral practice as an example, so please choose a native English voice labeled "American English" or "British English" (do not choose a Chinese voice, otherwise the English will sound very jarring).
- Performance Actions: The default is fine.
After creation, enter the application details page, and you will see the two most critical fields:
- App ID — Application unique identifier
- App Secret — Application key
Copy them for later use. Note: Be sure to keep the App Secret safe.
Step 3: Prepare an LLM API
In this article's architecture, the LLM and the 3D SDK are decoupled—you can freely choose any LLM service compatible with the OpenAI protocol. I personally use the AI ping aggregation platform (one recharge allows you to switch between the latest models from DeepSeek, Qwen, Kimi K2, GLM, Doubao, etc., making it easy to compare practice effects). You can also directly use platforms like the official DeepSeek, Tongyi Bailian, Kimi Open Platform, SiliconFlow, etc.
Just prepare three pieces of information:
- Base URL (e.g.,
https://api.deepseek.com/v1) - API Key
- Model Name (e.g.,
deepseek-chat/qwen-plus/kimi-k2, etc.)
Three steps done, let's move directly to the coding phase.
IV. Hardcore Practice: Running the "3D Practice Partner" with a Single HTML File
- Credentials are not hardcoded—a collapsible panel at the top, with 5 fields that save to
localStorageafter filling, suitable for distribution. - Added browser-native Web Speech ASR—hold the microphone to speak, release to automatically send, truly achieving "open your mouth and practice."
- The LLM layer is completely decoupled—any interface compatible with the OpenAI protocol can run directly; readers are not locked into a specific provider.
Create an index.html file locally and paste the following complete code:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Oral Practice Partner · Mofa Nebula 3D Embodied Intelligence</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://media.xingyun3d.com/xingyun3d/general/litesdk/[email protected]"></script>
<style>
#avatar-container {
background: radial-gradient(circle at 30% 20%, #064e3b 0%, #022c22 60%, #011a15 100%);
}
.scrollbar-thin::-webkit-scrollbar { width: 6px; }
.scrollbar-thin::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
.mic-pulse { animation: micPulse 1.2s ease-in-out infinite; }
@keyframes micPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); }
50% { box-shadow: 0 0 0 12px rgba(16, 185, 129, 0); }
}
</style>
</head>
<body class="bg-slate-900 text-slate-100 min-h-screen p-4 md:p-6">
<!-- Top Credential Configuration Panel (Collapsible + localStorage Persistence) -->
<div class="max-w-5xl mx-auto mb-4">
<button id="cred-toggle" class="w-full bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl px-4 py-3 flex items-center justify-between transition-colors">
<span class="flex items-center gap-2 text-sm">
<span id="cred-status-dot" class="h-2 w-2 rounded-full bg-amber-400"></span>
<span class="font-medium">Credential Configuration Panel</span>
<span id="cred-status-text" class="text-xs text-slate-400">(Not configured, click to expand)</span>
</span>
<span id="cred-chevron" class="text-slate-400 text-xs">▼</span>
</button>
<div id="cred-panel" class="mt-2 bg-slate-800 border border-slate-700 rounded-xl p-5 space-y-4">
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="block text-xs text-slate-400 mb-1">Mofa Nebula AppID</label>
<input type="text" id="cfg-mofa-appid" placeholder="Copy from Mofa Nebula Console" class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500">
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Mofa Nebula AppSecret</label>
<input type="password" id="cfg-mofa-secret" placeholder="Copy from Mofa Nebula Console" class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500">
</div>
</div>
<div class="grid md:grid-cols-3 gap-4">
<div>
<label class="block text-xs text-slate-400 mb-1">Model Base URL</label>
<input type="text" id="cfg-llm-url" placeholder="e.g., https://api.aiping.ai/v1" class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500">
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Model API Key</label>
<input type="password" id="cfg-llm-key" placeholder="OpenAI-compatible protocol Key" class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500">
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Model Name</label>
<input type="text" id="cfg-llm-model" placeholder="e.g., deepseek-chat / qwen-plus / kimi-k2" class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500">
</div>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Practice Partner Persona (System Prompt)</label>
<textarea id="cfg-persona" rows="2" placeholder="Leave blank to use the default oral tutor persona" class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 resize-none"></textarea>
<div class="mt-2 flex gap-2 flex-wrap text-xs">
<button class="preset-btn bg-slate-700 hover:bg-slate-600 px-2 py-1 rounded" data-preset="daily-en">Daily English Tutor</button>
<button class="preset-btn bg-slate-700 hover:bg-slate-600 px-2 py-1 rounded" data-preset="ielts">IELTS Speaking Examiner</button>
<button class="preset-btn bg-slate-700 hover:bg-slate-600 px-2 py-1 rounded" data-preset="business">Business Interview English</button>
<button class="preset-btn bg-slate-700 hover:bg-slate-600 px-2 py-1 rounded" data-preset="travel">Travel English Partner</button>
</div>
</div>
<div class="flex gap-2 pt-2 border-t border-slate-700">
<button id="cred-save" class="flex-1 bg-emerald-600 hover:bg-emerald-500 px-4 py-2 rounded-lg text-sm font-medium">Save and Initialize 3D Shell</button>
<button id="cred-clear" class="bg-slate-700 hover:bg-rose-800 px-4 py-2 rounded-lg text-sm text-slate-300 hover:text-white">Clear Local Credentials</button>
</div>
<p class="text-xs text-slate-500 leading-relaxed">
🔒 All credentials are only saved in your own browser's <code class="bg-slate-950 px-1 rounded">localStorage</code> and will not be uploaded to any third party. For production environments, please migrate the LLM API key to a backend relay to prevent frontend decompilation leaks.
</p>
</div>
</div>
<!-- Main Interaction Area -->
<div class="max-w-5xl mx-auto bg-slate-800 rounded-2xl shadow-2xl border border-slate-700 overflow-hidden flex flex-col md:flex-row h-[680px]">
<div class="w-full md:w-1/2 relative flex flex-col" id="avatar-container">
<div id="sdk_canvas" class="w-full h-full"></div>
<div id="loading-tips" class="absolute inset-0 flex flex-col items-center justify-center bg-slate-950/85 p-4 text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-emerald-500 mb-4"></div>
<p id="loading-text" class="text-sm text-slate-300">Please fill in credentials at the top first</p>
<p class="text-xs text-slate-500 mt-2">The 3D practice partner will load automatically after saving the configuration</p>
</div>
<div class="absolute top-4 left-4 bg-slate-900/70 backdrop-blur-sm px-3 py-1 rounded-full text-xs flex items-center gap-2 border border-slate-700">
<span id="status-dot" class="h-2 w-2 rounded-full bg-slate-500"></span>
<span id="status-text">Awaiting Credentials</span>
</div>
</div>
<div class="w-full md:w-1/2 flex flex-col p-6 bg-slate-800 border-t md:border-t-0 md:border-l border-slate-700">
<div class="mb-4">
<h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-emerald-400 to-cyan-400">AI Oral Practice Partner</h1>
<p class="text-xs text-slate-400 mt-1">Mofa Nebula 3D Embodiment × OpenAI-Compatible LLM</p>
</div>
<div id="chat-output" class="flex-1 overflow-y-auto bg-slate-950 rounded-xl p-4 border border-slate-800 space-y-3 mb-4 text-sm scrollbar-thin">
<div class="text-slate-500 italic text-center text-xs py-2">Ready when you are. Speak English or Chinese.</div>
</div>
<div class="space-y-2">
<div class="flex gap-2">
<input type="text" id="user-input" placeholder="Type or hold the microphone to speak..." class="flex-1 bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-emerald-500 transition-colors" disabled>
<button id="mic-btn" title="Hold to speak" disabled class="bg-slate-700 hover:bg-emerald-600 px-4 py-3 rounded-xl text-sm font-medium transition-all border border-slate-600 disabled:opacity-40 disabled:cursor-not-allowed">🎤</button>
<button id="send-btn" disabled class="bg-emerald-600 hover:bg-emerald-500 px-5 py-3 rounded-xl text-sm font-medium transition-all shadow-lg active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed">Send</button>
</div>
<button id="stop-btn" disabled class="w-full bg-slate-700 hover:bg-rose-900 text-slate-300 hover:text-white py-2 rounded-xl text-xs font-medium transition-all border border-slate-600 hover:border-rose-700 disabled:opacity-40 disabled:cursor-not-allowed">🛑 Interrupt Partner's Speech</button>
</div>
</div>
</div>
<script>
// ============ 0. Constants and State ============
const STORAGE_KEY = 'mofa_oral_coach_creds_v1';
const PERSONAS = {
'daily-en': "You are Emma, a warm and patient native-English oral practice partner. Reply in short, natural spoken English (< 40 words). If the learner made a pronunciation, grammar, or word-choice mistake, kindly point out ONE key tip in one line. Never lecture. Never use bullet points or markdown.",
'ielts': "You are a strict but fair IELTS Speaking Part 2/3 examiner. Ask one focused follow-up question at a time in natural English (< 40 words). After the learner answers, give a one-line band-descriptor feedback (fluency / lexical / grammar / pronunciation), then move on.",
'business': "You are an experienced foreign-company interviewer conducting a business English mock interview. Ask one concise behavioral or situational question at a time in natural English (< 40 words). After the candidate answers, give a one-line comment on their business vocabulary, professionalism, or clarity.",
'travel': "You are Jake, a friendly travel English coach. Role-play everyday travel scenes: ordering food, asking for directions, checking into a hotel, or shopping. Speak short natural English (< 40 words) and prompt the learner. Kindly correct ONE mistake per turn if any."
};
const DEFAULT_PERSONA = PERSONAS['daily-en'];
let mofaSdk = null, isFirstSentence = true, textBuffer = "", isSpeaking = false;
const punctReg = /[。!?;;!?.]/;
let creds = { appId: '', appSecret: '', llmUrl: '', llmKey: '', llmModel: '', persona: '' };
// ============ 1. Credential Panel ============
function loadCreds() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) creds = { ...creds, ...JSON.parse(raw) };
} catch (e) {}
document.getElementById('cfg-mofa-appid').value = creds.appId || '';
document.getElementById('cfg-mofa-secret').value = creds.appSecret || '';
document.getElementById('cfg-llm-url').value = creds.llmUrl || '';
document.getElementById('cfg-llm-key').value = creds.llmKey || '';
document.getElementById('cfg-llm-model').value = creds.llmModel || '';
document.getElementById('cfg-persona').value = creds.persona || '';
updateCredStatus();
}
function credsReady() {
return creds.appId && creds.appSecret && creds.llmUrl && creds.llmKey && creds.llmModel;
}
function updateCredStatus() {
const dot = document.getElementById('cred-status-dot');
const text = document.getElementById('cred-status-text');
if (credsReady()) {
dot.className = 'h-2 w-2 rounded-full bg-emerald-500';
text.innerText = '(Configured)';
} else {
dot.className = 'h-2 w-2 rounded-full bg-amber-400';
text.innerText = '(Not configured, click to expand)';
}
}
function saveCreds() {
creds = {
appId: document.getElementById('cfg-mofa-appid').value.trim(),
appSecret: document.getElementById('cfg-mofa-secret').value.trim(),
llmUrl: document.getElementById('cfg-llm-url').value.trim().replace(/\/+$/, ''),
llmKey: document.getElementById('cfg-llm-key').value.trim(),
llmModel: document.getElementById('cfg-llm-model').value.trim(),
persona: document.getElementById('cfg-persona').value.trim()
};
if (!credsReady()) { alert('Please fill in all five credentials'); return; }
localStorage.setItem(STORAGE_KEY, JSON.stringify(creds));
updateCredStatus();
toggleCredPanel(false);
initMofaAvatar();
}
function clearCreds() {
if (!confirm('Are you sure you want to clear local credentials?')) return;
localStorage.removeItem(STORAGE_KEY);
creds = { appId: '', appSecret: '', llmUrl: '', llmKey: '', llmModel: '', persona: '' };
['cfg-mofa-appid','cfg-mofa-secret','cfg-llm-url','cfg-llm-key','cfg-llm-model','cfg-persona'].forEach(id => document.getElementById(id).value = '');
updateCredStatus();
}
function toggleCredPanel(open) {
const panel = document.getElementById('cred-panel');
const chevron = document.getElementById('cred-chevron');
const shouldOpen = open !== undefined ? open : panel.classList.contains('hidden');
panel.classList.toggle('hidden', !shouldOpen);
chevron.innerText = shouldOpen ? '▲' : '▼';
}
// ============ 2. Mofa Nebula 3D Shell Initialization ============
function initMofaAvatar() {
if (typeof XmovAvatar === 'undefined') { setStatus('SDK Load Failed', 'rose'); return; }
if (mofaSdk) {
try { mofaSdk.destroy && mofaSdk.destroy(); } catch(e) {}
document.getElementById('sdk_canvas').innerHTML = '';
mofaSdk = null;
}
document.getElementById('loading-tips').style.display = 'flex';
document.getElementById('loading-text').innerText = '3D Practice Partner is descending...';
setStatus('Initializing', 'amber');
mofaSdk = new XmovAvatar({
containerId: '#sdk_canvas',
appId: creds.appId,
appSecret: creds.appSecret,
gatewayServer: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
onMessage: (msg) => console.log('[SDK Notification]', msg)
});
mofaSdk.init({
onDownloadProgress: (progress) => {
document.getElementById('loading-text').innerText = `Resource downloading ${progress}%`;
}
}).then(() => {
document.getElementById('loading-tips').style.display = 'none';
setStatus('3D Practice Partner Ready', 'emerald');
enableInteraction(true);
}).catch(err => {
document.getElementById('loading-text').innerText = 'Initialization failed: ' + (err?.message || 'Please check AppID/Secret');
setStatus('Initialization Failed', 'rose');
});
}
function setStatus(text, color) {
const colors = { emerald:'bg-emerald-500', amber:'bg-amber-400', rose:'bg-rose-500', slate:'bg-slate-500' };
document.getElementById('status-dot').className = 'h-2 w-2 rounded-full ' + (colors[color] || colors.slate);
document.getElementById('status-text').innerText = text;
}
function enableInteraction(on) {
['user-input','mic-btn','send-btn','stop-btn'].forEach(id => document.getElementById(id).disabled = !on);
}
// ============ 3. Streaming Sentence Segmentation → Drive speak ============
function processStreamText(chunk, isFinal = false) {
textBuffer += chunk;
let match;
while ((match = punctReg.exec(textBuffer)) !== null) {
const cut = match.index + 1;
const sentence = textBuffer.substring(0, cut).trim();
textBuffer = textBuffer.substring(cut);
if (sentence.length > 0) driveSpeak(sentence, false);
}
if (isFinal && textBuffer.trim().length > 0) {
driveSpeak(textBuffer.trim(), true);
textBuffer = '';
} else if (isFinal) {
driveSpeak('', true);
}
}
function driveSpeak(text, isEnd) {
if (!mofaSdk) return;
try {
mofaSdk.speak(text, isFirstSentence, isEnd);
if (isFirstSentence && text) isFirstSentence = false;
isSpeaking = !isEnd;
} catch (e) { console.error('speak call exception', e); }
}
// ============ 4. LLM SSE Streaming (OpenAI Compatible) ============
async function handleSend() {
const inputEl = document.getElementById('user-input');
const query = inputEl.value.trim();
if (!query || !credsReady()) return;
inputEl.value = '';
textBuffer = '';
isFirstSentence = true;
appendBubble('user', query);
const aiBubble = appendBubble('ai', '');
const systemPrompt = creds.persona || DEFAULT_PERSONA;
try {
const resp = await fetch(creds.llmUrl + '/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + creds.llmKey },
body: JSON.stringify({
model: creds.llmModel,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: query }
],
stream: true
})
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder('utf-8');
let partial = '';
while (true) {
const { done, value } = await reader.read();
if (done) { processStreamText('', true); break; }
partial += decoder.decode(value, { stream: true });
const lines = partial.split('\n');
partial = lines.pop() || '';
for (let line of lines) {
line = line.trim();
if (!line || line === 'data: [DONE]') continue;
if (!line.startsWith('data:')) continue;
try {
const json = JSON.parse(line.slice(5).trim());
const token = json.choices?.[0]?.delta?.content || '';
if (token) {
aiBubble.innerText += token;
document.getElementById('chat-output').scrollTop = 1e9;
processStreamText(token, false);
}
} catch (e) {}
}
}
} catch (err) {
aiBubble.innerHTML += `<span class="text-rose-400"> [Connection Error: ${err.message}]</span>`;
}
}
function appendBubble(who, text) {
const out = document.getElementById('chat-output');
const wrap = document.createElement('div');
wrap.className = who === 'user' ? 'text-right' : 'text-left';
const bubble = document.createElement('span');
bubble.className = who === 'user'
? 'bg-emerald-600 text-white inline-block px-3 py-2 rounded-xl rounded-tr-none max-w-[85%] text-left whitespace-pre-wrap'
: 'bg-slate-700 border border-slate-600 text-slate-100 inline-block px-3 py-2 rounded-xl rounded-tl-none max-w-[85%] whitespace-pre-wrap';
bubble.innerText = text;
wrap.appendChild(bubble);
out.appendChild(wrap);
out.scrollTop = 1e9;
return bubble;
}
// ============ 5. Web Speech ASR (Hold to speak, fixed English recognition) ============
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
let recognizer = null, recognizing = false;
function setupASR() {
const micBtn = document.getElementById('mic-btn');
if (!SR) { micBtn.disabled = true; micBtn.title = 'Current browser does not support Web Speech, please use Chrome/Edge + HTTPS/localhost'; return; }
recognizer = new SR();
recognizer.continuous = false;
recognizer.interimResults = true;
recognizer.lang = 'en-US';
recognizer.onresult = (e) => {
let text = '';
for (let i = e.resultIndex; i < e.results.length; i++) text += e.results[i][0].transcript;
document.getElementById('user-input').value = text;
};
recognizer.onerror = (e) => { recognizing = false; micBtn.classList.remove('mic-pulse','bg-emerald-600'); };
recognizer.onend = () => {
recognizing = false;
micBtn.classList.remove('mic-pulse','bg-emerald-600');
if (document.getElementById('user-input').value.trim()) handleSend();
};
const startRec = (e) => {
e.preventDefault();
if (recognizing || !credsReady()) return;
try {
document.getElementById('user-input').value = '';
recognizer.start();
recognizing = true;
micBtn.classList.add('mic-pulse','bg-emerald-600');
} catch (err) {}
};
const stopRec = (e) => { e.preventDefault(); if (recognizing) recognizer.stop(); };
micBtn.addEventListener('mousedown', startRec);
micBtn.addEventListener('mouseup', stopRec);
micBtn.addEventListener('mouseleave', stopRec);
micBtn.addEventListener('touchstart', startRec, { passive: false });
micBtn.addEventListener('touchend', stopRec, { passive: false });
}
// ============ 6. Event Binding ============
document.getElementById('cred-toggle').addEventListener('click', () => toggleCredPanel());
document.getElementById('cred-save').addEventListener('click', saveCreds);
document.getElementById('cred-clear').addEventListener('click', clearCreds);
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('cfg-persona').value = PERSONAS[btn.dataset.preset];
});
});
document.getElementById('send-btn').addEventListener('click', handleSend);
document.getElementById('user-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') handleSend(); });
document.getElementById('stop-btn').addEventListener('click', () => {
if (mofaSdk) {
try { mofaSdk.interactiveIdle(); } catch(e) {}
textBuffer = ''; isFirstSentence = true; isSpeaking = false;
const tip = document.createElement('div');
tip.className = 'text-center italic text-amber-400 text-xs py-1';
tip.innerText = '⚠️ Interrupted 3D Practice Partner';
document.getElementById('chat-output').appendChild(tip);
}
});
// ============ 7. Startup ============
window.addEventListener('load', () => {
loadCreds();
setupASR();
if (credsReady()) { toggleCredPanel(false); initMofaAvatar(); }
else { toggleCredPanel(true); }
});
</script>
</body>
</html>
After saving, start a small local server in the project directory (you cannot double-click to open it directly, because the browser does not allow microphone access or loading online SDKs under the file:// protocol):
python -m http.server 8000
Use Chrome or Edge to visit http://localhost:8000, fill in the credentials you obtained earlier, and click "Save and Initialize 3D Shell"—
After filling in the credentials, the 3D practice partner finishes descending. Hold the microphone and speak English to witness true embodied intelligence practice.
V. Deep Dive into SDK Core Methods: Unique Calling Paradigms for Practice Scenarios
Getting the code to run is only the first step. What truly determines the experience quality is the calling paradigm of the SDK's three core APIs in practice scenarios.
5.1 new XmovAvatar() and init(): Why Put Credentials in a Promise for Pre-validation
mofaSdk = new XmovAvatar({
containerId: '#sdk_canvas',
appId: creds.appId,
appSecret: creds.appSecret,
gatewayServer: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
onMessage: (msg) => console.log('[SDK Notification]', msg)
});
⚙️ Technical Breakdown:
The constructor only registers parameters and does not initiate any network requests—this is crucial. The actual authentication and resource downloading happen within the Promise of
mofaSdk.init(...). This allows us to switch credentials at runtime: after the reader changes the AppID/Secret, they can firstdestroy()the old instance, clear the DOM of#sdk_canvas, and thennewanother one, without needing to refresh the page. To support "change credentials - instantly switch applications" in this article, explicit instance cleanup is necessary; theif (mofaSdk) { mofaSdk.destroy?.(); ... }in the code is for this purpose.The
ttsapath ingatewayServeris an abbreviation for Text-to-Speech + Animation. This is an end-to-end parameter stream protocol—what you send in is a request, and what comes back is not an audio file or video stream, but a composite frame stream of facial muscle parameters + skeletal animation parameters + PCM audio parameters. The frontend SDK takes these three parameter streams locally and renders the 3D digital human using WebGL. This is why the mouth shapes can match in oral practice scenarios—the mouth-shape parameters are sent down in the same frame as the audio parameters, naturally aligned.
5.2 speak(text, isStart, isEnd): Why Keep Streaming Sentence Segmentation Instead of Sending Whole Paragraphs for Practice Scenarios
This is the decision in this article that requires the most careful consideration. The SDK's speak method allows two calling paradigms:
- Paradigm A · Streaming Sentence Segmentation: Every token the LLM outputs is accumulated into a buffer. Upon encountering punctuation like periods or question marks, a segment is immediately cut and sent out.
- Paradigm B · Whole Paragraph Sending: Wait for the LLM to output a complete reply paragraph, then call
speak(fullText, true, true)once.
After careful evaluation for the oral practice scenario, I still chose Paradigm A, for reasons contrary to intuition:
- Replies in practice scenarios are usually short to begin with (already constrained to < 40 words in the Prompt), so streaming segmentation cuts them into 1-2 segments at most, making intonation breaks almost imperceptible.
- However, first-frame latency is more fatal in practice than in tour guide scenarios—a student who has just finished speaking a sentence of English and is waiting for corrective feedback will get distracted if there's a 2-second dead air.
- A more subtle point: the first sentence of a practice reply is often an "acknowledgment" (Right, good try / I see), and the second sentence is the "correction" or "follow-up question." Streaming segmentation allows the digital human to first open its mouth to acknowledge, then add the correction, making the rhythm highly similar to a real foreign tutor.
function driveSpeak(text, isEnd) {
if (!mofaSdk) return;
mofaSdk.speak(text, isFirstSentence, isEnd);
if (isFirstSentence && text) isFirstSentence = false;
isSpeaking = !isEnd;
}
⚙️ Technical Breakdown:
The three parameters of the
speakmethod form a strict state machine:
- First sentence
(text, isStart=true, isEnd=false): Notifies the cloud to enter the pronunciation state and creates this speech session;- Middle sentences
(text, isStart=false, isEnd=false): Appends sentences to the existing session. The cloud streams back incremental animation frames at this point, and the frontend playback does not interrupt;- End
(text, isStart=false, isEnd=true)or('', false, true): Informs the cloud that the entire passage is finished, allowing it to do tail-sound closure and transition actions back to idle.Note that I've added an easily overlooked safeguard on the end frame—in the
isFinalbranch ofprocessStreamText, even iftextBufferis already empty (the end happened to be punctuation), you must explicitly calldriveSpeak('', true)once. Otherwise, the cloud will remain stuck in a "waiting for continuation" state, the action transition won't return to a natural idle, and the next round of conversation will inexplicably stutter. The code in the previous article would hit this pitfall when "the last sentence happened to end with punctuation"; this article has fixed it.
5.3 interactiveIdle(): "Interrupt at Any Time" is a Hard Requirement in Practice Scenarios
document.getElementById('stop-btn').addEventListener('click', () => {
if (mofaSdk) {
mofaSdk.interactiveIdle();
textBuffer = '';
isFirstSentence = true;
}
});
⚙️ Technical Breakdown:
In tour guide scenarios, "interrupt" is more of a defensive design. But in practice scenarios, interruption itself is part of the learning action—a student wants to interject and ask a follow-up question while the tutor is mid-correction, or upon hearing "you used the wrong tense just now," wants an example sentence immediately. If these interactions are blocked by "the practice partner must finish the current paragraph," the practice experience is ruined.
interactiveIdle()does two things at the low level: first, it clears the unplayed parameter frame buffer on the client side (otherwise residual frames will continue to play after interruption); second, it notifies the cloud session to terminate this round of TTSA generation (otherwise the cloud is still calculating the animation for the next sentence). Both are indispensable. Therefore, after clicking the interrupt button, the frontend'stextBufferandisFirstSentencestates must also be reset, otherwise the next round'sspeakwill passisStartasfalse, and the cloud will think it's a continuation of the previous session, resulting in abnormal playback.
VI. Switch Scenarios in Three Minutes: One Set of Code Covers Four English Learning Goals
The greatest value of this architecture is scenario reusability. Because the System Prompt is input from the UI and not hardcoded, readers can freely switch between multiple English learning goals without changing a single line of JS. The four preset buttons on the credential panel are already built-in:
| Preset Button | Suitable For | Practice Method |
|---|---|---|
| Daily English Tutor | Anyone wanting to maintain daily English feel | Free conversation, correct one pronunciation/grammar/word-choice error per line |
| IELTS Speaking Examiner | Students preparing for IELTS | Part 2/3 mock interview, feedback based on the four scoring dimensions: fluency / lexical / grammar / pronunciation |
| Business Interview English | Candidates applying for foreign companies/remote positions | Business behavioral/situational interview simulation, comment on business vocabulary and professional expression |
| Travel English Partner | Readers cramming spoken English before going abroad | Role-play ordering food, asking for directions, checking in, shopping scenarios |
Click a preset, and the Persona input box automatically fills with the corresponding System Prompt. After saving, the 3D shell session automatically restarts, and you can start practicing.
⚠️ Why does this article only do English, and not a multi-language family bucket?
One application can only be bound to one voice (e.g., "American English female voice"). After binding, even if the System Prompt tells it to speak Japanese or Chinese, the audio will still be read with an American English accent, which sounds very jarring. So if you want to make a Japanese practice partner, a Cantonese practice partner, or a Chinese interview HR, the correct approach is to create another application in the console (switch to the corresponding language's voice), and then swap a set of AppID/Secret in the page's credential panel—
localStoragewill automatically save the most recent configuration, making switching effortless.
Want to add your own English niche scenario? Just directly modify the text in the Persona input box. For example, to make a "TOEFL Independent Speaking 45-second Statement Practice Partner," paste a Prompt like this:
You are a TOEFL Independent Speaking coach. Give me a Task 1 topic (< 25 words). After I answer, first summarize my structure (position / two reasons / examples) in one line, then give a one-line delivery tip (fluency, coherence, vocabulary, or pronunciation).
VII. Pitfall Avoidance Guide and Conclusion
Here are a few common runtime pitfalls I've stepped on:
Must use HTTPS or localhost:
webkitSpeechRecognitionis only available in secure contexts for privacy reasons. So for local development, definitely usepython -m http.server 8000to go throughlocalhost:8000, not an IP address.Must use Chrome or Edge: Web Speech API support is still incomplete in Firefox/Safari; the Chrome kernel is the most stable. The code includes a compatibility downgrade—when not supported, the microphone button is automatically disabled, and the reader can still type.
Microphone permission prompt on first use: Chrome will pop up a permission window the first time you click the microphone; the reader needs to click "Allow." If "Block" is clicked by mistake, you need to go to
chrome://settings/content/microphoneto manually allow it.LLM CORS issues: I've tested AI ping / DeepSeek official / Tongyi Bailian / Kimi official, and they all have browser direct-connect CORS enabled. A very small number of privately deployed interfaces will block browser requests; if you encounter this, switch to a backend Node/Python relay.
AppSecret leak prevention: The credentials in this article are stored in
localStorage, which is fine if only kept in your own browser; but if you later deploy this page to a production environment (e.g., making it a real oral practice website for others to use), be sure to move both the AppSecret and the LLM API Key to a backend relay, with the frontend only exposing short-term signatures or session tokens.
The most intuitive feeling after the entire experiment is: in the past, running a 3D digital human that could make eye contact, listen, speak, and correct errors usually meant thinking of "cloud GPU rendering + video streaming," with unfriendly costs and latency. This time, using the "parameter stream + client-side rendering" approach, stuffing the whole thing into one HTML file and running it on a regular laptop is feasible, with less than 300 lines of code.
There are many directions this demo can continue to tinker with: swapping ASR for more accurate cloud-based recognition, making the System Prompt into savable multi-role configurations, transforming the interrupt button into "voice auto-interruption," overlaying a pronunciation scoring layer on replies... all are great next steps. Interested readers can continue to expand on the code foundation of this article and create their own embodied intelligence applications.