「解忧暗室(SereneNook)」------全语音 3D AI 具身交互智能数字人深夜陪伴与情绪自愈舱
7-1数字人演示视频
一、项目背景与痛点
说实话,这个项目的起点不是什么宏大的技术愿景,而是某个凌晨两点半------我躺在床上刷手机,脑子里全是白天开会时被怼的场景翻来覆去地回放,越想越清醒,越清醒越焦虑。
我试过市面上那些所谓的"AI 心理陪伴"产品,要么是打卡式的问卷调查,要么是冷冰冰的模板回复,体验下来感觉像是在跟一个话术机器人对线。更别提那些需要注册、填表、选标签的流程了------凌晨两点谁有那个耐心?
所以我决定自己动手,做一个真正能"接住"深夜情绪的东西。
核心需求很简单:开口就能说,说完就有人回应,回应是带温度的,而且------你可以随时打断它。 不是那种说完一句等半天、系统处理完再回一句的"对讲机模式",而是像跟一个真实的人坐在暗室里面对面聊天,你随时可以插话、追问、或者突然换个话题。
这就引出了三大技术挑战:语音识别要实时且零操作、大模型要快且拒绝"思考废话"、具身交互智能数字人要能被打断且状态无缝切换。
二、核心技术亮点与架构
整个应用是单文件 index.html,不依赖任何构建工具,打开就能跑。架构上分三个模块加一个状态机:
Plaintext
┌──────────────────────────────────────────────────────┐
│ State Machine │
│ idle → listening → thinking → speaking → idle │
│ ↑ interrupt ↙ │
├──────────┬──────────────┬─────────────┬───────────────┤
│ ASR │ LLM │ Avatar │ FloatingLines│
│ Web │ 火山方舟 │ 魔珐星云 │ Three.js │
│ Speech │ Response │ XmovAvatar │ WebGL Shader │
│ API │ API │ SDK │ 光影背景 │
└──────────┴──────────────┴─────────────┴───────────────┘
几个关键的设计决策:
1. 五态状态机
不用 Redux、不用 Zustand,就是一个朴素的字典加一个 transition() 函数:
JavaScript
const SM = {
IDLE: 'idle',
LISTENING: 'listening',
THINKING: 'thinking',
SPEAKING: 'speaking',
INTERRUPTING: 'interrupting'
};
let currentState = SM.IDLE;
function transition(next) {
const prev = currentState;
currentState = next;
updateUI(prev, next); // 同步更新状态点、波形动画、按钮状态
}
别小看这个简单结构------整个应用的时序控制全靠它。每个模块只关心两件事:我现在该切到什么状态?切的时候要做什么清理?
2. Web Speech API 的 VAD 变通方案
浏览器原生的 Web Speech API 没有暴露真正的 VAD(语音活动检测)接口,但 continuous: true + interimResults: true 的组合给了我们一个变通思路:只要 onresult 还在持续触发 interim 结果,就说明用户还在说话;一旦停顿超过阈值,就认为用户说完了。
JavaScript
SILENCE_TIMEOUT: 1500, // 静音 1.5 秒 → 判定说完
r.onresult = (e) => {
for (let i = e.resultIndex; i < e.results.length; i++) {
const t = e.results[i][0].transcript;
if (e.results[i].isFinal) {
finalPart += t;
} else {
interim += t; // 实时中间结果 → 用户还在说
}
}
this.resetSilenceTimer(); // 每次有结果就重置计时器
};
resetSilenceTimer() {
clearTimeout(this.silenceTimer);
this.silenceTimer = setTimeout(() => {
if (this.finalText.trim() || this.lastInterim.trim()) {
this.finalize(); // 静音超时 → 提交文本
}
}, this.SILENCE_TIMEOUT);
}
1.5 秒是反复调出来的经验值------太短会打断正常说话节奏(人说话中间本来就有停顿),太长会让用户觉得系统"没听见"。
3. 火山方舟 Response API + 原生多轮
用火山方舟的 Response API 而不是 Chat API,最大的好处是多轮对话不用自己维护 messages 数组。每次响应返回一个 id,下一轮传入 previous_response_id 就行,上下文由服务端自动管理。
JavaScript
const body = {
model: CONFIG.LLM_MODEL_ID,
input: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: text }
],
stream: false, // 不用流式,等完整回复
thinking: { type: 'disabled' } // 关闭思考,提速
};
if (this.previousResponseId) {
body.previous_response_id = this.previousResponseId;
}
const resp = await fetch(BASE_URL + '/responses', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
这里有个取舍:stream: false 意味着要等模型完整生成后才能播报,首字延迟会比流式高。但对于"解忧暗室"这个场景------用户在深夜倾诉情绪------比起逐字蹦出来的卡顿感,一次性输出一段完整、连贯的回复,体验反而更好。数字人 speak(text, true, true) 一次性喂入,口型和语气的连贯性也更好。
4. FloatingLines:Three.js WebGL 光影背景
暗室不能只是一个黑框里站个数字人------它得有"氛围"。用 Three.js 的 ShaderMaterial 写了一套全屏背景光影效果:三层正弦波叠加,鼠标移动时线条会弯曲跟随,还有视差效果。
Plaintext
// 片段着色器核心:三层波形叠加
float wave(vec2 uv, float offset, vec2 screenUv, vec2 mouseUv, bool shouldBend) {
float time = iTime * animationSpeed;
float amp = sin(offset + time * 0.2) * 0.3;
float y = sin(uv.x + offset + time * 0.1) * amp;
if (shouldBend) {
vec2 d = screenUv - mouseUv;
float influence = exp(-dot(d, d) * bendRadius);
y += (mouseUv.y - screenUv.y) * influence * bendStrength;
}
return 0.0175 / max(abs(uv.y - y) + 0.01, 1e-3);
}
关键是 lightUpChamber() 和 dimChamber() 两个函数------唤醒暗室时,WebGL 光影从透明度 0 渐变到 1(1.6s ease),配合 mix-blend-mode: screen 让光线条从面板毛玻璃背后透出来,营造"暗室亮灯"的感觉。隐没时反过来,先淡出再释放 WebGL 资源:
JavaScript
async function lightUpChamber() {
const THREE = await import('three'); // 动态加载,不用时不占体积
floatingLines = new FloatingLines(THREE, dom.chamberLines, {
enabledWaves: ['top', 'middle', 'bottom'],
lineCount: [3, 4, 5],
linesGradient: ['#38bdf8', '#6366f1', '#a78bfa'], // 冷青→靛蓝→紫
interactive: true,
parallax: true
});
floatingLines.start();
requestAnimationFrame(() => {
dom.chamberLines.classList.add('lit'); // CSS transition: opacity 1.6s
});
}
function dimChamber() {
dom.chamberLines.classList.remove('lit');
// 等淡出结束后再释放 WebGL 资源
setTimeout(() => floatingLines?.stop(), 1600);
}
5. stripStageDirections:过滤动作描写
大模型有时候会在回复里夹带舞台指令,比如"(轻轻放下手中的热茶)"、"【叹了口气】"。这些文字在对话框里显示没问题(增加画面感),但数字人不应该朗读括号里的内容------听起来很怪。
JavaScript
function stripStageDirections(text) {
return String(text)
.replace(/([^()]*)/g, '') // 中文圆括号
.replace(/\([^()]*\)/g, '') // 英文圆括号
.replace(/【[^【】]*】/g, '') // 中文方括号
.replace(/\[[^\[\]]*\]/g, '') // 英文方括号
.replace(/\s{2,}/g, ' ')
.trim();
}
在 Avatar.speak() 里调用:const spoken = stripStageDirections(text),对话框显示原文,数字人只读过滤后的台词。
三、核心功能与交互效果展示
1. 零操作语音对话
页面加载后,点击左下角的「唤醒暗室」初始化数字人,ASR 就自动开始监听了。用户不需要点任何按钮------开口说话就行。系统检测到 1.5 秒静音后自动提交,数字人回复,播报结束后自动回到监听状态,形成闭环。
整个过程中,右侧面板会实时显示:
-
状态指示点(蓝=倾听、紫=思量、绿=诉说)
-
24 条呼吸式频率柱动画(只在倾听态激活)
-
ASR 实时识别的中间文本(灰色斜体)

2. 错误的诗意表达
数字人 SDK 报错时,我拒绝用原生 alert()。做了一套毛玻璃 Toast + Modal 两级提醒:
JavaScript
onMessage: (message) => {
const code = message.code;
const msg = message.message || '';
if (code) {
const title = mapErrorCode(code); // 10003 → "会话异常"
showToast(title, msg, () => {
showModal(title, msg, 'Error Code: ' + code);
});
}
}
Toast 8 秒自动消失,点击可展开 Modal 查看完整错误码。把"您的积分不足"这种冰冷的 SDK 提示,包裹在高级暗色毛玻璃弹窗里------至少不会在凌晨两点把人吓一跳。

3. 隐秘的操作区:单按钮呼吸灯
不再用"唤醒"和"隐没"两个按钮------改成一个按钮两种状态。初始态是呼吸灯动画(chamberBreath,2.4s 周期,box-shadow 从 8px 扩到 20px),吸引用户点击但不刺眼。点击后切换为常亮态(active),文字从"唤醒暗室"变为"隐没暗室":
CSS
.btn-chamber.breathing {
animation: chamberBreath 2.4s ease-in-out infinite;
}
@keyframes chamberBreath {
0%, 100% { box-shadow: 0 0 8px 0 rgba(56,189,248,0.20); }
50% { box-shadow: 0 0 20px 5px rgba(56,189,248,0.6); }
}
交互提示语刻意克制------不说"初始化"、"销毁",而是用"唤醒"和"隐没",贴合暗室的人设。唤醒后数字人就绪,ASR 自动开始监听(ASR.autoStart = true),用户无需任何额外操作。
四、全双工打断的底层控制流实现
这是整个项目最难的部分。
想象一下场景:数字人正在说"你有没有想过,这种焦虑其实来源于......",用户突然插话"等等,我想说另一个事"。如果不能立即打断,用户就得等数字人说完一整段,体验直接崩掉。
核心控流逻辑
JavaScript
// 1. 轮询检测:数字人播报中 + ASR 捕获到新语音 → 立即打断
(function monitorInterrupt() {
setInterval(() => {
if (currentState === SM.SPEAKING &&
Avatar.isSpeaking &&
ASR.active &&
(ASR.lastInterim || ASR.finalText)) {
// 2. 调用 SDK 打断接口
Avatar.interrupt();
// 3. 清空 ASR 缓冲区(避免把刚才的残留文本当成新输入)
ASR.finalText = '';
ASR.lastInterim = '';
// 4. 状态机回到倾听
transition(SM.LISTENING);
busy = false;
}
}, 150);
})();
// Avatar 打断方法
interrupt() {
this.isSpeaking = false;
if (typeof this.sdk.interactiveIdle === 'function') {
this.sdk.interactiveIdle(); // SDK 立即停止播报,回到待机
}
}
完整生命周期
Plaintext
用户开口说话
↓
ASR 持续捕获 interim 结果
↓
静音 1.5s → finalize() 提交文本
↓
handleUserInput() → 如果数字人在说话 → 先 interrupt()
↓
processUserText() → 状态切 THINKING → LLM 推理
↓
收到回复 → addMessage() → stripStageDirections() → Avatar.speak()
↓
onVoiceStateChange('end') → 状态切 IDLE
↓
ASR.autoStart == true? → ASR.start() → 回到 LISTENING
↓
(循环)
其中 handleUserInput 有一个关键的防御逻辑:
JavaScript
function handleUserInput(text) {
if (!text.trim() || busy) return;
if (currentState === SM.SPEAKING && Avatar.isSpeaking) {
Avatar.interrupt();
setTimeout(() => processUserText(text), 200); // 等 SDK 状态稳定
} else {
processUserText(text);
}
}
那个 200ms 的 setTimeout 是踩坑加的------interactiveIdle() 调用后 SDK 内部有状态清理,如果立刻发 speak() 会偶现冲突。
五、开发踩坑记录与解决方案
1. config.json 末尾逗号引发的连锁 Bug
最开始 loadConfig() 怎么都报错"未能读取配置文件",排查了半小时发现是 config.json 最后一行多了个逗号:
Plaintext
- "LLM_MODEL_ID": "ep-xxxx-xxxx",
+ "LLM_MODEL_ID": "ep-xxxx-xxxx"
}
JSON 标准不允许 trailing comma,JSON.parse() 直接抛异常。但 fetch() 的错误信息只说了"HTTP 200"然后静默失败,前端显示的是兜底的"未能读取配置文件",完全没有指向 JSON 格式问题。
教训:在 catch 里把原始错误信息透传出来,别只显示兜底文案。
2. file:// 协议下 fetch 被拦截
本地开发时双击 index.html 打开,fetch('config.json') 直接被浏览器 CORS 策略拦截,连请求都没发出去。加了 XMLHttpRequest 降级方案------XHR 在 file:// 下的状态码是 0 而非 200,需要特殊处理:
JavaScript
try {
const resp = await fetch('config.json');
data = await resp.json();
} catch (fetchErr) {
// 降级为 XHR
data = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.overrideMimeType('application/json');
xhr.open('GET', 'config.json', true);
xhr.onload = () => {
if (xhr.status === 200 || xhr.status === 0) { // 0 = file://
resolve(JSON.parse(xhr.responseText));
}
};
xhr.send();
});
}
3. ASR onend 自动重启的时序问题
Web Speech API 的 onend 在静音超时后也会触发。如果不加状态判断就盲目重启,会导致 THINKING 状态下 ASR 又跑起来,捕获到数字人的声音(回声),造成"自己打断自己"的死循环。
更隐蔽的问题是"隐没暗室"时的残留:用户点了隐没,但 onend 回调还在飞行中,如果没有 manualStop 标志位,ASR 会在暗室关闭后自动重启,幽灵般地继续监听。
JavaScript
r.onend = () => {
this.active = false;
if (this.finalText.trim()) this.finalize();
// 只在 IDLE/LISTENING 状态 且 非手动停止 时才重启
if (!this.manualStop &&
(currentState === SM.LISTENING || currentState === SM.IDLE)) {
setTimeout(() => this.start(), 200);
}
};
// stop() 时置位,start() 时清位
stop() { this.manualStop = true; /* ... */ }
start() { this.manualStop = false; /* ... */ }
4. 大模型回复里夹带"舞台指令"
让系统提示词写"像一位老朋友"之后,大模型偶尔会在回复里加括号动作描写,比如"(端起杯子喝了口水)你说的这个焦虑,我特别理解"。对话框里显示出来挺有画面感的,但数字人真的把"端起杯子喝了口水"念出来就很出戏。
解决方案就是上面提到的 stripStageDirections()------在喂给 speak() 之前过滤掉四种括号,对话框保留原文。一行正则省了一个 prompt 工程的反复调参。
5. 大模型回复格式不统一
火山方舟 Response API 的 output 字段在不同情况下格式不一样------有时是字符串,有时是数组,有时嵌套在 content 里。写了一段防御性解析:
JavaScript
let outputText = '';
if (typeof data.output === 'string') {
outputText = data.output;
} else if (Array.isArray(data.output)) {
outputText = data.output
.filter(item => item.type === 'message' && item.content)
.map(item => {
if (Array.isArray(item.content)) {
return item.content.map(c => c.text || '').join('');
}
return typeof item.content === 'string' ? item.content : '';
})
.join('');
}
六、总结与后续演进方向
这个项目最终沉淀为一个 2200 行的单文件 index.html,打开浏览器就能跑。没有 npm、没有 webpack、没有 node_modules------就是一个 HTML 文件加一个 JSON 配置(Three.js 通过动态 import() 按需加载,不打包)。
做了什么:
-
Web Speech API 连续监听 + 静音 VAD,用户零操作开口即对话
-
火山方舟 Response API 非流式接入,
previous_response_id原生多轮 -
魔珐星云数字人 SDK 全生命周期管理,含优雅错误弹窗
-
全双工打断:150ms 轮询检测 +
interactiveIdle()即时切断 -
Three.js WebGL 光影背景:三层正弦波 + 鼠标弯曲 + 视差,唤醒/隐没动画
-
stripStageDirections括号过滤:对话框显示原文,数字人只读台词 -
ASR 生命周期精细化:
manualStop/autoStart/shutdown()三重控制 -
暗夜极简 UI:毛玻璃、0.5px 边框、呼吸灯按钮、呼吸波形动画
下一步想做的:
-
情绪识别:用 Web Audio API 分析用户语音的振幅和语速,判断焦虑程度,让数字人的回应语气动态调整
-
RAG 记忆:把用户之前的对话摘要存到 localStorage,下次打开暗室时数字人能说"上次你提到的那个项目,后来怎么样了?"
-
多模态输入:支持用户拍照上传(比如办公桌上的一杯咖啡),数字人看图说话,增加陪伴感
-
语音情感 TTS:目前数字人的语音是 SDK 默认的,后续可以接入情感 TTS,让"安慰"的语气真的像在安慰
在无人知晓的暗室里,和自己好好待一会儿。