文章目录


什么是魔珐星云?

魔珐星云是魔珐科技在2025年10月29日正式发布的全球首个面向开发者的具身智能3D数字人开放平台。它不仅仅是一个数字人制作工具,而是一套完整的具身智能基础设施。魔珐星云的核心定位是"语言驱动身体",让大语言模型能够真正"长出身体",拥有自然的表情、手势和肢体语言。与传统内容生产工具不同,魔珐星云输出的是动作参数而非预渲染视频,这些参数可以实时驱动虚拟世界中的3D数字人,也能驱动物理机器人。

为什么选择魔珐星云进行数字人技术选型?

魔珐星云在数字人技术领域脱颖而出,主要得益于以下六大核心优势:

高质量渲染——数字人要"像人"

传统数字人常见的问题包括嘴型不准确、表情僵硬和动作机械。魔珐的解决方案采用52个面部关键点捕捉,不仅能精细控制嘴部,还能独立驱动眉毛、眼睛和脸颊。微表情系统让数字人能够表现出自然的情感——开心时眼角弯起,难过时眉头皱起。全身骨骼驱动技术则赋予数字人完整的肢体语言能力,而不是仅仅会说话的头部。呼吸动画功能在待机时显示自然的胸腔起伏,彻底消除了"静止感",让数字人看起来真正"活"了。

低延迟交互——对话要"跟得上"

这是数字人体验的生命线。根据最新测试数据,魔珐星云将端到端响应延迟压缩到了500毫秒以内(此前宣传的<300ms对部分场景有调整)。与传统方案相比,优化体现在多个环节:ASR语音识别实现流式识别,LLM通过流式输出实现首字响应,文本转语音合成在100毫秒内完成,数字人实时驱动响应时间控制在50毫秒以下。更重要的是,系统完全支持随时打断功能,用户可以在任何时刻改变指令,数字人会立刻停止并响应新的内容,这才是真正的自然对话体验。

高并发支持——轻松应对流量洪峰

采用云原生架构的魔珐星云具备弹性扩容能力,能根据用户数量自动增加或减少资源。系统通过智能负载均衡将请求分配到不同节点,并利用全国多地部署的CDN加速就近接入。这种设计让初创项目无需担心流量突增,实战数据显示系统支持千万级并发能力。

低成本运营——创业公司也用得起

相比传统方案需要5到10万元的3D建模费用、3到5人3个月的开发成本(10到20万人力)以及每月1到3万元的服务器费用,魔珐星云的成本结构完全不同。开发仅需一个前端工程师花一周时间,使用按量计费的模式使小流量项目几乎零成本,云端托管则完全消除了服务器购置成本。对初创项目而言,首月可能仅需投入几百元,真正实现了按需付费。

多终端支持——一次开发,到处运行

魔珐星云提供统一的API接口,支持Web、iOS、Android、Unity和Unreal Engine等多个平台,开发者无需为每个平台单独开发。系统根据设备性能自动调整渲染质量,代码复用率可以超过90%。这意味着在Web上调试完成后,迁移到小程序或App仅需修改少量配置。

信创支持——满足合规需求

魔珐星云专门提供国产化部署方案,支持私有化部署在飞腾、鲲鹏等国产CPU和麒麟、统信等国产操作系统上。平台通过了等保三级和商密认证,让数字人技术不仅服务于互联网应用,也能满足政府、金融和教育等对合规性有严格要求的行业需求。


实战应用:打造虚拟情绪疗愈伙伴「沐木」

现在,让我们进入实操环节。接下来的内容将展示如何利用魔珐星云的完整技术栈,一步步搭建「沐木」这样一个具有情感交互能力的虚拟伙伴,将上述六大优势转化为实际的产品价值。

云端创建数字人

步骤 1:登录魔珐星云官方平台

访问 魔珐星云官网

步骤 2:创建应用

应用配置:

  • 应用类型:具身驱动应用
  • 应用名称:沐木
  • 角色形象:从模板库选一个温柔的年轻女性形象
  • 音色:少女音-温柔系
  • 表演风格:自然/活泼/治愈

步骤 3:调试预览

在调试中可以设置画面分辨率、帧率、KA 自动触发、驱动指令、开场白,点击发送,即可查看效果。

满意后点击保存,系统会生成:

  • App ID(应用标识)
  • App Secret(密钥)
  • Server URL(服务地址)

这三个参数是前端接入的钥匙。到这一步为止,沐木已经在云端创建好了,接下来就是"把她接到你的页面里"。


技术选型与架构设计

技术选型

我选择了 Vue 3 + TypeScript + Vite 的技术栈,原因很简单:

  • Vue 3:组合式 API 适合管理复杂状态
  • TypeScript:提供类型安全
  • Vite:开发体验丝滑

架构分层设计

src/
├── main.ts                 # 应用入口,SDK初始化
├── components/             # UI组件层
│   ├── AvatarRender.vue    # 虚拟人渲染容器
│   └── ConfigPanel.vue     # 配置面板
├── services/               # 业务逻辑层
│   ├── avatar.ts           # 虚拟人连接与控制
│   └── llm.ts              # LLM交互
├── composables/            # 组合式功能
│   └── useAsr.ts           # 语音识别hooks
├── stores/                 # 应用状态
│   └── app.ts              # 集中式状态管理
├── utils/                  # 工具函数
│   ├── sdk-loader.ts       # SDK动态加载
│   └── index.ts            # 通用工具
├── lib/                    # 第三方库封装
│   └── asr.ts              # ASR签名生成
├── constants/              # 配置常量
├── types/                  # 类型定义
└── style.css              # 全局样式

应用启动与 SDK 加载

入口初始化流程

应用启动时面临的第一个挑战是加载三个异构 SDK:CryptoJS(用于腾讯云签名)、WebAudioSpeechRecognizer(语音识别)、XmovAvatar(虚拟人渲染)。这些 SDK 来自不同厂商,加载方式和初始化流程各不相同。

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { initSDKs, checkSDKStatus } from './utils/sdk-loader'

async function initApp() {
  console.log('应用初始化开始...')
  
  // 关键步骤1:初始化所有SDK
  const sdkLoaded = await initSDKs()
  
  if (sdkLoaded) {
    console.log('✓ SDK加载成功')
    checkSDKStatus()
  } else {
    console.error('✗ SDK加载失败,应用可能无法正常工作')
  }
  
  // 关键步骤2:挂载Vue应用
  const app = createApp(App)
  app.mount('#app')
}

initApp().catch(error => {
  console.error('应用初始化异常:', error)
})

这里的初始化采用先 SDK 后应用的策略,确保在 Vue 应用挂载时,所有全局 SDK 已经就绪。

SDK 动态加载与轮询检测

SDK 加载的核心挑战是处理异步加载的不确定性。因为 SDK 来自 CDN,网络状况可能导致加载延迟或失败。我们采用了"加载 + 轮询 + 超时"的组合方案:

// src/utils/sdk-loader.ts
export function checkSDKStatus() {
  const status = {
    cryptoJS: !!window.CryptoJSTest,
    speechRecognizer: !!window.WebAudioSpeechRecognizer,
    xmovAvatar: !!window.XmovAvatar
  }
  
  console.log('SDK加载状态检查:', status)
  return status
}

export function waitForSDK(
  sdkName: keyof ReturnType<typeof checkSDKStatus>, 
  timeout = 10000
): Promise<boolean> {
  return new Promise((resolve) => {
    const startTime = Date.now()
    
    const check = () => {
      const status = checkSDKStatus()
      
      // 检查到SDK已加载
      if (status[sdkName]) {
        resolve(true)
        return
      }
      
      // 检查超时
      if (Date.now() - startTime > timeout) {
        console.error(`${sdkName} SDK加载超时(${timeout}ms)`)
        resolve(false)
        return
      }
      
      // 继续轮询
      setTimeout(check, 100)
    }
    
    check()
  })
}

export function loadSDK(src: string): Promise<void> {
  return new Promise((resolve, reject) => {
    // 避免重复加载
    const existingScript = document.querySelector(`script[src="${src}"]`)
    if (existingScript) {
      resolve()
      return
    }
    
    const script = document.createElement('script')
    script.src = src
    script.onload = () => {
      console.log(`${src} 加载成功`)
      resolve()
    }
    script.onerror = () => {
      console.error(`${src} 加载失败`)
      reject(new Error(`Failed to load script: ${src}`))
    }
    
    document.head.appendChild(script)
  })
}

关键点分析:

  1. 轮询策略:每 100ms 检查一次 SDK 是否挂载到 window 上,相比回调方式更稳定
  2. 超时保护:设置 10 秒超时,防止长时间等待
  3. 错误降级:SDK 加载失败时不中断应用,而是记录日志并继续运行(某些功能会不可用)

第三方 SDK 的兼容性处理

腾讯云 ASR SDK 期望 CryptoJS 挂载在 window.CryptoJS,但某些 CDN 场景可能会有命名冲突。我们采用了命名映射的方案,避免了全局命名空间污染,同时保证了腾讯云 ASR SDK 能找到加密库。

export async function initSDKs() {
  try {
    // 并行加载三个SDK
    await Promise.all([
      loadSDK('/cryptojs.js').catch(() => {
        console.warn('本地cryptojs.js加载失败,尝试CDN')
        return loadSDK(
          'https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js'
        )
      }),
      loadSDK('/speechrecognizer.js'),
      loadSDK(
        'https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar.0.1.0-alpha.63.js'
      )
    ])
    
    // 等待所有SDK初始化完成
    await Promise.all([
      waitForSDK('cryptoJS'),
      waitForSDK('speechRecognizer'),
      waitForSDK('xmovAvatar')
    ])
    
    console.log('✓ 所有SDK加载完成')
    return true
  } catch (error) {
    console.error('SDK加载失败:', error)
    return false
  } finally {
    // 兼容性处理:将CryptoJS映射为CryptoJSTest
    if (!window.CryptoJSTest && window.CryptoJS) {
      window.CryptoJSTest = window.CryptoJS
      console.log('✓ CryptoJS兼容性映射完成')
    }
  }
}

虚拟人连接与渲染

运行效果

运行 pnpm install 安装依赖。

然后 pnpm run dev 启动开发服务器。

访问 http://localhost:5173/ 就能看到一个基础的数字人页面。

接入魔珐 SDK

这是最核心的一步,也是最简单的一步。

在项目里引入魔珐的 SDK 文件,然后初始化:

  • 指定渲染容器(页面上的哪个区域显示数字人)
  • 填入 App ID 和 App Secret
  • 调用连接方法

连接成功后,沐木就出现在页面上了,还会自动播放开场白:“嘿嘿,我来啦~”。此时你已经拥有了一个能说话、能做表情、能动的数字人。

容器管理与生命周期

虚拟人渲染前,需要在 DOM 中预留容器。XmovAvatar SDK 会在该容器内创建 Canvas 元素并渲染数字人。关键是容器 ID 的生成和跟踪。通过时间戳 + 随机字符串组合,确保容器 ID 全局唯一,同时便于调试。

// src/services/avatar.ts
let containerId: string = ''

function generateContainerId(): string {
  const id = `avatar-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
  containerId = id
  return id
}

export function getContainerId(): string {
  return containerId
}

连接流程详解

虚拟人连接是应用中最复杂的异步操作。它涉及 SDK 初始化、网络通信、资源下载、渲染启动等多个阶段:

// src/services/avatar.ts
async connect(config: AvatarConfig, callbacks: AvatarCallbacks): Promise<any> {
  const { appId, appSecret } = config
  const { onSubtitleOn, onSubtitleOff, onStateChange } = callbacks

  // 第一步:构造网关URL
  // 网关地址包含了服务端场景配置,决定了数字人是否有背景、是否有特定动作等
  const url = new URL(SDK_CONFIG.GATEWAY_URL)
  url.searchParams.append('data_source', SDK_CONFIG.DATA_SOURCE)
  url.searchParams.append('custom_id', SDK_CONFIG.CUSTOM_ID)

  // 第二步:创建Promise,用于追踪连接状态
  let resolve: (value: boolean) => void
  let reject: (reason?: any) => void
  const connectPromise = new Promise<boolean>((res, rej) => {
    resolve = res
    reject = rej
  })

  // 第三步:配置SDK选项
  // 这些选项是XmovAvatar SDK的核心配置
  const constructorOptions = {
    // 渲染容器
    containerId: `#${this.containerId}`,
    
    // 认证信息
    appId,
    appSecret,
    enableDebugger: false,
    gatewayServer: url.toString(),
    
    // 事件回调
    onWidgetEvent: (event: any) => {
      console.log('【SDK事件】', event.type, event)
      
      // 字幕事件:数字人说话时触发
      if (event.type === 'subtitle_on') {
        onSubtitleOn(event.text)
      } else if (event.type === 'subtitle_off') {
        onSubtitleOff()
      }
    },
    
    // 状态变化回调
    // 例如:'idle' -> 'speaking' -> 'thinking' -> 'idle'
    onStateChange,
    
    // 错误回调
    onMessage: async (error: any) => {
      const state = await getPromiseState(connectPromise)
      const plainError = new Error(error.message)
      
      // 只在连接未完成时才抛错
      if (state === 'pending') {
        reject(plainError)
      }
    }
  }

  // 第四步:实例化SDK
  const avatar = new window.XmovAvatar(constructorOptions)
  
  // 第五步:等待SDK初始化
  // 初始化涉及下载资源文件(模型、动作、音频等),可能需要几秒
  // 为了避免promise race condition,我们设置一个合理的初始化超时
  await new Promise(resolve => {
    setTimeout(resolve, APP_CONFIG.AVATAR_INIT_TIMEOUT)
  })

  // 第六步:调用init方法,开始下载资源
  await avatar.init({
    onDownloadProgress: (progress: number) => {
      console.log(`资源下载进度: ${progress}%`)
      
      // 资源下载完成,连接可以建立
      if (progress >= 100) {
        resolve(true)
      }
    },
    
    onClose: () => {
      onStateChange('')
      console.log('【连接关闭】SDK连接已断开')
    }
  })

  // 第七步:等待连接完成
  // 使用allSettled而不是all,因为我们要设置超时
  const [result] = await Promise.allSettled([
    connectPromise,
    new Promise(resolve => setTimeout(resolve, 1000))
  ])

  // 连接失败时抛错
  if (result.status === 'rejected') {
    console.error('【连接失败】', result.reason)
    throw result.reason
  }

  // 第八步:视频自动播放处理
  // Chrome等浏览器默认禁止自动播放,需要用户交互触发
  try {
    const containerSelector = `#${this.containerId}`
    const videoEl = document.querySelector(
      `${containerSelector} video`
    ) as HTMLVideoElement | null
    
    if (videoEl) {
      videoEl.muted = true
      await videoEl.play().catch(() => {})
      
      // 如果播放失败,绑定点击事件作为后备方案
      if (videoEl.paused) {
        const container = document.querySelector(containerSelector)
        container?.addEventListener(
          'click',
          () => {
            videoEl.play().catch(() => {})
          },
          { once: true }
        )
      }
    }
  } catch {}

  return avatar
}

连接流程中的关键点:

  1. Promise 追踪:通过 connectPromise 追踪连接状态,避免多次回调冲突
  2. 资源下载:数字人的模型、动作库、音色等资源都需要下载,进度回调让我们能实时展示加载状态
  3. 自动播放处理:现代浏览器限制了无声视频的自动播放,我们通过 muted 属性和点击事件来绕过这个限制
  4. 错误隔离:在连接完成后发生的错误不会中断应用,而只是记录日志

渲染容器与 UI 叠加

虚拟人渲染容器和 UI 元素的关系如下:

<!-- src/components/AvatarRender.vue -->
<template>
  <div class="avatar-container">
    <!-- 虚拟人Canvas容器,由SDK自动创建Canvas -->
    <div :id="containerId" class="avatar-render-area"></div>
    
    <!-- 字幕UI,覆盖在虚拟人上方 -->
    <div class="avatar-subtitle" v-if="appState.ui.subTitleText">
      {{ appState.ui.subTitleText }}
    </div>
    
    <!-- 连接中的遮罩层 -->
    <div v-if="!appState.avatar.connected" class="avatar-mask">
      <div class="mask-content">
        <div class="spinner"></div>
        <p>正在连接虚拟人...</p>
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
import { inject, computed } from 'vue'
import { avatarService } from '../services/avatar'
import type { AppState } from '../types'

const appState = inject<AppState>('appState')!
const containerId = computed(() => avatarService.getContainerId())
</script>
<style scoped>
.avatar-container {
  position: relative;
  width: 100%;
  height: 600px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  border-radius: 12px;
  overflow: hidden;
}

.avatar-render-area {
  width: 100%;
  height: 100%;
}

/* 字幕UI绝对定位,显示虚拟人的语音内容 */
.avatar-subtitle {
  position: absolute;
  bottom: 30px;
  left: 50%;
  transform: translateX(-50%);
  background: rgba(0, 0, 0, 0.7);
  color: white;
  padding: 12px 24px;
  border-radius: 24px;
  max-width: 80%;
  text-align: center;
  font-size: 14px;
  line-height: 1.5;
  z-index: 10;
}

/* 连接中的遮罩层 */
.avatar-mask {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.5);
  z-index: 100;
}

.mask-content {
  text-align: center;
  color: white;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid rgba(255, 255, 255, 0.3);
  border-top-color: white;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto 16px;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}
</style>

虚拟人的渲染采用了分层设计:底层是 Canvas 元素,中层是字幕气泡,上层是连接状态遮罩。这样的设计既能展示虚拟人的实时动画,又能清晰显示交互反馈。

接入语音识别(ASR)

腾讯云 ASR 的工作原理

要让沐木"听懂"用户说的话,需要集成语音识别服务。我选择了腾讯云 ASR

腾讯云 WebAudioSpeechRecognizer 是一个浏览器语音识别 SDK,它的核心特点是流式识别——边说边转写,而不是等用户说完再识别。这对于实时对话应用至关重要。

工作流程如下:

用户说话 → 浏览器捕获音频 → 实时上传到腾讯云
           ↓
      腾讯云处理音频流 → 边处理边返回识别结果 → 浏览器展示中间结果
           ↓
    用户停止说话 → 腾讯云返回最终结果

在腾讯云控制台创建 ASR 应用

访问腾讯云控制台创建 ASR 应用,获取密钥,然后在项目里集成 WebSocket 实时识别:

  • 用户点击"语音输入"按钮
  • 浏览器请求麦克风权限
  • 开始录音,实时传输音频流给 ASR
  • ASR 边听边转写,文字实时显示在输入框
  • 用户松开按钮,完整文本发送给后台

关键点:要用 WebSocket 流式识别,不要用录完再上传的方式,后者延迟太高。

点击新建密钥,获取 ASR App IDASR Secret IDASR Secret Key

ASR 的组合式封装

我们将 ASR 的初始化、事件监听、生命周期管理封装成一个 Vue composable,这样任何组件都能轻松使用:

// src/composables/useAsr.ts
import { ref } from 'vue'
import type { AsrConfig, AsrCallbacks } from '../types'
import { ASR_CONFIG } from '../constants'
import { signCallback } from '../lib/asr'

export function useAsr(config: AsrConfig) {
  const asrText = ref('')          // 当前识别的文本
  const isListening = ref(false)   // 是否正在监听
  let webAudioSpeechRecognizer: any = null

  // 根据配置构建ASR参数
  const buildAsrConfig = (vadSilenceTime?: number) => ({
    // 签名回调:腾讯云要求请求签名,用于权限验证
    signCallback: signCallback.bind(null, config.secretKey),
    
    // 认证信息
    appid: config.appId,
    secretid: config.secretId,
    secretkey: config.secretKey,
    
    // 识别引擎配置
    engine_model_type: ASR_CONFIG.ENGINE_MODEL_TYPE,  // 16k通用版本
    voice_format: ASR_CONFIG.VOICE_FORMAT,            // PCM格式
    
    // 文本过滤配置
    filter_dirty: ASR_CONFIG.FILTER_DIRTY,           // 过滤脏话
    filter_modal: ASR_CONFIG.FILTER_MODAL,           // 过滤语气词
    filter_punc: ASR_CONFIG.FILTER_PUNC,             // 过滤标点符号
    convert_num_mode: ASR_CONFIG.CONVERT_NUM_MODE,   // 数字转写
    word_info: ASR_CONFIG.WORD_INFO,                 // 词信息输出
    
    // VAD(Voice Activity Detection)配置
    // VAD用于检测语音活动,自动判断用户是否说完
    needvad: ASR_CONFIG.NEEDVAD,
    vad_silence_time: vadSilenceTime || config.vadSilenceTime || 300  // 300ms无声则认为说完
  })

  // 开始识别
  const start = (callbacks: AsrCallbacks, vadSilenceTime?: number) => {
    if (isListening.value) {
      console.warn('语音识别已在进行中')
      return
    }

    // 检查SDK是否加载
    if (!window.WebAudioSpeechRecognizer) {
      console.error('WebAudioSpeechRecognizer 未加载')
      callbacks.onError('WebAudioSpeechRecognizer 未加载')
      return
    }

    // 检查配置完整性
    if (!config.appId || !config.secretId || !config.secretKey) {
      console.error('ASR配置不完整')
      callbacks.onError('ASR配置不完整,请检查App ID、Secret ID和Secret Key')
      return
    }

    const asrConfig = buildAsrConfig(vadSilenceTime)
    console.log('【ASR配置】', asrConfig)
    
    try {
      // 创建识别器实例
      webAudioSpeechRecognizer = new window.WebAudioSpeechRecognizer(asrConfig)
      
      // 绑定事件监听
      setupEventListeners(callbacks)
      
      // 开始录音并识别
      webAudioSpeechRecognizer.start()
      isListening.value = true
      console.log('【ASR】开始语音识别')
    } catch (error) {
      console.error('创建WebAudioSpeechRecognizer失败:', error)
      callbacks.onError(error)
    }
  }

  // 停止识别
  const stop = () => {
    if (webAudioSpeechRecognizer) {
      webAudioSpeechRecognizer.stop()
      webAudioSpeechRecognizer = null
    }
    isListening.value = false
    console.log('【ASR】停止语音识别')
  }

  // 事件监听器设置
  const setupEventListeners = (callbacks: AsrCallbacks) => {
    // 识别开始事件
    webAudioSpeechRecognizer.OnRecognitionStart = (res: any) => {
      console.log('【ASR事件】识别开始:', res)
    }

    // 句子开始事件(VAD检测到语音)
    webAudioSpeechRecognizer.OnSentenceBegin = (res: any) => {
      console.log('【ASR事件】句子开始:', res)
      asrText.value = ''  // 清空之前的识别结果
    }

    // 识别结果变化事件(实时返回中间结果)
    webAudioSpeechRecognizer.OnRecognitionResultChange = (res: any) => {
      const currentText = res.result?.voice_text_str
      if (currentText) {
        asrText.value = currentText
        console.log('【ASR事件】识别中:', currentText)
      }
    }

    // 句子结束事件(VAD检测到用户停止说话)
    webAudioSpeechRecognizer.OnSentenceEnd = (res: any) => {
      const resultText = res.result?.voice_text_str
      console.log('【ASR事件】句子结束:', resultText)
      
      if (resultText) {
        asrText.value = resultText
        // 将识别结果传回给调用者
        callbacks.onFinished(resultText)
      }
    }

    // 识别完成事件
    webAudioSpeechRecognizer.OnRecognitionComplete = (res: any) => {
      console.log('【ASR事件】识别完成:', res)
      isListening.value = false
    }

    // 错误事件
    webAudioSpeechRecognizer.OnError = (res: any) => {
      console.error('【ASR错误】', res)
      callbacks.onError(res)
      isListening.value = false
    }
  }

  return {
    asrText,
    isListening,
    start,
    stop
  }
}

腾讯云签名的生成

腾讯云 API 要求使用 HMAC-SHA1 算法对请求进行签名,以验证请求的合法性。这需要用到 CryptoJS 库。HMAC-SHA1 是一种基于密钥的哈希算法。腾讯云会在服务端使用相同的密钥验证签名,确保请求来自合法的应用。

// src/lib/asr.ts
/**
 * 将CryptoJS的WordArray转换为Uint8Array
 * CryptoJS内部使用WordArray格式存储数据,需要转换为浏览器原生的Uint8Array
 */
function toUint8Array(wordArray: any) {  
  const words = wordArray.words       // 32位整数数组
  const sigBytes = wordArray.sigBytes // 有效字节数
  const u8 = new Uint8Array(sigBytes)
  
  for (let i = 0; i < sigBytes; i++) {
    // 每个32位整数包含4个字节,按大端序提取
    u8[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff
  }
  return u8
}

/**
 * 将Uint8Array转换为字符串
 * 用于后续Base64编码
 */
function Uint8ArrayToString(fileData: Uint8Array) {
  let dataString = ''
  for (let i = 0; i < fileData.length; i++) {
    dataString += String.fromCharCode(fileData[i])
  }
  return dataString
}

/**
 * 生成腾讯云ASR请求签名
 * @param secretKey 密钥
 * @param signStr 待签名字符串
 * @returns Base64编码的签名
 */
export function signCallback(secretKey: string, signStr: string) {
  if (!window.CryptoJSTest) {
    console.error('CryptoJS 未加载')
    throw new Error('CryptoJS 未加载')
  }
  
  try {
    // 使用HMAC-SHA1算法生成签名
    const hash = window.CryptoJSTest.HmacSHA1(signStr, secretKey)
    
    // 转换为字节数组
    const bytes = Uint8ArrayToString(toUint8Array(hash))
    
    // Base64编码
    return window.btoa(bytes)
  } catch (error) {
    console.error('签名生成失败:', error)
    throw error
  }
}

组件中的 ASR 集成

在配置面板中,我们实际调用 ASR 的 start 方法:

// src/components/ConfigPanel.vue
import { useAsr } from '../composables/useAsr'

const asrConfig = computed(() => ({
  provider: 'tx' as const,
  appId: appState.asr.appId,
  secretId: appState.asr.secretId,
  secretKey: appState.asr.secretKey
}))

function handleVoiceInput() {
  // 如果正在监听,则停止
  if (appState.asr.isListening) {
    stopAsr()
    appStore.stopVoiceInput()
    return
  }

  // 检查ASR配置
  const { appId, secretId, secretKey } = appState.asr
  if (!appId || !secretId || !secretKey) {
    alert('请先配置ASR信息')
    return
  }

  // 创建ASR实例并开始识别
  const { start: startAsrWithConfig } = useAsr(asrConfig.value)
  
  appStore.startVoiceInput({
    onFinished: (text: string) => {
      appState.ui.text = text  // 将识别结果填入输入框
      appStore.stopVoiceInput()
    },
    onError: (error: any) => {
      console.error('语音识别错误:', error)
      appStore.stopVoiceInput()
    }
  })

  startAsrWithConfig({
    onFinished: (text: string) => {
      appState.ui.text = text
      appStore.stopVoiceInput()
    },
    onError: (error: any) => {
      console.error('语音识别错误:', error)
      appStore.stopVoiceInput()
    }
  })
}

接入大语言模型(LLM)

火山引擎豆包配置

数字人的"大脑"来自大语言模型。我用的是火山引擎方舟平台的豆包模型。

访问火山引擎方舟平台,登录后,点击 API 接入

创建 API Key 后,点击 开通模型

访问 API Key测试网站,查看是否成功开通,点击授权刚创建的 API Key。

点击发起调试,调试结果为 200 则连接成功。

LLM 服务的设计

虚拟人的"大脑"来自大语言模型。我们选择了 OpenAI 兼容接口,这样既能使用 OpenAI 的 GPT 系列,也能使用火山引擎豆包等兼容实现。相比传统的一次性 API,流式 API 能逐字返回结果。这让我们能在 LLM 还在思考时就开始播放虚拟人的语音,大幅降低了用户感知的延迟。

// src/services/llm.ts
import type { LLMConfig } from '../types'
import { LLM_CONFIG } from '../constants'

class LLMService {
  private client: any = null

  /**
   * 惰性创建OpenAI客户端
   * 只在第一次调用LLM方法时才创建,避免不必要的初始化
   */
  private getClient(config: LLMConfig) {
    if (!this.client) {
      const { OpenAI } = window
      if (!OpenAI) {
        throw new Error('OpenAI SDK未加载')
      }

      this.client = new OpenAI({
        apiKey: config.apiKey,
        // 豆包方舟默认接入点
        baseURL: 'https://ark.cn-beijing.volces.com/api/v3',
        dangerouslyAllowBrowser: true
      })
    }
    return this.client
  }

  /**
   * 发送单次消息并获取完整回复
   * 适用于简单的问答场景
   */
  async sendMessage(config: LLMConfig, userMessage: string): Promise<string> {
    try {
      const client = this.getClient(config)
      const response = await client.chat.completions.create({
        model: config.model,
        messages: [
          {
            role: 'system',
            content: LLM_CONFIG.SYSTEM_PROMPT
          },
          {
            role: 'user',
            content: userMessage
          }
        ],
        temperature: 0.7,
        top_p: 0.95,
        max_tokens: 1000
      })

      return response.choices[0].message.content
    } catch (error) {
      console.error('LLM请求失败:', error)
      throw error
    }
  }

  /**
   * 发送消息并获取流式回复
   * 返回异步迭代器,可以逐块获取响应
   * 这对于实时对话和显示实时动画至关重要
   */
  async sendMessageWithStream(
    config: LLMConfig,
    userMessage: string
  ): Promise<AsyncIterable<string>> {
    try {
      const client = this.getClient(config)
      const stream = await client.chat.completions.create({
        model: config.model,
        messages: [
          {
            role: 'system',
            content: LLM_CONFIG.SYSTEM_PROMPT
          },
          {
            role: 'user',
            content: userMessage
          }
        ],
        temperature: 0.7,
        top_p: 0.95,
        max_tokens: 1000,
        stream: true  // 启用流式模式
      })

      // 将SDK的流转换为自定义的异步迭代器
      return (async function* () {
        for await (const chunk of stream) {
          const content = chunk.choices[0]?.delta?.content || ''
          if (content) {
            yield content
          }
        }
      })()
    } catch (error) {
      console.error('LLM流式请求失败:', error)
      throw error
    }
  }
}

export const llmService = new LLMService()

打通完整链路

分句算法的设计

虚拟人播报文本时需要分句,否则会一次性播放很长的文本,显得生硬。分句算法需要遵循以下原则:

// src/stores/app.ts - 分句算法
const MIN_SPLIT_LENGTH = 2      // 最少2个字符才分句
const MAX_SPLIT_LENGTH = 20     // 最多20个字符

function splitSentence(text: string): string[] {
  if (!text) return []

  const chinesePunctuations = new Set(['、', ',', ':', ';', '。', '?', '!', '…', '\n'])
  const englishPunctuations = new Set([',', ':', ';', '.', '?', '!'])

  let count = 0                    // 字符计数
  let firstValidPunctAfterMin = -1 // 第一个有效标点位置
  let forceBreakIndex = -1         // 强制分句位置

  let i = 0
  const n = text.length

  while (i < n && count < MAX_SPLIT_LENGTH) {
    const char = text[i]

    // 中文字符或数字,计数+1
    if ((char >= '\u4e00' && char <= '\u9fff') || (char >= '0' && char <= '9')) {
      count++
      if (count === MAX_SPLIT_LENGTH) {
        forceBreakIndex = i + 1
      }
      i++
    }
    // 英文单词
    else if ((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')) {
      i++
      // 跳过整个单词
      while (
        i < n &&
        ((text[i] >= 'a' && text[i] <= 'z') || (text[i] >= 'A' && text[i] <= 'Z'))
      ) {
        i++
      }
      count++
      if (count === MAX_SPLIT_LENGTH) {
        forceBreakIndex = i
      }
    }
    // 标点符号
    else {
      // 中文标点
      if (chinesePunctuations.has(char)) {
        // 在达到最小长度后记录第一个标点位置
        if (count >= MIN_SPLIT_LENGTH && firstValidPunctAfterMin === -1) {
          firstValidPunctAfterMin = i
        }
        i++
      }
      // 英文标点(需要后跟空格才视为句尾)
      else if (englishPunctuations.has(char)) {
        if (i + 1 >= n || text[i + 1] === ' ') {
          if (count >= MIN_SPLIT_LENGTH && firstValidPunctAfterMin === -1) {
            firstValidPunctAfterMin = i
          }
        }
        i++
      } else {
        i++
      }
    }
  }

  // 决定分句位置
  let splitIndex = -1
  if (firstValidPunctAfterMin !== -1) {
    // 优先在标点位置分句
    splitIndex = firstValidPunctAfterMin + 1
  } else if (forceBreakIndex !== -1) {
    // 字符超长时强制分句
    splitIndex = forceBreakIndex
  }

  // 分句
  if (splitIndex > 0 && splitIndex < text.length) {
    return [text.substring(0, splitIndex), text.substring(splitIndex)]
  }
  
  return [text]
}

算法的核心逻辑:

  • 优先级 1:在中文标点处分句(满足最小长度的前提下)
  • 优先级 2:在英文标点处分句(标点后需有空格)
  • 优先级 3:超过最大长度时,在字符/单词边界强制分句

这样既能保证句子的完整性,又能避免单次播放过长的文本。

SSML 文本生成

SSML(Speech Synthesis Markup Language)是用于控制语音合成的标记语言。我们通过 SSML 可以控制虚拟人的语速、音调、音量等:

// src/utils/index.ts
export function generateSSML(
  text: string,
  pitch?: number,
  speed?: number,
  volume?: number
): string {
  // 组装SSML属性
  let attributes = ''
  if (pitch !== undefined) {
    attributes += ` pitch="${pitch}%"`
  }
  if (speed !== undefined) {
    attributes += ` rate="${speed}%"`
  }
  if (volume !== undefined) {
    attributes += ` volume="${volume}%"`
  }

  return `<speak>${text}<prosody${attributes}>${text}</prosody></speak>`
}

完整的消息处理流程

最后,我们将 ASR、LLM、虚拟人播报串联起来,形成完整的对话流程:

// src/stores/app.ts
export class AppStore {
  /**
   * 发送消息并让虚拟人播报
   * 这是应用的核心业务流程
   */
  async sendMessage(): Promise<string | undefined> {
    const { llm, ui, avatar } = appState

    // 1. 参数校验
    if (!validateConfig(llm, ['apiKey']) || !ui.text || !avatar.instance) {
      return
    }

    try {
      // 2. 等待虚拟人准备好
      await this.waitForAvatarReady()

      // 3. 调用LLM获取流式回复
      const stream = await llmService.sendMessageWithStream(
        {
          provider: 'openai',
          model: llm.model,
          apiKey: llm.apiKey
        },
        ui.text
      )

      if (!stream) return

      // 4. 缓冲区和播报控制
      let buffer = ''                // 累积文本缓冲区
      let isFirstChunk = true        // 是否第一句

      for await (const chunk of stream) {
        buffer += chunk
        console.log('【LLM输出】', chunk)

        // 5. 尝试分句
        const arr = splitSentence(buffer)

        // 分句成功,第一句可以播报
        if (arr.length > 1) {
          const ssml = generateSSML(arr[0] || '')
          console.log('【播报】', arr[0])

          // 使用SDK的speak方法播报
          // 参数解释:
          // - ssml: 待播报的SSML文本
          // - true/false: 是否立即开始播报(打断之前的语音)
          // - false: 是否最后一句
          if (isFirstChunk) {
            avatar.instance.speak(ssml, true, false)
            isFirstChunk = false
          } else {
            avatar.instance.speak(ssml, false, false)
          }

          // 6. 保存剩余文本到缓冲区
          buffer = arr[1] || ''
        }
      }

      // 7. 处理剩余的文本
      if (buffer.length > 0) {
        const ssml = generateSSML(buffer)
        if (isFirstChunk) {
          avatar.instance.speak(ssml, true, false)
        } else {
          avatar.instance.speak(ssml, false, false)
        }
      }

      // 8. 发送结束信号
      const finalSsml = generateSSML('')
      avatar.instance.speak(finalSsml, false, true)

      return buffer
    } catch (error) {
      console.error('发送消息失败:', error)
      throw error
    }
  }

  /**
   * 等待虚拟人准备好
   * 如果虚拟人正在说话,则打断并等待
   */
  private async waitForAvatarReady(): Promise<void> {
    if (avatarState.value === 'speak') {
      console.log('【控制】虚拟人正在说话,发送打断信号')
      appState.avatar.instance.think()
      await delay(APP_CONFIG.SPEAK_INTERRUPT_DELAY)
    }
  }

  /**
   * 启动语音输入
   */
  startVoiceInput(callbacks: {
    onFinished: (text: string) => void
    onError: (error: any) => void
  }): void {
    appState.asr.isListening = true
    console.log('【语音输入】已启动')
  }

  /**
   * 停止语音输入
   */
  stopVoiceInput(): void {
    appState.asr.isListening = false
    console.log('【语音输入】已停止')
  }
}

export const appStore = new AppStore()

根据回复内容触发对应的表情动作

整个流程跑通后,你会发现:从用户说话到沐木回应,整个过程行云流水,没有明显的等待感。

最后把所有模块串起来:

用户文字输入/语音输入 
  → ASR 转文字 
  → 发送给 LLM 
  → LLM 生成回复 
  → 用 SSML 包装(控制语速、音调) 
  → 发送给魔珐 SDK 
  → 沐木开口说话

同时根据回复内容触发对应的表情动作:

  • 如果回复里有"开心"、“哈哈”,触发开心表情
  • 如果有"抱抱"、“没关系”,触发安慰动作
  • 默认是温柔的倾听表情

这就是魔珐星云的魔力——技术细节被 SDK 优雅地封装了,开发者只需要关心业务逻辑和用户体验

常量与配置管理

为了便于后续扩展和调整,我们将所有硬编码的值集中在常量文件中:

// src/constants/index.ts

// 应用级常量
export const APP_CONFIG = {
  AVATAR_CONTAINER_PREFIX: 'avatar-',
  AVATAR_INIT_TIMEOUT: 3000,
  SPEAK_INTERRUPT_DELAY: 500
}

// LLM配置
export const LLM_CONFIG = {
  DEFAULT_MODEL: 'doubao-pro-32k',
  SYSTEM_PROMPT: `你是沐木,一个温柔、富有同情心的虚拟情绪疗愈伙伴。你的目标是:
  1. 认真倾听用户的情感诉求
  2. 给予温暖和理解
  3. 提供建设性的建议
  4. 用温柔、自然的语言交流
  回复应该简洁(200字以内),避免生硬和冗长的建议。`,
  SUPPORTED_MODELS: [
    'doubao-pro-32k',
    'gpt-4o',
    'gpt-4-turbo',
    'gpt-3.5-turbo'
  ]
}

// ASR配置
export const ASR_CONFIG = {
  ENGINE_MODEL_TYPE: '16k_zh',
  VOICE_FORMAT: 'wav',
  FILTER_DIRTY: 1,
  FILTER_MODAL: 0,
  FILTER_PUNC: 0,
  CONVERT_NUM_MODE: 1,
  WORD_INFO: 0,
  NEEDVAD: 1
}

// SDK网关配置(需要根据实际账号填写)
export const SDK_CONFIG = {
  GATEWAY_URL: 'https://your-gateway-url.com',
  DATA_SOURCE: 'scene_template_0001',
  CUSTOM_ID: 'your_custom_id'
}

遇到的坑与解决方案

坑 1:浏览器禁止自动播放

现象:数字人出现了,但没有声音。

原因:Chrome 等浏览器默认禁止网页自动播放音频(防止广告骚扰)。

解决:在用户第一次点击页面时,触发音频上下文恢复。或者在连接前加个"点击开始"的引导页。

坑 2:背景显示不出来

现象:数字人渲染正常,但背景是黑色。

排查步骤:

1. 打开 DevTools 查看 Network,确认背景资源是否加载
const id = document.querySelector('.sdk-container').id; 
const el = document.querySelector('#' + id);
console.log(el.querySelector('canvas'))

2. 用官方测试页验证账号是否开通了背景权限

打开你仓库内的 TTSA 测试页 src/stores/sdk-test.html(直接在资源管理器里双击打开即可)。在页面右侧填写:

  • server_url(你的服务地址)
  • username / app_id / app_secret
  • tag(场景标签)

点击"连接房间 connect"。若该 tag 场景具备背景,测试页会显示带背景的画面。这一步用于确认账号与场景能力。

3. 检查场景配置的 dataSource 是否指向正确的场景
  • 如果测试页能显示背景:说明你的账号具备背景能力。接下来需要确认"主应用的网关参数与场景配置"一致,即当前 data_source/custom_id 指向的流是否就是你在 Demo 页用到的有背景的场景。通常需要服务侧把 data_source/custom_id 映射到带背景的房间/模板。
  • 如果测试页也没有背景或 Network 显示失败:需要确认你的账号是否启用了背景资源和对应场景(tag),或修正网络/跨域问题。只有服务端返回包含背景的流,客户端 canvas 才能渲染背景。

运行与构建

开发环境

# 安装依赖
pnpm install

# 启动开发服务器
pnpm run dev

# 访问本地应用
# http://localhost:5173/

生产构建

# 类型检查 + 打包
pnpm run build

# 预览生产构建结果
pnpm run preview

核心实现要点总结

模块 关键技术 核心文件
SDK加载 动态Script注入 + 轮询检测 sdk-loader.ts
虚拟人渲染 Canvas + XmovAvatar SDK avatar.ts, AvatarRender.vue
语音识别 WebSocket流式ASR + 签名 useAsr.ts, asr.ts
LLM交互 OpenAI兼容API + 流式输出 llm.ts
业务编排 分句算法 + SSML包装 app.ts
状态管理 Pinia + Vue 3 Reactivity app.ts

写在最后

从第一行代码到沐木真正"活"起来,我最大的感受是:具身智能时代已经到来,而魔珐星云正在让它变得触手可及

这就是技术进步的意义——把曾经的"黑科技"变成人人可用的工具,让更多创新成为可能。如果你也想做一个有温度的数字人产品,不妨试试魔珐星云。它不只是一个 SDK,更是通往下一代交互的入场券。

快点击官方网站进行体验吧:https://xingyun3d.com/?utm_campaign=daily&utm_source=jixinghuiKoc11

此代码已上传至gitee仓库:https://gitee.com/yanyanhy/mumu

💡 技术的终极意义,是让世界变得更温暖。
当大模型有了身体,AI 就不再是冷冰冰的工具,而是能陪伴、能理解、能共情的伙伴。

在这里插入图片描述

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐