@financial-freedom/i-spark-ai-digital
v0.2.5
Published
VZai 数字人组件:Vue 3 语音对话组件(ASR/TTS + S0–S4 状态机),自包含样式、零第三方 UI 依赖。
Maintainers
Readme
@financial-freedom/i-spark-ai-digital
VZai 数字人组件 —— Vue 3 语音对话组件,内置 ASR/TTS 驱动的 S0–S4 状态机,形象姿态由使用者通过插槽提供。样式自包含、零第三方 UI 依赖,仅以 Vue 3 为 peer 依赖。
组件只负责前端采集 / 播放 / 渲染,权威状态机与 ASR/TTS 在后端(FastAPI + DashScope),二者经 WebSocket 通信。后端不在本包内。
安装
pnpm add @financial-freedom/i-spark-ai-digital
# 或 npm i / yarn addpeer 依赖:vue@^3.5。
使用
样式已内联进 JS,import 组件即自动注入,无需额外引入 CSS。
按需引入
<script setup lang="ts">
import { VZai } from '@financial-freedom/i-spark-ai-digital'
</script>
<template>
<VZai
base-api="https://your-backend-host"
:size="200"
auto-start
@state-change="(s) => console.log(s)"
>
<template #opening>
<video src="/avatar-opening.mp4" autoplay loop muted playsinline />
</template>
<template #standing>
<video src="/avatar-standing.mp4" autoplay loop muted playsinline />
</template>
<template #listening>
<video src="/avatar-listening.mp4" autoplay loop muted playsinline />
</template>
<template #thinking>
<video src="/avatar-thinking.mp4" autoplay loop muted playsinline />
</template>
<template #speaking>
<video src="/avatar-speaking.mp4" autoplay loop muted playsinline />
</template>
</VZai>
</template>全局注册(插件)
import { createApp } from 'vue'
import VZaiPlugin from '@financial-freedom/i-spark-ai-digital'
import App from './App.vue'
createApp(App).use(VZaiPlugin).mount('#app')
// 之后模板里可直接用 <VZai />CDN(UMD)
<script src="https://unpkg.com/vue@3"></script>
<script src="https://unpkg.com/@financial-freedom/i-spark-ai-digital"></script>
<script>
Vue.createApp({}).use(VZaiDigital.default).mount('#app')
</script>AudioWorklet 资产
麦克风降采样运行在独立的音频线程,必须以独立 URL 文件加载,无法被打包内联。包内随附 dist/vzai-worklet.js,请将其放到你站点可访问的路径,并通过 worklet-url 指定:
import workletUrl from '@financial-freedom/i-spark-ai-digital/worklet?url' // Vite 写法<VZai base-api="https://your-backend-host" :worklet-url="workletUrl" />未指定时默认从 ${BASE_URL}vzai-worklet.js 加载(即站点根目录),你也可手动把该文件拷到 public/。
Props
| Prop | 类型 | 默认 | 说明 |
|---|---|---|---|
| baseApi | string | http://127.0.0.1:8765 | 后端基础接口地址;为空时回退环境变量 VITE_APP_BASE_API,组件默认由它派生 /digital/ws/vzai 与 /digital/tts/stream |
| baseUrl | string | /(部署 base 路径) | 运行期资产路径前缀;为空时回退环境变量 BASE_URL。默认用于派生 ${baseUrl}vzai-worklet.js,姿态素材由具名插槽自行提供 |
| workletUrl | string | ${baseUrl}vzai-worklet.js | 降采样 AudioWorklet 脚本地址 |
| autoStart | boolean | false | 挂载即建连开始对话(getUserMedia 授权仍需用户首次交互) |
| openingText | string | 你好,我是V仔,很高兴见到你 | 开场姿态实时合成播报文本;设为空字符串则不播开场音频 |
| openingTtsUrl | string | 由 baseApi 推导 | 开场 TTS HTTP 接口;显式传入时优先,否则使用 ${baseApi}/digital/tts/stream?text=...,后端使用配置的 TTS_VOICE 保持声音一致 |
| size | number \| string | 160 | 形象尺寸,数字按 px 处理,也可传任意 CSS 长度 |
| ptt | boolean | false | 手动触发模式(按住说话、松开后一次性识别,识别完成才进入思考),适用于移动端 |
| keywords | { keyword: string; callback: () => void }[] | [] | 关键词注册表,ASR 命中即触发回调(不进入正常问答流程) |
| agent | AgentConfig | undefined | 智能体接入配置。建连时随 hello 帧透传给服务端,逐字段覆盖服务端 .env 默认值;不传或某字段缺省时回退服务端自身配置。token / appId 会暴露在客户端,仅限可信环境使用 |
回调 Props
onStateChange(state)、onError(message)、onClose()、onReplyStart()、onReplyChunk(text)、onReplyEnd()、onEvent(event)(服务端原始事件 + 客户端合成的 audio_first 透传,供自建面板 / 埋点)。
agent 类型
import { type AgentConfig } from '@financial-freedom/i-spark-ai-digital'
const agent: AgentConfig = {
baseApi: 'https://your-agent-host/oms', // 智能体后端基础地址
appId: '...', // 应用 ID
userName: 'digital_vzai', // 用户名
token: '...', // 鉴权 token(会暴露在客户端,仅可信环境)
systemCode: '...', // 系统编码(可选)
fileInterType: 0, // 文件交互类型(可选)
}暴露方法(ref)
const vzai = ref<InstanceType<typeof VZai>>()
// vzai.value.start() / stop() / wakeup() / interrupt() / pttStart() / pttEnd()
// await vzai.value.resumeMedia() // 必须在真实点击/触摸处理函数中调用;成功返回 true
// vzai.value.state(只读)/ holding(只读)/ connecting(只读)微信内置浏览器(包括鸿蒙 NEXT)会拦截自动播放的 Web Audio 与有声音频输出。使用 autoStart 或 ptt 时,宿主页面应先显示点击蒙版,在点击处理函数中 await resumeMedia(),仅当返回 true 后撤掉蒙版。resumeMedia 在用户手势内对一段运行时合成的近静音 WAV 调用 HTMLAudioElement.play() 一次以打通音频输出路由,同时 resume() 播放上下文并逐个 play() 姿态视频解锁自动播放。组件在解锁成功前保留 opening 姿态、不提前请求开场 TTS,避免被静音消费掉;解锁后 TTS 一律走 Web Audio 流式播放(首帧即播,支持 tts_play 同步滚动)。
语音-文字同步滚动
第三方界面常需让回答文字随语音播放同步滚动 / 高亮。组件经 on-event 透传两类事实信号:
tts_text_chunk { text }—— 文本增量(逐块到达)。tts_play { position, duration, ended? }—— 播放时钟:position已播秒数、duration已缓冲秒数、ended终态帧。
两者都不含“哪块文本对应哪段音频”的逐块边界——那是估计,由包内独立的 createTtsLocator helper 承担:把文本块按字符权重等比映射到真实总时长,估算每块起止,再据 position 定位当前块。helper 不依赖组件、不持响应式状态,按需引入:
import { createTtsLocator, type TtsSegment } from '@financial-freedom/i-spark-ai-digital'
const loc = createTtsLocator()
// loc.push(text) // on tts_text_chunk
// loc.reset() // on tts_start
// loc.locate(position, dur) // → { index, text, start, end } | null
// loc.segments // 当前段表(只读)
// loc.totalWeight // 当前总权重⚠️ 不要把组件 tts_play.duration 直接喂给 locate
locate(position, duration) 的 duration 语义是整段音频的真实总时长(helper 用 (cumWeight / totalWeight) × duration 给每块定终点)。而组件流式下 tts_play.duration 是已缓冲到的秒数——随音频块到达离散跳档,只有 ended 那一帧才等于真实总时长。直接喂入会出两个症状:
- 初始化定位到末块再退回首块:
tts_start时duration=0→locate(0,0)走 fallback 命中末块;随后duration涨成小值、position更小,position/duration极小 → 退回首块。 - 上下跳跃抖动:
duration离散跳档、position平滑上涨,每帧position/duration忽小忽大 → 高亮块在首/中/末来回摆、回退。
正确用法:语速 × 总权重 估固定总时长
用历史语速 × 总权重估出整段总时长喂给 locate,position 用组件真实值。一轮内语速固定 → 估算总时长固定 → locate(position, 固定值) 随 position 单调递增,不抖不回退。ended 帧用 position / totalWeight 反推语速、snap 末块并校准下轮语速:
<script setup lang="ts">
import { ref } from 'vue'
import { VZai, createTtsLocator, type TtsSegment } from '@financial-freedom/i-spark-ai-digital'
const segs = ref<TtsSegment[]>([])
const activeIdx = ref(-1)
const loc = createTtsLocator()
let speakRate = 0.25 // sec/字符,≈4 字/秒首轮兜底
function onEvent(e: { type: string; [k: string]: unknown }) {
switch (e.type) {
case 'tts_start': // 新一轮回答:复位
loc.reset()
segs.value = []
activeIdx.value = -1
break
case 'tts_text_chunk': // 入块
loc.push((e.text as string) || '')
segs.value = [...loc.segments]
break
case 'tts_play': { // ~10Hz 进度帧
const pos = (e.position as number) || 0
if (e.ended) { // 终态:snap 末块 + 校准下轮语速
if (loc.totalWeight > 0) speakRate = pos / loc.totalWeight
activeIdx.value = loc.segments.length - 1
} else {
const estTotal = speakRate * loc.totalWeight // 一轮内固定,不随缓冲抖
const r = loc.locate(pos, estTotal) // position 真实,duration 固定估算
if (r && r.index >= activeIdx.value) activeIdx.value = r.index // 单调护栏:只前进
}
break
}
}
}
</script>
<template>
<VZai base-api="https://your-backend-host" :on-event="onEvent" />
<p class="reply">
<span
v-for="s in segs"
:key="s.index"
:class="{ active: s.index === activeIdx }"
>{{ s.text }}</span>
</p>
</template>要点:
- 为何估总时长而非用
duration:组件tts_play.duration在tts_end前随入队音频增长、离散跳档,position则平滑上涨;把前者当总时长喂入会让二者比值忽小忽大,高亮块抖动回退。改用speakRate × totalWeight:一轮内speakRate固定 → 估算总时长固定 →locate随position单调递增。 - 单调护栏:仅当
r.index >= activeIdx才前进高亮。文本块仍在陆续到达、totalWeight增大时locate可能临时返回更早的块,护栏兜住防回退;position >= estTotal(估算偏低)时落末块、原地保持,只有ended才 snap 末块,杜绝抢跑。 - 语速校准:
ended帧用position / totalWeight反推speakRate,下一轮复用;首轮用0.25(≈4 字/秒)兜底。要跨句稳定可记多轮均值。 - 逐字而非逐块:示例按块高亮。要逐字,用
r.start / r.end在该块文本内再按字符比例细分即可。 - 局限:估计而非精确,标点停顿、数字/日期展开、中英混排会漂移;纯中文连续句误差小。要逐词精确需换带词边界回调的 TTS 或上 forced-alignment,届时用真实时间戳、绕过本 helper。
自定义形象
组件不再提供内置默认姿态。使用者必须通过与状态对应的具名插槽提供全部姿态(opening / standing / listening / thinking / speaking),插槽参数提供 pose、state 与 onOpeningEnd。opening 只在组件初始化时展示一次;开场音频通过后端 TTS 实时合成并同步播放,只有开场音频播完后才会稍作延时并切换为 standing,不等待 opening 动画结束。建议 opening 动画自身设置 loop,避免音频未结束前画面停住:
<VZai base-api="https://your-backend-host">
<template #opening>
<video
src="/avatar-opening.mp4"
autoplay
loop
muted
playsinline
/>
</template>
<template #standing>
<video src="/avatar-standing.mp4" autoplay loop muted playsinline />
</template>
<template #listening>
<video src="/avatar-listening.mp4" autoplay loop muted playsinline />
</template>
<template #thinking>
<video src="/avatar-thinking.mp4" autoplay loop muted playsinline />
</template>
<template #speaking="{ state }">
<img src="/avatar-speaking.webp" :alt="state" />
</template>
</VZai>示例姿态视频
组件本身不内置默认姿态,但包内随附一组「V仔」示例姿态视频,供快速测试:dist/poses/vzai-opening.mp4 / vzai-standing.mp4 / vzai-speaking.mp4(listening / thinking 可复用 standing)。两种引用方式:
1)经 ?url 引入(Vite,推荐) —— 由打包器生成哈希资产地址,无需手动拷文件:
<script setup lang="ts">
import openingUrl from '@financial-freedom/i-spark-ai-digital/poses/vzai-opening.mp4?url'
import standingUrl from '@financial-freedom/i-spark-ai-digital/poses/vzai-standing.mp4?url'
import speakingUrl from '@financial-freedom/i-spark-ai-digital/poses/vzai-speaking.mp4?url'
</script>
<template>
<VZai base-api="https://your-backend-host" :auto-start="true">
<template #opening>
<video :src="openingUrl" autoplay loop muted playsinline />
</template>
<template #standing>
<video :src="standingUrl" autoplay loop muted playsinline />
</template>
<template #listening>
<video :src="standingUrl" autoplay loop muted playsinline />
</template>
<template #thinking>
<video :src="standingUrl" autoplay loop muted playsinline />
</template>
<template #speaking>
<video :src="speakingUrl" autoplay loop muted playsinline />
</template>
</VZai>
</template>2)自托管 —— 把上述文件拷到站点 public/,直接以根路径引用:
cp node_modules/@financial-freedom/i-spark-ai-digital/dist/poses/*.mp4 public/<template>
<VZai base-api="https://your-backend-host" :auto-start="true">
<template #opening>
<video src="/vzai-opening.mp4" autoplay loop muted playsinline />
</template>
<!-- standing / listening / thinking → /vzai-standing.mp4;speaking → /vzai-speaking.mp4 -->
</VZai>
</template>正式上线建议替换为自有形象素材。
完整文档
包内随附一份独立 HTML 文档 dist/VZai.html(亦可经子路径 @financial-freedom/i-spark-ai-digital/docs 引用),在浏览器中打开即可查看组件的完整说明:
open node_modules/@financial-freedom/i-spark-ai-digital/dist/VZai.htmlLicense
MIT
