跪拜 Guibai
← Back to the summary

A 3D AI English Tutor That Runs in a Single HTML File

@[toc]

image.png


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:

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:

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:

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. image.png

Step 2: Create an Embodied Intelligence Application

After entering the console, click "Create Application." image.png

Select in order:

After creation, enter the application details page, and you will see the two most critical fields:

  1. App ID — Application unique identifier
  2. App Secret — Application key

b02be2e120bd0a00784a7cffabde503d.png image.png

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:

Three steps done, let's move directly to the coding phase.

IV. Hardcore Practice: Running the "3D Practice Partner" with a Single HTML File

  1. Credentials are not hardcoded—a collapsible panel at the top, with 5 fields that save to localStorage after filling, suitable for distribution.
  2. Added browser-native Web Speech ASR—hold the microphone to speak, release to automatically send, truly achieving "open your mouth and practice."
  3. 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"—

6064b963-1d0e-4832-95bd-c8aa6c9e1eb3.png

After filling in the credentials, the 3D practice partner finishes descending. Hold the microphone and speak English to witness true embodied intelligence practice. image.png

5e44bf1ee23f246ca8b62289e5848e3c.png

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 first destroy() the old instance, clear the DOM of #sdk_canvas, and then new another one, without needing to refresh the page. To support "change credentials - instantly switch applications" in this article, explicit instance cleanup is necessary; the if (mofaSdk) { mofaSdk.destroy?.(); ... } in the code is for this purpose.

The ttsa path in gatewayServer is 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:

After careful evaluation for the oral practice scenario, I still chose Paradigm A, for reasons contrary to intuition:

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 speak method 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 isFinal branch of processStreamText, even if textBuffer is already empty (the end happened to be punctuation), you must explicitly call driveSpeak('', 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's textBuffer and isFirstSentence states must also be reset, otherwise the next round's speak will pass isStart as false, 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
image.png

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—localStorage will 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:

  1. Must use HTTPS or localhost: webkitSpeechRecognition is only available in secure contexts for privacy reasons. So for local development, definitely use python -m http.server 8000 to go through localhost:8000, not an IP address.

  2. 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.

  3. 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/microphone to manually allow it.

  4. 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.

  5. 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.