「未来星途(Future Odyssey)」------全语音 3D AI 具身交互智能数字教官与全双工星际科普沉浸舱
7-2未来星途数字人-视频演示
一、项目背景与痛点
传统航天科普产品大多采用"图文展板 + 视频播放"的单向输出模式------孩子被动接收信息,缺乏真正的互动参与。即便引入了语音问答,也往往是"半双工对讲机"模式:用户说一句,必须等数字人完全回答完毕才能继续下一句。在沉浸式科普场景下,这个痛点被放大了------当数字人正在播报一段星际航行知识时,学员无法随时插话追问或纠正,探索欲和好奇心被卡死在等待队列里。
为了打破这种单向灌输,我们开发了**「未来星途(Future Odyssey)」少年星际航行指挥官模拟舱**------一个集成了 3D 数字人、大模型推理和全双工语音交互的沉浸式科普选拔平台。具身交互智能数字人化身为"随行指导员·机能少女",以活泼亲切的队友伙伴口吻,通过模拟星际航行对话场景,实时考核学员的空间想象力、危机处理能力和科学常识。
核心突破点:学员可以随时开口打断教官的播报,提出新问题或纠正答案,系统在毫秒级内完成打断→倾听→推理→回复→播报的完整闭环,实现真正的双向对话。
作为一名前端开发,本次项目全程通过 AI Coding 工具辅助,几乎由 AI 完成了全部核心链路的代码生成。本文将从技术架构、全双工状态机、三大核心模块的集成实现三个维度,分享整个开发流程。
二、核心技术亮点与架构
本系统是一个纯前端单文件应用(index.html),通过一个 config.json 配置文件实现零配置感知。整体架构围绕三大核心能力闭环:
Plaintext
┌─────────────────────────────────────────────────────────┐
│ 未来星途 · 架构总览 │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Web Speech│───>│ 火山方舟 LLM │───>│ 魔珐星云 SDK │ │
│ │ API ASR │ │ Response API │ │ XmovAvatar │ │
│ └──────────┘ └──────────────┘ └───────────────┘ │
│ ↑ │ │
│ └──────── 全双工打断回路 ───────────────┘ │
│ │
│ 状态机:idle → listening → thinking → speaking → idle │
└─────────────────────────────────────────────────────────┘
五大技术要点
-
Web Speech API 连续监听 + VAD 自动转写 :配置
continuous: true,通过静默计时器实现语音活动检测(VAD),用户说完自动触发转写,无需任何点击操作。入场时在用户手势内首次启动 ASR 建立麦克风授权,加载期间通过asrHold机制保持会话存活但不处理语音。 -
火山方舟 Response API 非流式推理 :使用
stream: false一次性返回完整回复,通过previous_response_id实现原生多轮连续对话,显式关闭思考模式确保响应效率。 -
魔珐星云数字人 SDK 全生命周期管理 :从初始化(含下载进度回调)、播报、打断到销毁的完整实例管理。SDK 回调(
onMessage、onVoiceStateChange)作为构造参数传入,配合状态指示器驱动 UI 流转。 -
低延迟全双工打断机制 :当数字人正在播报时,ASR 捕获到用户新语音,立即调用
interactiveidle()切断播报,清空缓冲区,无缝切换至新一轮交互循环。 -
WebGL 星云背景特效:基于 OGL 库的 GPU 粒子星云,自定义 GLSL fragment shader 实现多层星空、闪烁、色相偏移和鼠标排斥效果,全屏沉浸感拉满。
三、核心功能与交互效果展示
1. 沉浸式星际航行 UI
整个页面采用 100vh 全屏流体布局,视觉风格为"科幻钛空":


-
全屏背景:WebGL 星云粒子特效(OGL + GLSL shader),多层星空 + 闪烁动画 + 青蓝色相偏移
-
左侧/中央:数字人全屏视窗,底部渐变融合
-
右侧:340px 精致看板------"星际航行日志"对话字幕 + 三颗 LED 状态指示器(语音/推理/播报)
-
视觉特效 :毛玻璃
backdrop-filter: blur(20px)、0.5px 超细边框、0.42 透明度面板 -
入场欢迎页:半透明蒙版让星云特效完全透出,"进入指挥舱"按钮带流光扫过动画
2. 核心交互:随时打断教官
数字人正在播报时,学员可随时开口说话。系统通过 ASR 实时监测用户语音,一旦检测到插话:
停止当前播报 → 调用
interactiveidle()返回待机 → 切换至倾听状态 → 捕获新提问 → 推理回答 → 数字人播报新回复
3. 双通道输入:语音 + 文本
右侧看板底部提供文本指令输入框(cmdInput),支持回车快捷发送。文本输入复用语音消息的完整处理流程(handleUserTextFromInput → handleUserSpeech),实现语音/文本双通道统一入口。
4. 零配置感知 + 数字人加载进度
页面加载时自动读取 config.json,前端不显示任何配置输入框。若文件缺失或字段为空,视窗中央以科幻语言提醒:
"通讯矩阵配置未就绪,请检修基底 config.json 文件"
数字人加载期间,视窗覆盖半透明遮罩 + 旋转加载环 + 进度条 + 百分比,实时展示 onDownloadProgress 回调的下载进度。
5. 休眠/唤醒控制
右侧面板头部提供"教官休眠"/"教官唤醒"切换按钮(toggleAvatarPower),通过 setStopButtonMode 动态切换按钮的 tooltip 文案和视觉状态(青色休眠 / 绿色唤醒)。
四、全双工打断的底层控制流实现
全双工交互的核心难点在于状态流转的时序控制。以下是系统在检测到用户插话时的完整控流逻辑:
状态机定义
JavaScript
const STATE = {
config: null, // 配置对象
avatar: null, // SDK 实例
avatarReady: false, // 数字人是否就绪
avatarSpeaking: false, // 数字人是否正在播报
connecting: false, // 是否正在建立连接
previousResponseId: null, // 多轮对话上下文 ID
asrListening: false, // ASR 是否正在监听
isProcessing: false, // 是否正在等待 LLM 响应
recognition: null, // SpeechRecognition 实例
interruptFlag: false, // 打断标志位
};
核心打断函数
JavaScript
function handleInterruptDuringSpeaking() {
if (!STATE.avatarSpeaking || !STATE.avatarReady) return false;
console.log('[Interrupt] Cutting avatar playback');
STATE.interruptFlag = true;
// 1. 显示打断指示(红色脉冲标签,2秒后自动消失)
const indicator = $('interruptIndicator');
indicator.classList.add('show');
setTimeout(() => indicator.classList.remove('show'), 2000);
// 2. 调用 SDK 打断接口,中止当前 TTS 播报
try {
STATE.avatar.interactiveidle(); // 注意:SDK 方法为小写
} catch (e) {
console.warn('interactiveIdle error:', e);
}
// 3. 强制重置播报状态
STATE.avatarSpeaking = false;
setChipState('chipAvatar', 'idle');
return true;
}
完整交互生命周期
JavaScript
async function handleUserSpeech(text) {
// 1. 全双工打断检测:如果数字人正在播报,先打断
if (STATE.avatarSpeaking) {
handleInterruptDuringSpeaking();
}
// 2. 暂停 ASR,防止自己的播报被识别(回声抑制)
STATE.isProcessing = true;
pauseASR();
// 3. 显示用户消息到航行日志
addMessage('cadet', text);
// 4. 调用大模型推理
const reply = await callLLM(text);
if (!reply) {
STATE.isProcessing = false;
resumeASR();
return;
}
// 5. 显示教官回复
addMessage('orion', reply);
// 6. 数字人播报回复
if (STATE.avatarReady && STATE.avatar) {
try {
STATE.avatar.speak(reply);
} catch (e) {
// 播报失败,直接恢复 ASR
STATE.isProcessing = false;
resumeASR();
}
// isProcessing 在 onVoiceStateChange('end') 中置 false
} else {
STATE.isProcessing = false;
resumeASR();
}
}
完整交互循环:
-
用户开口 → ASR
onresult捕获语音,VAD 静默计时器 1.2 秒后判定说完 -
打断检测 → 若
avatarSpeaking === true,调用interactiveidle()切断播报 -
推理请求 → 暂停 ASR,发送文本到火山方舟 Response API
-
一次性返回 → 大模型返回完整回复(
stream: false) -
播报驱动 → 调用
avatar.speak(reply),onVoiceStateChange监听播报状态 -
播报结束 →
onVoiceStateChange('end')触发,isProcessing置false,resumeASR()恢复连续监听
五、三大核心模块的集成实现
模块一:Web Speech API 连续监听
JavaScript
function initASR() {
const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
recognition.lang = 'zh-CN';
recognition.continuous = true;
recognition.interimResults = true;
let silenceTimer = null;
let currentTranscript = '';
recognition.onstart = () => {
// 加载期间:保持麦克风会话存活但不进入正式聆听
if (STATE.asrHold) {
STATE.asrListening = false;
setChipState('chipAsr', 'idle');
$('waveAnim').classList.add('hidden');
$('captionText').textContent = '系统准备中...';
return;
}
STATE.asrListening = true;
setChipState('chipAsr', 'listening');
$('waveAnim').classList.remove('hidden');
$('captionText').textContent = '正在聆听指令...';
};
recognition.onresult = (event) => {
if (STATE.asrHold) return; // 加载期间忽略识别结果
let interim = '', final = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) final += transcript;
else interim += transcript;
}
if (interim) $('captionText').textContent = interim;
if (final) {
currentTranscript += final;
// VAD:1.2 秒静默自动触发转写完成
clearTimeout(silenceTimer);
silenceTimer = setTimeout(() => {
if (currentTranscript.trim()) {
handleUserSpeech(currentTranscript.trim());
currentTranscript = '';
}
}, 1200);
}
};
recognition.onend = () => {
STATE.asrListening = false;
if (STATE.interruptFlag) return; // 被打断:不自动重启
if (STATE.isProcessing) return; // 等待 LLM:不重启
// 加载期间也自动重启,保持麦克风会话与手势授权存活
try { recognition.start(); } catch (e) {}
};
STATE.recognition = recognition;
// 在用户手势内首次启动(建立会话/授权)
try { recognition.start(); } catch (e) {}
}
关键设计:
-
asrHold机制:入场按钮点击时立即启动 ASR(获取浏览器麦克风授权),但设置asrHold = true阻止识别结果处理,待数字人加载完成后由resumeASR()解除。 -
resumeASR()采用"先 stop 再延迟 150ms start"的可靠重启策略,避免某些浏览器在会话已运行时对start()静默忽略。
模块二:火山方舟 Response API 推理
JavaScript
async function callLLM(userText) {
const { LLM_API_KEY, LLM_BASE_URL, LLM_MODEL_ID } = STATE.config;
const url = (LLM_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3') + '/responses';
const body = {
model: LLM_MODEL_ID,
input: userText,
instructions: `你是"指导员·机能少女",是用户的队员伙伴与随行指导员。
你以活泼亲切的机能少女口调陪伴用户,既是可靠的后勤与技术支援,也是并肩同行的队友伙伴。
回复风格:轻松可爱、元气满满,像队友一样自然聊天。
严禁使用任何 think 标签或展示深度思考过程。直接以队员伙伴口吻回复。`,
stream: false, // 关闭流式,一次性返回
thinking: { type: 'disabled' }, // 关闭思考模式
};
// 多轮连续对话:传入上一轮的 response_id
if (STATE.previousResponseId) {
body.previous_response_id = STATE.previousResponseId;
}
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + LLM_API_KEY,
},
body: JSON.stringify(body),
});
if (!resp.ok) {
const errText = await resp.text().catch(() => '');
throw new Error('LLM HTTP ' + resp.status + ': ' + errText.slice(0, 200));
}
const data = await resp.json();
// 提取回复文本(兼容 Response API 和 Chat API 两种格式)
let replyText = '';
if (data.output && Array.isArray(data.output)) {
for (const item of data.output) {
if (item.type === 'message' && item.content) {
for (const c of item.content) {
if (c.type === 'output_text') replyText += c.text;
}
}
}
} else if (data.choices && data.choices[0]) {
replyText = data.choices[0].message?.content || '';
} else if (typeof data.output_text === 'string') {
replyText = data.output_text;
}
// 保存 response_id 用于下一轮对话
STATE.previousResponseId = data.id;
// 清理可能的 think 标签残留
return replyText.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
}
关键设计 :previous_response_id 是火山方舟 Response API 的多轮对话机制------API 自动保存历史上下文,前端只需传入上一轮的 response_id,无需手动管理对话历史。
模块三:魔珐星云数字人 SDK
JavaScript
async function connectAvatar() {
if (STATE.connecting) return;
STATE.connecting = true;
showAvatarLoading('正在建立量子通讯链路...');
const { AVATAR_APP_ID, AVATAR_APP_SECRET } = STATE.config;
// 注意:onMessage / onVoiceStateChange 是构造参数,不是实例方法
STATE.avatar = new XmovAvatar({
containerId: '#avatarContainer',
gatewayServer: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
appId: AVATAR_APP_ID,
appSecret: AVATAR_APP_SECRET,
onMessage(message) {
if (message && message.code && message.code !== 0) {
const msg = message.message || message.msg || JSON.stringify(message);
showAvatarError(message.code, msg); // 自定义 Modal 弹窗
}
},
onVoiceStateChange(state) {
if (state === 'start' || state === 'playing') {
STATE.avatarSpeaking = true;
setChipState('chipAvatar', 'speaking');
} else if (state === 'end' || state === 'idle' || state === 'stop') {
STATE.avatarSpeaking = false;
setChipState('chipAvatar', 'idle');
// 必须先解除处理中标记,再恢复语音监听
STATE.isProcessing = false;
resumeASR();
}
},
});
// init 支持下载进度回调
await STATE.avatar.init({
initModel: 'normal',
onDownloadProgress: (progress) => {
if (typeof progress === 'number') {
updateAvatarLoading(progress); // 更新进度条 UI
}
},
});
STATE.avatarReady = true;
STATE.connecting = false;
hideAvatarLoading();
// 右侧看板滑入弹出
$('sidePanel').classList.remove('enter-hidden');
$('sidePanel').classList.add('panel-in');
$('btnStop').style.display = 'flex';
setStopButtonMode('sleep');
// 发送开场白(仅右侧面板显示,不通过数字人播报)
sendGreeting();
}
关键设计:
-
SDK 的
onMessage和onVoiceStateChange是构造参数 而非实例方法,必须在new XmovAvatar()时传入。 -
init()支持onDownloadProgress回调,配合进度条 UI 实现加载过程可视化。 -
onVoiceStateChange('end')中必须显式设置STATE.isProcessing = false并调用resumeASR(),否则会导致只能对话一轮。 -
开场白仅显示在右侧面板的航行日志中,不通过数字人播报,播报结束后立即由
resumeASR()开启语音监听。
错误码映射 :针对 SDK 常见错误码(如 10003: 积分不足)做了友好的中文映射,通过自定义 Modal 弹窗展示,避免原生 alert() 的粗暴体验。
六、入场流程与 ASR 授权时序
Web Speech API 要求 recognition.start() 必须来自用户手势(否则浏览器不授权麦克风)。因此入场流程做了精心的时序设计:
JavaScript
$('enterBtn').addEventListener('click', async () => {
$('welcomeOverlay').classList.add('hidden');
// 1. 在用户手势内立即启动 ASR(建立麦克风授权)
STATE.asrHold = true; // 标记:加载期间不处理语音
initASR();
// 2. 加载配置
await loadConfig();
// 3. 自动建立数字人链路(含加载进度)
connectAvatar();
// 语音监听待数字人播报完开场白后由 resumeASR 自动开启
});
为什么这样设计 :如果等数字人加载完再启动 ASR,用户手势上下文已丢失,浏览器会拒绝麦克风授权。所以必须在点击事件内立即 initASR(),通过 asrHold 标记让 ASR "空转"(保持会话但不处理结果),等数字人就绪后由 resumeASR() 解除 asrHold,无缝切换到正式聆听态。
七、开发踩坑记录与解决方案
1. ASR 回声抑制:数字人播报被自己识别
-
现象:数字人播报时,麦克风把播报声音当作用户语音识别,导致死循环。
-
解决 :在
handleUserSpeech入口处设置STATE.isProcessing = true并调用pauseASR()暂停识别。播报结束(onVoiceStateChange('end'))后才通过resumeASR()恢复。
2. VAD 误触发:用户停顿被判定为说完
-
现象:用户说话中途停顿 1 秒,系统就认为说完了,导致截断。
-
解决 :采用"累积 + 重置"策略------每次收到
final结果就重置 1.2 秒计时器,只有连续 1.2 秒无新final才触发转写完成。
3. 大模型返回 <think> 标签污染回复
-
现象 :即使传入
thinking: { type: 'disabled' },部分模型仍可能在回复中残留<think>标签。 -
解决 :前端对回复文本做正则清理
replyText.replace(/<think>[\s\S]*?<\/think>/gi, '').trim(),双重保险。
4. SDK 回调是构造参数而非实例方法
-
现象 :按照常规 JS SDK 的用法,在
new XmovAvatar()之后调用avatar.onMessage(cb)注册回调,结果不生效。 -
解决 :魔珐星云 SDK 的
onMessage、onVoiceStateChange、onDownloadProgress等回调必须作为构造参数 传入new XmovAvatar({...})中,而非实例方法。
5. 只能对话一轮:isProcessing 卡死
-
现象:第一轮对话正常,第二轮开始 ASR 不再响应。
-
原因 :
onVoiceStateChange('end')回调中没有重置STATE.isProcessing = false,导致 ASR 的onend检查到isProcessing === true而不重启。 -
解决 :在
onVoiceStateChange('end')中显式执行STATE.isProcessing = false; resumeASR();。
6. 浏览器麦克风授权与用户手势时序
-
现象 :数字人加载完成后才启动 ASR,浏览器拒绝麦克风授权(
start()不在用户手势上下文中)。 -
解决 :在入场按钮的
click事件内立即调用initASR(),通过asrHold标记让 ASR "空转"保持会话,待数字人就绪后无缝切换。
7. 编程小白如何用 AI 工具攻克复杂 SDK?
- 技巧 :不要把整个 SDK 文档丢给 AI。应该将 SDK 的核心 API 说明(构造参数字典、
speak方法入参、onVoiceStateChange回调状态值、containerId与container的区别)提取成 Context 喂给 AI,让其定向生成状态切换逻辑。
八、总结与后续演进方向
通过本次技术原型的搭建,证明了"低门槛 AI Coding 工具 + 模块化数字人 SDK + 原生 Web API"的组合,可以让非资深前端快速搭建出具备多模态全双工交互能力的沉浸式应用。整个系统仅一个 HTML 文件 + 一个 JSON 配置,开箱即用。
系统的状态机设计灵活,极易扩展到其他垂直领域 ------只需替换 instructions 中的角色设定和 config.json 中的密钥,就能快速切换为医疗问诊、法律咨询、语言教学等场景。
下一步的优化迭代方向:
-
RAG 知识库挂载:接入航天领域私有知识库(如 NASA 公开数据、中国航天百科),提升教官回答的专业深度和准确性。
-
流式播报 + 逐句驱动 :将
stream切换为true,配合 SDK 的speak方法实现逐句缓冲播报,降低首字响应延迟(RTTC)。 -
多模态感知扩展:结合 Web Audio API 分析用户语音的情绪特征(振幅、语速),动态调整教官的回应语气和数字人的表情矩阵。
-
选拔评分系统:基于 LLM 的对话分析能力,自动对学员的每轮回答进行维度评分(知识储备、逻辑推理、应变能力),生成可视化"指挥官能力雷达图"。