10分钟搭建你的专属AI外教!基于魔珐星云参数流架构的AI口语陪练官实战指南

0 阅读19分钟

@[toc]

image.png


一、 缺口暴露:AI 口语陪练的"最后一公里"到底堵在哪

自从 DeepSeek、Qwen、Kimi K2 等国产大模型接连突破推理极限,"每个人都能拥有一个私人外教"看起来像是唾手可得的现实。然而但凡认真用过市面上「AI 口语陪练」产品的同学都能感受到一种微妙的割裂:大模型的语言能力已经远超一个称职的外教,但陪练体验却依然停留在"聊天框 + 语音条"的原始阶段。

我把主流方案按体验痛点做了一次归类:

  • 纯文字大模型 + TTS 拼贴(多数 App 的做法):读者对着聊天框打字,AI 回一段文字再由 TTS 合成播音。整个过程和"聋哑口语课"没什么区别——你练不到听力,AI 也读不出你嘴型和情绪,纠错完全依赖脑补。

  • 大模型 + 视频流数字人(云端渲染型):看起来像"真人外教",但因为整段视频都要在云端渲染再推流到你屏幕,问一句话要傻等 3~5 秒才见反应。稍有网络波动就音画脱节——嘴还在动,声音已经播完。真正对练时这种延迟极其致命,因为口语训练需要即时反馈:你刚说错的元音,外教半秒内纠正才有肌肉记忆。

  • 纯文字大模型 + 浏览器 TTS:延迟低但无表情、无对视、无肢体,冷冰冰。练两次就会腻,坚持不下来。 image.png

真正的口语陪练体验缺的从来不是 IQ,而是"具身感":能对视的眼神、随语义变化的微表情、能一次说完再听你完整回应的对话节奏。要补上这最后一公里,大模型必须拥有一个既能实时响应、又能低成本运行在浏览器里的 3D 具身躯壳

二、 技术选型:为什么走"参数流 + 端渲染"这条路

要在浏览器里跑一个能实时对话的 3D 数字人,架构上大致有两条路:

  • 云端渲染 + 视频推流:3D 画面在服务器端渲染成视频流,推给浏览器播放。好处是端侧无算力压力,坏处是首帧延迟高、带宽敏感、嘴型清晰度受编码影响。
  • 参数流 + 端渲染:服务器端只下发骨骼、嘴型、面部肌肉等驱动参数,浏览器 SDK 用本地显卡实时渲染。传输量非常小,端到端延迟更容易压下去,嘴型也能保留原始精度。

本次实验选择的 魔珐星云 走的是第二条路。它下发的是一份复合的参数流(面部肌肉参数 + 骨骼动画参数 + PCM 音频参数),SDK 拿到之后在 WebGL 里渲染。这套技术方案对陪练场景友好的几个点:

  • 端到端延迟低:官方给出的数据是双向响应 1200ms 左右,实测下来"你刚说完 → 数字人开始张嘴回应"确实几乎无缝。口语训练最怕"僵持感",这个延迟是能接受的。
  • 端侧算力压力可控:3D 骨骼与面部表情在本地显卡渲染,普通笔记本的 Chrome 也能跑。这意味着做 Demo 不需要自己起 GPU 服务器,一份 HTML 就能自己用。
  • 嘴型保真:面部肌肉参数是直接下发的,配合端渲染能拿到清晰的口型动作,跟着模仿 "th" "r" "l" 这种中文母语者常错的音时能看得比较清楚。 image.png

接下来我们就用一个 HTML 文件,把这套东西跑起来。

三、 注册开户:3 分钟拿到调用凭证

平台接入门槛不高,Web 控制台点击即可,没有企业审核环节。

第一步:注册账号

打开官网👇

https://xingyun3d.com/

点击右上角"登录/注册"。注册时"邀请码"一栏可以填 JM8ABNY834,能领 1000 积分作为新人额度,跑完本篇 Demo 还能试用很久 image.png

第二步:创建具身智能应用

进入控制台后点击"创建应用" image.png

依次挑选:

  • 形象:官方提供了几十个 3D 数字人形象,男女老少、职业穿搭都有。做口语外教我推荐挑一个「职业女性 / 白领」气质的形象,代入"外教"角色更自然。
  • 场景:教室、办公室、书房、咖啡厅都合适,避免过于花哨的舞台场景。
  • 音色:这里要留意——一个应用只能绑定一种音色,绑定后无论 System Prompt 里让它说什么语言,输出音频都会走这一种口音。这是本篇必须只锁定一种目标语言的根本原因。因为我们后面不使用平台自带的对话大模型,而是走"外部大模型 + SDK speak 接口"的模式,音色的选择会直接决定陪练官朗读英语的地道程度。本文以英语口语陪练为例,请挑一个标注了"美式英语"或"英式英语"的原生英语音色(不要选中文音色,否则英语会念得非常出戏)。
  • 表演动作:默认即可。 image.png

创建完成后,进入应用详情页,你会看到最关键的两个字段:

  1. App ID — 应用唯一标识
  2. App Secret — 应用密钥

b02be2e120bd0a00784a7cffabde503d.png image.png

复制备用。注意 App Secret 务必妥善保存

第三步:准备一个大模型 API

本文的架构里,大模型和 3D SDK 是解耦的——你可以自由选任何兼容 OpenAI 协议的大模型服务。我个人用的是 AI ping 聚合平台(一次充值就能随意切换 DeepSeek、Qwen、Kimi K2、GLM、豆包等各家最新模型,做陪练效果对比很方便),你也可以直接用 DeepSeek 官方、通义百炼、Kimi 开放平台、硅基流动等任意平台。

准备三个信息即可:

  • Base URL(如 https://api.deepseek.com/v1
  • API Key
  • 模型名称(如 deepseek-chat / qwen-plus / kimi-k2 等)

三步搞定,接下来直接进入代码环节。

四、 硬核实战:单 HTML 文件跑通"3D 陪练官"

  1. 凭证不硬编码——顶部一个可折叠面板,5 个字段填完存进 localStorage,符合分发形态;
  2. 加入浏览器原生 Web Speech ASR——按住麦克风说话,松开自动发送,真正做到"张嘴对练";
  3. 大模型层完全解耦——只要兼容 OpenAI 协议的接口都能直接跑,读者不用锁死在某家。

在本地建一个 index.html,粘贴以下完整代码:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AI 口语陪练官 · 魔珐星云 3D 具身智能</title>
    <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
    <script src="https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar@latest.js"></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">

    <!-- 顶部凭证配置面板(可折叠 + localStorage 持久化) -->
    <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">凭证配置面板</span>
                <span id="cred-status-text" class="text-xs text-slate-400">(未配置,点击展开)</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">魔珐星云 AppID</label>
                    <input type="text" id="cfg-mofa-appid" placeholder="从魔珐星云控制台复制" 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">魔珐星云 AppSecret</label>
                    <input type="password" id="cfg-mofa-secret" placeholder="从魔珐星云控制台复制" 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">模型 Base URL</label>
                    <input type="text" id="cfg-llm-url" placeholder="如 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">模型 API Key</label>
                    <input type="password" id="cfg-llm-key" placeholder="OpenAI 兼容协议的 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">模型名称</label>
                    <input type="text" id="cfg-llm-model" placeholder="如 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">陪练角色人设 (System Prompt)</label>
                <textarea id="cfg-persona" rows="2" placeholder="留空则使用默认口语外教人设" 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">日常口语外教</button>
                    <button class="preset-btn bg-slate-700 hover:bg-slate-600 px-2 py-1 rounded" data-preset="ielts">雅思口语考官</button>
                    <button class="preset-btn bg-slate-700 hover:bg-slate-600 px-2 py-1 rounded" data-preset="business">外企面试英语</button>
                    <button class="preset-btn bg-slate-700 hover:bg-slate-600 px-2 py-1 rounded" data-preset="travel">旅行英语陪练</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">保存并初始化 3D 躯壳</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">清除本地凭证</button>
            </div>
            <p class="text-xs text-slate-500 leading-relaxed">
                🔒 所有凭证仅保存在你自己的浏览器 <code class="bg-slate-950 px-1 rounded">localStorage</code> 中,不会上传到任何第三方。生产环境请把大模型密钥迁移到后端中转,避免前端反编译泄漏。
            </p>
        </div>
    </div>

    <!-- 主交互区 -->
    <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">请先在顶部填写凭证</p>
                <p class="text-xs text-slate-500 mt-2">配置保存后自动加载 3D 陪练官</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">待配置凭证</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 口语陪练官</h1>
                <p class="text-xs text-slate-400 mt-1">魔珐星云 3D 具身 × 兼容 OpenAI 协议大模型</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. 说英语或中文都可以。</div>
            </div>
            <div class="space-y-2">
                <div class="flex gap-2">
                    <input type="text" id="user-input" placeholder="输入或按住麦克风说话..." 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="按住说话" 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">发送</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">🛑 打断陪练官发言</button>
            </div>
        </div>
    </div>

    <script>
    // ============ 0. 常量与状态 ============
    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. 凭证面板 ============
    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 = '(已配置)';
        } else {
            dot.className = 'h-2 w-2 rounded-full bg-amber-400';
            text.innerText = '(未配置,点击展开)';
        }
    }
    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('请填齐五项凭证'); return; }
        localStorage.setItem(STORAGE_KEY, JSON.stringify(creds));
        updateCredStatus();
        toggleCredPanel(false);
        initMofaAvatar();
    }
    function clearCreds() {
        if (!confirm('确定清除本地凭证吗?')) 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. 魔珐星云 3D 躯壳初始化 ============
    function initMofaAvatar() {
        if (typeof XmovAvatar === 'undefined') { setStatus('SDK 加载失败', '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 陪练官正在降临...';
        setStatus('正在初始化', '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 通知]', msg)
        });
        mofaSdk.init({
            onDownloadProgress: (progress) => {
                document.getElementById('loading-text').innerText = `资源下载中 ${progress}%`;
            }
        }).then(() => {
            document.getElementById('loading-tips').style.display = 'none';
            setStatus('3D 陪练官已就绪', 'emerald');
            enableInteraction(true);
        }).catch(err => {
            document.getElementById('loading-text').innerText = '初始化失败:' + (err?.message || '请检查 AppID/Secret');
            setStatus('初始化失败', '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. 流式断句 → 驱动 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 调用异常', e); }
    }

    // ============ 4. 大模型 SSE 流式(OpenAI 兼容) ============
    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"> [连接异常: ${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(按住说话,固定英语识别) ============
    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 = '当前浏览器不支持 Web Speech,请用 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. 事件绑定 ============
    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 = '⚠️ 已打断 3D 陪练官';
            document.getElementById('chat-output').appendChild(tip);
        }
    });

    // ============ 7. 启动 ============
    window.addEventListener('load', () => {
        loadCreds();
        setupASR();
        if (credsReady()) { toggleCredPanel(false); initMofaAvatar(); }
        else { toggleCredPanel(true); }
    });
    </script>
</body>
</html>

保存后,进入项目目录起一个本地小服务器(不能直接双击打开,因为 file:// 协议下浏览器不允许调用麦克风也不允许拉线上 SDK):

python -m http.server 8000

Chrome 或 Edge 访问 http://localhost:8000,填入之前拿到的凭证,点击"保存并初始化 3D 躯壳"——

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

填写完凭证之后,3D 陪练官降临完成,按住麦克风开口说英语,见证真正的具身智能对练。 image.png

5e44bf1ee23f246ca8b62289e5848e3c.png

五、 SDK 核心方法深度拆解:陪练场景独有的调用范式

代码跑通只是第一步,真正决定体验质量的,是 SDK 的三个核心 API 在陪练场景下的调用范式。

5.1 new XmovAvatar()init():为什么把凭证放到 Promise 前置校验

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 通知]', msg)
});

⚙️ 技术拆解

构造函数只做参数登记,不发起任何网络请求——这一点非常关键。真正的鉴权与资源下载发生在 mofaSdk.init(...) 的 Promise 里。这允许我们在运行时切换凭证:读者更换 AppID/Secret 后,先 destroy() 旧实例、清空 #sdk_canvas 的 DOM,再重新 new 一个即可,无需刷页面。本篇支持"改凭证-即时切换应用",就必须显式做实例清理,代码里的 if (mofaSdk) { mofaSdk.destroy?.(); ... } 就是为此。

gatewayServerttsa 路径是 Text-to-Speech + Animation 的缩写,这是一条端到端的参数流协议——请求发进去,返回的不是音频文件或视频流,而是面部肌肉参数 + 骨骼动画参数 + PCM 音频参数的复合帧流。前端 SDK 在本地拿到这三路参数后,用 WebGL 渲染出 3D 数字人。这就是为什么口语陪练场景下嘴型能对得上——嘴型参数是和音频参数在同一帧里下发的,天然对齐。

5.2 speak(text, isStart, isEnd):陪练场景为什么保留流式断句而不是整段发送

这是本篇最需要仔细掂量的一个决策。SDK 的 speak 方法允许两种调用范式:

  • 范式 A · 流式断句:大模型每吐一个 token 就累加到 buffer,遇到句号/问号等标点就立刻切一段发出去
  • 范式 B · 整段发送:等大模型输出完整段回复后,一次性 speak(fullText, true, true)

口语陪练场景我认真评估后依然选择范式 A,原因跟直觉相反:

  • 陪练场景的回复通常本来就短(Prompt 里已约束 < 40 词),流式断句最多切成 1-2 段,语调断裂几乎不可感知
  • 首帧延迟在陪练里比在导游里更致命——学生刚说完一句英语等着纠错反馈,僵持 2 秒就会分心
  • 更微妙的一点:陪练回复的第一句往往是"回应"(Right, good try / I see),第二句才是"纠错"或"追问"。流式断句正好让数字人先开口回应、再补上纠错,节奏和真实外教高度接近
function driveSpeak(text, isEnd) {
    if (!mofaSdk) return;
    mofaSdk.speak(text, isFirstSentence, isEnd);
    if (isFirstSentence && text) isFirstSentence = false;
    isSpeaking = !isEnd;
}

⚙️ 技术拆解

speak 方法的三个参数是一台严格的状态机:

  • 首句 (text, isStart=true, isEnd=false):通知云端进入发音状态,创建本次语音会话;
  • 中间句 (text, isStart=false, isEnd=false):向已有会话追加语句,云端在此时流式返回增量的动画帧,前端播放不中断;
  • 末尾 (text, isStart=false, isEnd=true)('', false, true):告知云端整段说完,可以做尾音收束、动作过渡回待机。

注意我在结束帧上做了一个易被忽略的兜底——processStreamTextisFinal 分支里,即使 textBuffer 已经是空的(末尾正好是标点),也要显式调一次 driveSpeak('', true),否则云端会一直挂在"等待续接"状态,动作过渡不回自然待机,下一轮对话会莫名卡顿。上一篇的代码里遇到"最后一句正好以标点结尾"就会踩这个坑,本篇修好了。

5.3 interactiveIdle():陪练场景的"随时打断"是刚需

document.getElementById('stop-btn').addEventListener('click', () => {
    if (mofaSdk) {
        mofaSdk.interactiveIdle();
        textBuffer = '';
        isFirstSentence = true;
    }
});

⚙️ 技术拆解

在导游场景,"打断"更多是防御性设计。但在陪练场景,打断本身就是学习动作的一部分——学生想在外教纠错到一半时插话追问,或者听到"你刚才用错时态了"想立刻要例句。这些交互如果被"陪练官必须说完当前段落"卡住,陪练体验就毁了。

interactiveIdle() 底层做了两件事:一是清空端侧未播放的参数帧缓冲(否则打断后残留帧还会继续播),二是通知云端会话终止本轮 TTSA 生成(否则云端还在给你算下一句的动画)。缺一不可。所以打断按钮点击后,还必须把前端的 textBufferisFirstSentence 状态重置,否则下一轮 speak 会把 isStart 传成 false,云端会以为是接续之前的会话,结果播放异常。

六、 三分钟切场景:一套代码覆盖四种英语学习目标

这套架构最大的价值是场景可复用。因为 System Prompt 是从 UI 输入的、不写死在代码里,读者不用改一行 JS 就能在多种英语学习目标之间自由切换。凭证面板上的四个预设按钮已经内置好了:

预设按钮适用人群陪练方式
日常口语外教想日常保持英语手感的所有人自由聊天,一句话纠一个发音/语法/用词错误
雅思口语考官备考雅思的学生Part 2/3 模拟面试,按 fluency / lexical / grammar / pronunciation 四大评分维度反馈
外企面试英语求职外企/远程岗的候选人商务行为面/情境面模拟,点评商务用词和职业化表达
旅行英语陪练出国前突击应急口语的读者角色扮演点餐、问路、入住、购物场景
image.png

点一下预设,Persona 输入框自动填充对应的 System Prompt,保存后 3D 躯壳会话自动重启即可开练。

⚠️ 为什么本篇只做英语,而不是多语言全家桶?

一个应用只能绑定一种音色(例如"美式英语女声")。绑定后即使 System Prompt 里让它说日语或中文,音频依然会以美式英语的口音去朗读,效果非常出戏。所以想做日语陪练官、粤语陪练官、面试中文 HR,正确的做法是在控制台再建一个应用(换成对应语种的音色),然后在页面凭证面板换一套 AppID/Secret 即可——localStorage 会自动保存最近一次的配置,切换毫无成本。

想加自己的英语细分场景?直接改 Persona 输入框里的文本即可。例如做「TOEFL 独立口语 45 秒陈述陪练」,粘一段这样的 Prompt 就行:

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

七、 避坑指南与结语

几个常见的运行时坑,我把踩过的都列在这

  1. 必须用 HTTPS 或 localhostwebkitSpeechRecognition 出于隐私考虑,只在安全上下文里可用。所以本地开发一定用 python -m http.server 8000localhost:8000,不要用 IP。

  2. 必须用 Chrome 或 Edge:Web Speech API 目前 Firefox/Safari 支持仍不完整,Chrome 内核最稳。代码里做了兼容降级——不支持时麦克风按钮自动禁用,读者仍可打字。

  3. 麦克风权限首次询问:Chrome 会在第一次点麦克风时弹权限窗,读者要点"允许"。如果误点了"阻止",需要去 chrome://settings/content/microphone 里手动放行。

  4. 大模型 CORS 问题:AI ping / DeepSeek 官方 / 通义百炼 / Kimi 官方这几家我实测都开了浏览器直连 CORS。极少数私有部署接口会拦截浏览器请求,遇到就换成后端 Node/Python 中转。

  5. AppSecret 泄漏防护:本文的凭证存在 localStorage,只放在自己浏览器里没问题;但如果你后续把这个页面部署到生产环境(比如做成一个真的口语陪练网站给别人用),务必把 AppSecret 和大模型 API Key 都挪到后端中转,前端只暴露短期签名或 session token。


整个实验做下来最直观的感受是:过去要跑一个能对视、能听说、能纠错的 3D 数字人,通常想到的都是"云端 GPU 渲染 + 视频推流",成本和延迟都不友好。这次用"参数流 + 端渲染"这套思路,把整套东西塞进一个 HTML 文件、跑在一台普通笔记本上是可行的,代码不到 300 行。

这套 Demo 还有很多可以继续折腾的方向:把 ASR 换成更准的云端识别、把 System Prompt 做成可保存的多角色配置、把打断按钮改造成"语音自动打断"、给回复叠加一层发音评分……都是很好的下一步。有兴趣的读者可以在本文的代码基础上继续扩展,做出属于自己的具身智能应用。