@[toc]
一、 缺口暴露:AI 口语陪练的"最后一公里"到底堵在哪
自从 DeepSeek、Qwen、Kimi K2 等国产大模型接连突破推理极限,"每个人都能拥有一个私人外教"看起来像是唾手可得的现实。然而但凡认真用过市面上「AI 口语陪练」产品的同学都能感受到一种微妙的割裂:大模型的语言能力已经远超一个称职的外教,但陪练体验却依然停留在"聊天框 + 语音条"的原始阶段。
我把主流方案按体验痛点做了一次归类:
-
纯文字大模型 + TTS 拼贴(多数 App 的做法):读者对着聊天框打字,AI 回一段文字再由 TTS 合成播音。整个过程和"聋哑口语课"没什么区别——你练不到听力,AI 也读不出你嘴型和情绪,纠错完全依赖脑补。
-
大模型 + 视频流数字人(云端渲染型):看起来像"真人外教",但因为整段视频都要在云端渲染再推流到你屏幕,问一句话要傻等 3~5 秒才见反应。稍有网络波动就音画脱节——嘴还在动,声音已经播完。真正对练时这种延迟极其致命,因为口语训练需要即时反馈:你刚说错的元音,外教半秒内纠正才有肌肉记忆。
-
纯文字大模型 + 浏览器 TTS:延迟低但无表情、无对视、无肢体,冷冰冰。练两次就会腻,坚持不下来。
真正的口语陪练体验缺的从来不是 IQ,而是"具身感":能对视的眼神、随语义变化的微表情、能一次说完再听你完整回应的对话节奏。要补上这最后一公里,大模型必须拥有一个既能实时响应、又能低成本运行在浏览器里的 3D 具身躯壳
二、 技术选型:为什么走"参数流 + 端渲染"这条路
要在浏览器里跑一个能实时对话的 3D 数字人,架构上大致有两条路:
- 云端渲染 + 视频推流:3D 画面在服务器端渲染成视频流,推给浏览器播放。好处是端侧无算力压力,坏处是首帧延迟高、带宽敏感、嘴型清晰度受编码影响。
- 参数流 + 端渲染:服务器端只下发骨骼、嘴型、面部肌肉等驱动参数,浏览器 SDK 用本地显卡实时渲染。传输量非常小,端到端延迟更容易压下去,嘴型也能保留原始精度。
本次实验选择的 魔珐星云 走的是第二条路。它下发的是一份复合的参数流(面部肌肉参数 + 骨骼动画参数 + PCM 音频参数),SDK 拿到之后在 WebGL 里渲染。这套技术方案对陪练场景友好的几个点:
- 端到端延迟低:官方给出的数据是双向响应 1200ms 左右,实测下来"你刚说完 → 数字人开始张嘴回应"确实几乎无缝。口语训练最怕"僵持感",这个延迟是能接受的。
- 端侧算力压力可控:3D 骨骼与面部表情在本地显卡渲染,普通笔记本的 Chrome 也能跑。这意味着做 Demo 不需要自己起 GPU 服务器,一份 HTML 就能自己用。
- 嘴型保真:面部肌肉参数是直接下发的,配合端渲染能拿到清晰的口型动作,跟着模仿 "th" "r" "l" 这种中文母语者常错的音时能看得比较清楚。
接下来我们就用一个 HTML 文件,把这套东西跑起来。
三、 注册开户:3 分钟拿到调用凭证
平台接入门槛不高,Web 控制台点击即可,没有企业审核环节。
第一步:注册账号
打开官网👇
https://xingyun3d.com/
点击右上角"登录/注册"。注册时"邀请码"一栏可以填 JM8ABNY834,能领 1000 积分作为新人额度,跑完本篇 Demo 还能试用很久
第二步:创建具身智能应用
进入控制台后点击"创建应用"
依次挑选:
- 形象:官方提供了几十个 3D 数字人形象,男女老少、职业穿搭都有。做口语外教我推荐挑一个「职业女性 / 白领」气质的形象,代入"外教"角色更自然。
- 场景:教室、办公室、书房、咖啡厅都合适,避免过于花哨的舞台场景。
- 音色:这里要留意——一个应用只能绑定一种音色,绑定后无论 System Prompt 里让它说什么语言,输出音频都会走这一种口音。这是本篇必须只锁定一种目标语言的根本原因。因为我们后面不使用平台自带的对话大模型,而是走"外部大模型 + SDK speak 接口"的模式,音色的选择会直接决定陪练官朗读英语的地道程度。本文以英语口语陪练为例,请挑一个标注了"美式英语"或"英式英语"的原生英语音色(不要选中文音色,否则英语会念得非常出戏)。
- 表演动作:默认即可。
创建完成后,进入应用详情页,你会看到最关键的两个字段:
- App ID — 应用唯一标识
- App Secret — 应用密钥
复制备用。注意 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 陪练官"
- 凭证不硬编码——顶部一个可折叠面板,5 个字段填完存进
localStorage,符合分发形态; - 加入浏览器原生 Web Speech ASR——按住麦克风说话,松开自动发送,真正做到"张嘴对练";
- 大模型层完全解耦——只要兼容 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 躯壳"——
填写完凭证之后,3D 陪练官降临完成,按住麦克风开口说英语,见证真正的具身智能对练。
五、 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?.(); ... }就是为此。
gatewayServer的ttsa路径是 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):告知云端整段说完,可以做尾音收束、动作过渡回待机。注意我在结束帧上做了一个易被忽略的兜底——
processStreamText的isFinal分支里,即使textBuffer已经是空的(末尾正好是标点),也要显式调一次driveSpeak('', true),否则云端会一直挂在"等待续接"状态,动作过渡不回自然待机,下一轮对话会莫名卡顿。上一篇的代码里遇到"最后一句正好以标点结尾"就会踩这个坑,本篇修好了。
5.3 interactiveIdle():陪练场景的"随时打断"是刚需
document.getElementById('stop-btn').addEventListener('click', () => {
if (mofaSdk) {
mofaSdk.interactiveIdle();
textBuffer = '';
isFirstSentence = true;
}
});
⚙️ 技术拆解:
在导游场景,"打断"更多是防御性设计。但在陪练场景,打断本身就是学习动作的一部分——学生想在外教纠错到一半时插话追问,或者听到"你刚才用错时态了"想立刻要例句。这些交互如果被"陪练官必须说完当前段落"卡住,陪练体验就毁了。
interactiveIdle()底层做了两件事:一是清空端侧未播放的参数帧缓冲(否则打断后残留帧还会继续播),二是通知云端会话终止本轮 TTSA 生成(否则云端还在给你算下一句的动画)。缺一不可。所以打断按钮点击后,还必须把前端的textBuffer和isFirstSentence状态重置,否则下一轮speak会把isStart传成false,云端会以为是接续之前的会话,结果播放异常。
六、 三分钟切场景:一套代码覆盖四种英语学习目标
这套架构最大的价值是场景可复用。因为 System Prompt 是从 UI 输入的、不写死在代码里,读者不用改一行 JS 就能在多种英语学习目标之间自由切换。凭证面板上的四个预设按钮已经内置好了:
| 预设按钮 | 适用人群 | 陪练方式 |
|---|---|---|
| 日常口语外教 | 想日常保持英语手感的所有人 | 自由聊天,一句话纠一个发音/语法/用词错误 |
| 雅思口语考官 | 备考雅思的学生 | Part 2/3 模拟面试,按 fluency / lexical / grammar / pronunciation 四大评分维度反馈 |
| 外企面试英语 | 求职外企/远程岗的候选人 | 商务行为面/情境面模拟,点评商务用词和职业化表达 |
| 旅行英语陪练 | 出国前突击应急口语的读者 | 角色扮演点餐、问路、入住、购物场景 |
点一下预设,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).
七、 避坑指南与结语
几个常见的运行时坑,我把踩过的都列在这:
-
必须用 HTTPS 或 localhost:
webkitSpeechRecognition出于隐私考虑,只在安全上下文里可用。所以本地开发一定用python -m http.server 8000走localhost:8000,不要用 IP。 -
必须用 Chrome 或 Edge:Web Speech API 目前 Firefox/Safari 支持仍不完整,Chrome 内核最稳。代码里做了兼容降级——不支持时麦克风按钮自动禁用,读者仍可打字。
-
麦克风权限首次询问:Chrome 会在第一次点麦克风时弹权限窗,读者要点"允许"。如果误点了"阻止",需要去
chrome://settings/content/microphone里手动放行。 -
大模型 CORS 问题:AI ping / DeepSeek 官方 / 通义百炼 / Kimi 官方这几家我实测都开了浏览器直连 CORS。极少数私有部署接口会拦截浏览器请求,遇到就换成后端 Node/Python 中转。
-
AppSecret 泄漏防护:本文的凭证存在
localStorage,只放在自己浏览器里没问题;但如果你后续把这个页面部署到生产环境(比如做成一个真的口语陪练网站给别人用),务必把 AppSecret 和大模型 API Key 都挪到后端中转,前端只暴露短期签名或 session token。
整个实验做下来最直观的感受是:过去要跑一个能对视、能听说、能纠错的 3D 数字人,通常想到的都是"云端 GPU 渲染 + 视频推流",成本和延迟都不友好。这次用"参数流 + 端渲染"这套思路,把整套东西塞进一个 HTML 文件、跑在一台普通笔记本上是可行的,代码不到 300 行。
这套 Demo 还有很多可以继续折腾的方向:把 ASR 换成更准的云端识别、把 System Prompt 做成可保存的多角色配置、把打断按钮改造成"语音自动打断"、给回复叠加一层发音评分……都是很好的下一步。有兴趣的读者可以在本文的代码基础上继续扩展,做出属于自己的具身智能应用。