「解忧暗室(SereneNook)」——全语音_3D_AI_具身交互智能数字人深夜陪伴与情绪自愈舱[1]

「解忧暗室(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,让"安慰"的语气真的像在安慰

在无人知晓的暗室里,和自己好好待一会儿。

相关推荐
zhangfeng11331 小时前
CVOCA 卷积模型,《Nature》子刊 特征提取技术突破性研究的综合分析报告
人工智能
炎火星1 小时前
直播切片素材杂乱,怎么用 AI 剪辑做成可用带货视频?
人工智能
像风一样的男人@1 小时前
python --fastapi推流AI推理
人工智能·python·fastapi
行走的小派2 小时前
从个人开发到企业部署:OPi AI Station四大应用场景与选型指南
人工智能·边缘计算·香橙派·边缘ai
天云数据2 小时前
从“雇佣一个人”到“组建一个团队”:AI生产力的组织方式被重写
人工智能
2601_956319882 小时前
最新量化开发卡住,先查规则和流程是否完整
人工智能·python
weixin_6682 小时前
Cursor-superpowers插件用法
数据库·人工智能
adinnet20262 小时前
深度拆解企业级 Agent 架构:LangGraph + 知识图谱 + 向量检索的协同设计
人工智能·架构·知识图谱
武子康2 小时前
Shippy:确定性工具、会话级 Sandbox 与 Live-Data Eval(4 类收敛 + 7 步实现方案 + 6 类评测指标)
前端·人工智能·后端
糖果店的幽灵2 小时前
langgraph分支之 - 动态分支(Dynamic Branch)
java·前端·javascript·人工智能·langgraph