浏览器下时域分析实现简易语音嘴唇同步算法

需求:在实现全双工语音 Agent 助手的客户端时,服务端流式传输PCM编码的语音数据,客户端在播放语音的同时控制一个虚拟形象的嘴唇开合程度。

这里的建模比较简单(后面说明项目背景的时候会提到这其实是只一个二次元皮套人(bushi)),把嘴唇开合度记为一个落在$[0,1]$的实数,问题就是:已知语音的时域,求嘴唇开合度,并且语音的传输是实时的。

在讨论工程实现之前,需要先了解一些音频分析的基础知识,并把“声音强弱”和“嘴巴开合”之间的关系抽象成一个数学模型。

数学建模

声音的本质

从物理上看,声音本质上是介质中的压力扰动。以空气为例,当声源振动时,它会让附近的空气分子发生微小位移,使局部空气出现周期性的压缩和稀疏。这个压缩、稀疏的扰动向外传播,人耳或麦克风感受到的就是随时间变化的声压。

如果把某一点处的声压变化记为$p(t)$,那么一个最简单的纯音可以用正弦波表示:

$$ p(t) = A \sin(2\pi f t + \varphi) $$

其中,$A$表示振幅,决定声音的响度;$f$表示频率,决定声音的音调;$\varphi$表示相位,表示波形在时间轴上的平移。

现实生活中的语音大多不是单一的正弦波。人的声带振动、口腔和鼻腔共鸣、舌头和嘴唇的运动都会让声音包含很多频率成分。因此,复杂声音可以近似看成许多不同频率、不同振幅、不同相位的正弦波叠加:

$$ p(t) = \sum_{k=1}^{K} A_k \sin(2\pi f_k t + \varphi_k) $$

这里的每一项都可以理解为声音中的一个频率成分。不同人的音色、不同元音和辅音的差异,本质上都和这些频率成分的分布有关。

声压、电信号与数字音频

声波是空气中的压力变化,而数字系统不能直接处理连续的空气压力,所以声音通常会经过一条物理和信号处理上的转换链路:

$$ \text{声压变化} \rightarrow \text{振膜振动} \rightarrow \text{模拟电信号} \rightarrow \text{数字音频} $$

麦克风的振膜会随着空气压力变化而振动,并把这种机械振动转换成连续变化的电压信号。这个电压信号仍然是模拟信号,可以记为:

$$ x(t) $$

要把连续时间信号表示成数字信号,就需要进行采样。采样的含义是在等间隔的时间点上读取信号值,从而得到一个离散序列:

$$ x[n] = x(nT_s) $$

其中,$T_s$是采样间隔,采样率$f_s$定义为:

$$ f_s = \frac{1}{T_s} $$

如果采样率为$24000\text{ Hz}$,就表示每秒采集$24000$个样本。如果一段数字音频有$N$个采样点,那么它对应的时长为:

$$ T = \frac{N}{f_s} $$

采样时还需要注意Nyquist采样定理。对于最高频率为$f_{\max}$的信号,采样率至少需要满足:

$$ f_s \ge 2f_{\max} $$

否则高频信号会被错误地折叠到低频,产生混叠。人耳可听范围大约到$20\text{ kHz}$,所以常见音乐采样率会使用$44.1\text{ kHz}$或$48\text{ kHz}$。而语音任务通常不需要完整保留所有高频信息,$16\text{ kHz}$、$24\text{ kHz}$也很常见。

数字音频除了采样率以外,还需要考虑量化。连续的电压值会被映射成有限精度的数字值。以16位有符号PCM为例,每个采样点可以表示的整数范围大约是:

$$ [-32768, 32767] $$

为了便于做数学分析,通常可以把整数采样归一化成$[-1,1]$附近的浮点值:

$$ x_{\text{float}}[n] = \frac{x_{\text{pcm16}}[n]}{32768} $$

归一化之后,音频信号就可以看成一个离散时间序列:

$$ x[0], x[1], \cdots, x[N-1] $$

后续的嘴唇同步建模,就是从这段离散时域信号中提取一个代表“当前声音强弱”的量,再把它映射成嘴巴开合度。

频谱分析:傅立叶变换

傅立叶变换的核心思想是:任何足够“合理”的信号,都可以分解成许多不同频率的正弦波或复指数信号之和。

对于连续时间信号$x(t)$,傅立叶变换可以写成:

$$ X(f) = \int_{-\infty}^{+\infty} x(t)e^{-j2\pi ft}\,dt $$

$X(f)$描述的是信号在频率$f$上的成分强弱和相位信息。直观地说,时域告诉我们“声音随时间怎么变”,频域告诉我们“声音由哪些频率组成”。

在数字音频中,我们处理的是离散序列$x[n]$,通常使用离散傅立叶变换,也就是DFT:

$$ X[k] = \sum_{n=0}^{N-1} x[n]e^{-j2\pi kn/N} $$

其中,$N$是分析窗口内的采样点数量,$k$表示第$k$个频率 bin。

直接计算DFT的复杂度是$O(N^2)$,而FFT,也就是快速傅立叶变换,可以把复杂度降低到$O(N\log N)$。这也是实时音频分析中常用FFT的原因。

频谱信息对于高精度口型同步非常有用。例如,不同元音会有不同的共振峰结构,不同辅音也会在高频区域表现出不同特征。如果目标是识别具体音素,或者生成更接近真实发音的viseme口型,那么频域特征、音素识别和时间对齐都会很重要。

不过,当前要建立的是一个轻量模型。它不追求区分“a”“o”“i”等具体口型,而是只关心一个更粗粒度的问题:

$$ \text{声音越强,嘴巴越张开;声音越弱,嘴巴越闭合} $$

因此,频谱分析在理论上有帮助,但在这个简化模型中并不是必要条件。我们可以直接在时域上估计声音强度。

时域特征分析

设一帧音频采样为:

$$ x[0], x[1], \cdots, x[N-1] $$

其中$x[n]$是归一化后的浮点采样,通常位于$[-1,1]$之间。

最直接的声音强度特征是幅度,也就是采样点偏离$0$的程度。例如可以使用最大绝对值:

$$ A_{\max} = \max_{0 \le n < N} |x[n]| $$

但最大值对瞬时尖峰非常敏感。一个很短的爆音、毛刺或采样异常,就可能让估计出来的声音强度突然变大。如果直接用它驱动嘴型,嘴巴可能会出现不自然的剧烈抖动。

另一个常见特征是能量:

$$ E = \sum_{n=0}^{N-1} x[n]^2 $$

能量反映了一段信号整体的强弱。但是它会随着窗口长度$N$变化。如果窗口越长,累加项越多,能量自然可能越大,所以不同窗口长度之间不方便直接比较。

为了消除窗口长度影响,可以使用平均功率:

$$ P = \frac{1}{N}\sum_{n=0}^{N-1} x[n]^2 $$

平均功率表示单位采样点上的平均能量。进一步对平均功率开平方,就得到RMS,也就是均方根值:

$$ \text{RMS} = \sqrt{\frac{1}{N}\sum_{n=0}^{N-1} x[n]^2} $$

RMS可以理解为这段波形的“有效幅度”。相比最大幅度,它更平滑;相比平均绝对值,它和信号功率之间有清晰关系;相比能量,它不会随着窗口长度直接增大。因此,在实时音量检测、VU meter、简单嘴型同步中,RMS是一个非常合适的特征。

记第$t$帧音频的RMS为:

$$ r_t = \sqrt{\frac{1}{N}\sum_{n=0}^{N-1}x_t[n]^2} $$

其中,$x_t[n]$表示第$t$帧中的第$n$个采样点。

接下来需要把$r_t$映射成嘴巴开合度。设嘴巴开合度为$m_t$,并约束在:

$$ m_t \in [0,1] $$

其中:

  • $m_t = 0$表示嘴巴闭合;
  • $m_t = 1$表示嘴巴张到最大;
  • 中间值表示不同程度的开合。

最简单的映射可以写成:

$$ u_t = \operatorname{clip}((r_t - \theta)g, 0, 1) $$

其中,$\theta$是静音阈值,$g$是增益系数,$\operatorname{clip}$表示截断函数:

$$ \operatorname{clip}(x,0,1)= \begin{cases} 0, & x < 0 \\ x, & 0 \le x \le 1 \\ 1, & x > 1 \end{cases} $$

这个映射可以分成三步理解:

$$ \text{RMS} \rightarrow \text{去掉底噪} \rightarrow \text{放大(或缩小)变化} \rightarrow \text{限制到}[0,1] $$

当$r_t < \theta$时,声音被认为接近静音,嘴巴不应该张开;当$r_t$逐渐变大时,嘴巴开合度随之增大;当结果超过$1$时,则限制在最大开合度。

嘴巴运动的平滑建模

如果直接令:

$$ m_t = u_t $$

嘴巴会完全跟随每一帧音频强度变化。由于语音波形本身变化很快,RMS也会有一定抖动,这样会导致嘴巴动画看起来不自然。

为了让运动更平滑,可以引入一个平滑公式:

$$ m_t = m_{t-1} + \alpha(u_t - m_{t-1}) $$

整理后得到:

$$ m_t = (1-\alpha)m_{t-1} + \alpha u_t $$

这里:

  • $u_t$是由当前音频强度得到的目标开合度;
  • $m_t$是实际显示出来的平滑开合度;
  • $\alpha$是平滑系数。

当$\alpha$较大时,$m_t$会更快接近$u_t$,嘴巴响应更灵敏;当$\alpha$较小时,$m_t$变化更慢,动画更平滑。

为了让嘴型更自然,还可以使用非对称平滑:

$$ \alpha = \begin{cases} \alpha_{\text{open}}, & u_t > m_{t-1} \\ \alpha_{\text{close}}, & u_t \le m_{t-1} \end{cases} $$

并且取:

$$ \alpha_{\text{open}} > \alpha_{\text{close}} $$

也就是说,当声音变强时,嘴巴快速张开;当声音变弱时,嘴巴稍慢地闭合。这个设计符合直觉,也更接近人说话时嘴部运动的观感。

在没有声音输入时,还可以让嘴巴开合度自然衰减:

$$ m_t = \lambda m_{t-1} $$

其中:

$$ 0 < \lambda < 1 $$

这样嘴巴不会瞬间关闭,而是以一个较快但连续的方式回到闭合状态。

关于这个巧妙的平滑公式,AI 嘉豪可能会觉得它像深度学习里 Adam 优化器的 EMA,但是我更倾向于把它理解为"一阶低通滤波",它的物理直觉来源于咱们的嘴巴肌肉像一个有惯性和阻尼的系统,不会瞬间跳到目标位置,而是按指数曲线逼近目标。

工程化实现

把上面的数学模型落到浏览器里,需要解决两个层面的问题:

  1. 音频播放链路:服务端流式返回的是一段一段的原始音频数据,客户端要把它连续、稳定地播放出来;
  2. 嘴型分析链路:客户端要从“正在播放的音频”里实时取出时域采样,计算RMS,再更新嘴巴开合度。

音频数据格式:PCM 与 Float32

服务端下发的TTS音频是PCM流,也就是把连续声波采样、量化后得到的一串数字采样点。客户端已知PCM流的:

  • 采样率:例如$24000\text{ Hz}$;
  • 声道数:例如单声道;
  • 位深:例如16位有符号整数;
  • 字节序:通常按运行环境中的小端序解释。

在具体实现里,下行TTS音频按单声道、24000Hz、PCM16处理。PCM16表示每个采样点是一个16位有符号整数:

$$ p[n] \in [-32768, 32767] $$

它适合网络传输和后端音频接口,因为体积小、表达直接。但浏览器的Web Audio API在处理AudioBuffer通道数据时,使用的是归一化后的浮点采样,也就是Float32ArrayAnalyserNode.getFloatTimeDomainData读出来的时域数据同样是浮点格式。换句话说,在浏览器的播放和分析链路里,实际参与计算的是:

$$ x[n] \in [-1, 1] $$

因此,PCM16进入浏览器后,需要先转换成Float32:

$$ x[n] = \frac{p[n]}{32768} $$

其中,$p[n]$是服务端传来的PCM16整数采样,$x[n]$是浏览器端用于播放和分析的浮点采样。

这一步很重要:浏览器并不是不能接收PCM字节流,而是不能把一段裸PCM16整数直接塞进AudioBuffer当作可播放通道数据。AudioBuffer需要的是Float32形式的线性PCM采样。所以工程链路可以概括为:

$$ \text{PCM16 ArrayBuffer} \rightarrow \text{Int16Array} \rightarrow \text{Float32Array} \rightarrow \text{AudioBuffer} $$

如果用JavaScript表示核心转换逻辑,可以写成:

const pcm = new Int16Array(arrayBuffer);
const floats = new Float32Array(pcm.length);

for (let i = 0; i < pcm.length; i++) {
  floats[i] = pcm[i] / 32768;
}

完成转换后,用这些Float32采样创建一个单声道音频缓冲区。假设采样率是$24000\text{ Hz}$,缓冲区包含$N$个采样点,那么该chunk对应的播放时长为:

$$ \Delta t = \frac{N}{24000} $$

WebSocket 接收流式音频

服务端为了实时播放音频,不可能等整句话全部生成完再发送给前端,而是边生成边发送多个小音频片段。客户端可以使用WebSocket接收两类消息:

  • 文本消息:用于传递控制事件,例如日志、状态、用户打断、检测到新一轮说话等;
  • 二进制消息:用于传递TTS音频chunk,也就是上面提到的PCM16字节流。

客户端收到二进制消息后,应把它当作一个独立的PCM16音频片段进行解析、转换和调度播放。收到文本控制消息时,则根据消息类型决定是否停止当前TTS播放。例如,当用户开始说话或明确打断时,需要立即停止已经排队的TTS音频,并把嘴巴开合度归零。

这样的消息设计把“音频数据”和“控制事件”分开了。二进制消息只负责高效传输音频,文本消息负责表达语义状态,前端逻辑会更清晰。

创建播放缓冲区并接入分析节点

先创建AudioContext对象,这是浏览器中用来音频处理的一个类。

playbackContext = new AudioContext({ sampleRate: 24000 });

每个PCM chunk转换成Float32后,都可以放入一个AudioBuffer。这个缓冲区的三个核心参数是:

playbackContext.createBuffer(声道数, 采样点数, 采样率)

对于单声道、$24000\text{ Hz}$的TTS音频,可以创建:

const buffer = playbackContext.createBuffer(1, floats.length, 24000);
buffer.copyToChannel(floats, 0);

这里的1表示单声道,floats.length表示采样点数,24000表示采样率。之后再用AudioBufferSourceNode播放这个缓冲区。

为了让同一份音频既能被用户听到,又能被嘴型算法分析,可以把播放节点接到一个AnalyserNode,再由分析节点接到扬声器输出:

$$ \text{BufferSource} \rightarrow \text{AnalyserNode} \rightarrow \text{AudioDestination} $$

初始化分析节点时,设置:

mouthAnalyser = playbackContext.createAnalyser();
mouthAnalyser.fftSize = 1024;
mouthAnalyser.smoothingTimeConstant = 0;
mouthSamples = new Float32Array(mouthAnalyser.fftSize);
mouthAnalyser.connect(playbackContext.destination);

这里虽然属性名叫fftSize(快速傅里叶变化窗口大小),但当调用getFloatTimeDomainData时,它同样决定一次读取多少个时域采样。取$N=1024$,在$24000\text{ Hz}$采样率下,对应的时间窗口为:

$$ \frac{1024}{24000} \approx 0.0427\text{ s} $$

也就是大约$42.7\text{ ms}$。这个窗口足够短,可以比较及时地跟随语音音量变化;同时又不会短到对单个采样点或瞬时尖峰过于敏感。

AnalyserNode本身也带有平滑参数,但这里把smoothingTimeConstant设为$0$。原因是嘴巴开合的平滑已经由后面的数学模型控制,并且还会区分“张嘴”和“闭嘴”的不同响应速度。如果分析器内部再额外平滑一次,最终动画的响应会更难调。

让多个音频 chunk 连续播放

流式TTS会不断产生小音频片段。浏览器端并不是把这些chunk直接“拼成一个大文件”再播放,而是会把每个chunk转换成一个独立的AudioBufferSourceNode,再交给Web Audio API调度播放。

这里需要维护播放时间,是因为Web Audio API的播放接口支持指定精确的启动时间。也就是说,通过:

const node = playbackContext.createBufferSource();
node.start(when);

可以把某一段音频安排在AudioContext时间轴上的某个未来时刻开始播放。

如果每个chunk一到达就立刻播放,主要会出现一个明显问题:当多个chunk几乎同时到达时,它们会在同一时间开始播放,听起来像多段音频重叠在一起。因此,客户端需要维护一个“下一段音频应该从什么时间开始播放”的变量,记为:

$$ t_{\text{next}} $$

它的初始值是0。

Web Audio API内部维护的时间轴,可以通过playbackContext.currentTime获取,记为:

$$ t_{\text{now}} $$

每个新chunk的播放起点按下面的公式决定,其中$t_{next}$是处理上一个chunk计算得到的:

$$ t_{\text{start}} = \max(t_{\text{next}}, t_{\text{now}} + \delta) $$

其中,$\delta$是一个很小的调度提前量,比如$0.03\text{ s}$。它的作用是给浏览器音频线程留出一点调度时间,避免把音频安排在“当前这一瞬间”启动,从而降低调度不稳定、起播不及时或出现细小爆音的概率。

设当前chunk的时长为$\Delta t$,完成调度后更新:

$$ t_{\text{next}} \leftarrow t_{\text{start}} + \Delta t $$

这样就形成了一个连续播放队列:

$$ \text{chunk}_1 \rightarrow \text{chunk}_2 \rightarrow \text{chunk}_3 \rightarrow \cdots $$

每个chunk都被安排在前一个chunk结束的位置之后开始播放。即使网络一次性送来多个chunk,它们也不会互相重叠。

用 requestAnimationFrame 驱动嘴型更新

嘴巴开合属于视觉动画,更新节奏应该和浏览器渲染节奏对齐。因此,可以用浏览器提供的requestAnimationFrame建立一个动画循环。它会在浏览器下一次重绘之前调用回调函数,适合用来更新DOM样式、Canvas绘制或其他视觉状态。

整体流程可以表示为:

$$ \text{读取当前播放音频} \rightarrow \text{计算RMS} \rightarrow \text{映射成目标嘴巴开合度} \rightarrow \text{平滑} \rightarrow \text{更新DOM} $$

具体代码:

let mouthAnimationFrame;

function startMouthSync() {
  if (mouthAnimationFrame) {
    cancelAnimationFrame(mouthAnimationFrame);
  }

  const tick = () => {
    updateMouthFromPlayback();
    mouthAnimationFrame = requestAnimationFrame(tick);
  };

  mouthAnimationFrame = requestAnimationFrame(tick);
}

这里的tick就是每一帧要执行的更新逻辑。每次执行完updateMouthFromPlayback之后,再次调用requestAnimationFrame(tick),就形成了一个持续循环。

requestAnimationFrame的回调通常接近屏幕刷新率,例如$60\text{ FPS}$时大约每$16.7\text{ ms}$执行一次。但它并不保证固定间隔执行,而是由浏览器根据页面渲染、设备刷新率和当前负载来调度。相比固定间隔的setInterval,它更适合驱动视觉动画:动画更新会尽量贴近浏览器重绘时机,页面不可见时,浏览器也更容易自动降低频率,从而节省资源。

当需要停止嘴型同步时,可以用cancelAnimationFrame取消下一帧回调:

function stopMouthSync(immediate = false) {
  if (mouthAnimationFrame) {
    cancelAnimationFrame(mouthAnimationFrame);
  }

  mouthAnimationFrame = null;

  if (immediate) {
    setMouthOpen(0);
  }
}

这样可以避免页面停止播放后仍然持续计算RMS和更新DOM。

每一帧更新时,先判断当前是否有可分析的播放音频。如果分析器不存在、采样数组还没初始化,或者播放队列中没有任何音频节点,就让嘴巴开合度自然衰减:

$$ m_t = 0.65m_{t-1} $$

这比直接把嘴巴设为$0$更自然,因为它给了嘴巴一个快速但连续的闭合过程。对应的逻辑可以写成:

if (!mouthAnalyser || !mouthSamples || scheduledSources.length === 0) {
  mouthOpen = 0.65 * mouthOpen;
  setMouthOpen(mouthOpen);
  return;
}

如果当前存在正在播放或即将播放的TTS音频,就从AnalyserNode读取一帧Float32时域采样:

mouthAnalyser.getFloatTimeDomainData(mouthSamples);

getFloatTimeDomainData会把当前分析窗口内的时域波形写入传入的Float32Array。这些采样值已经是浏览器Web Audio内部使用的浮点格式,通常位于$[-1,1]$范围内,因此可以直接用于RMS计算。

此时mouthSamples可以看成:

$$ x_t[0], x_t[1], \cdots, x_t[N-1] $$

其中$N=1024$,也就是上面设置的fftSize,并且每个$x_t[n]$都是浏览器端归一化后的浮点采样。

从RMS到嘴巴开合度

读到当前窗口的时域采样后,先计算RMS:

$$ r_t = \sqrt{\frac{1}{N}\sum_{n=0}^{N-1}x_t[n]^2} $$

其中,$N=1024$。RMS越大,说明当前播放声音整体越强。

接下来把RMS映射成目标嘴巴开合度:

$$ u_t = \operatorname{clip}((r_t - 0.012) \times 9.5, 0, 1) $$

其中$\operatorname{clip}$表示截断函数:

$$ \operatorname{clip}(x,0,1)= \begin{cases} 0, & x < 0 \\ x, & 0 \le x \le 1 \\ 1, & x > 1 \end{cases} $$

这个映射包含三层含义。

第一,$0.012$是静音阈值,也可以理解为噪声门限。当RMS低于这个值时,认为声音太弱,不应该驱动嘴巴明显张开。

第二,$9.5$是增益系数。归一化音频的RMS通常不会特别大,如果不放大,嘴巴开合变化会很弱。

第三,clip把结果限制在$[0,1]$,保证嘴巴开合度始终处在合法范围内。

所以工程里的目标值计算,本质上就是:

$$ \text{RMS} \rightarrow \text{去掉底噪} \rightarrow \text{放大变化} \rightarrow \text{限制到}[0,1] $$

嘴型平滑

根据上面的数学建模,嘴巴开合度使用一阶低通滤波形式:

$$ m_t = m_{t-1} + \alpha(u_t - m_{t-1}) $$

也可以写成:

$$ m_t = (1-\alpha)m_{t-1} + \alpha u_t $$

这里,$u_t$是当前目标开合度,$m_t$是平滑后的实际开合度,$\alpha$控制响应速度。

为了让嘴巴更像真实说话时的运动,使用非对称平滑:

$$ \alpha = \begin{cases} 0.42, & u_t > m_{t-1} \\ 0.16, & u_t \le m_{t-1} \end{cases} $$

当声音变强时,目标开合度增大,嘴巴应该快速张开,所以使用较大的$\alpha=0.42$。

当声音变弱时,目标开合度减小,嘴巴可以稍慢地闭合,所以使用较小的$\alpha=0.16$。

这会得到一种“张嘴快、闭嘴稍慢”的效果。对虚拟形象来说,这通常比完全对称的平滑更自然。

最后由于嘴巴闭合的时候是嘴巴大小指数递减的,并不会归零,还需要增加一个很小的归零阈值:

$$ m_t = \begin{cases} 0, & m_t < 0.004 \\ m_t, & m_t \ge 0.004 \end{cases} $$

它可以避免嘴巴在接近闭合时仍然残留极小开合度,导致视觉上看起来一直没有完全闭嘴。

把开合度映射到页面元素

经过RMS映射和平滑后,得到的$m_t$已经是一个$[0,1]$之间的数。最后一步是把它映射到虚拟嘴巴的视觉形态。

假设嘴是一个DOM元素,开合效果通过纵向缩放实现。先把$m_t$限制到合法范围:

$$ m = \operatorname{clip}(m_t,0,1) $$

最后通过线性变换设置嘴巴元素的纵向缩放:

$$ s_y = 0.2 + 2.5m $$

当$m=0$时:

$$ s_y = 0.2 $$

嘴巴接近闭合,但仍保留一点基础高度。

当$m=1$时:

$$ s_y = 2.7 $$

嘴巴被明显拉开。

function setMouthOpen(value) {
    const clamped = Math.max(0, Math.min(1, value));
    mouthEl.style.transform = `translateX(-50%) scaleY(${0.2 + clamped * 2.5})`;
}

双向语音场景中的上行音频补充

完整的语音交互里,除了播放服务端返回的TTS,还可能需要把用户麦克风音频发回服务端做语音识别或打断检测。这个上行链路和嘴型同步不是同一件事,但它也涉及Float32与PCM之间的转换。

浏览器从麦克风拿到的音频,在AudioContext处理链路里通常也是Float32采样:

$$ x[n] \in [-1,1] $$

如果服务端希望接收$16000\text{ Hz}$、PCM16格式的语音,就需要先把浏览器输入按目标采样率重采样,再把Float32转回PCM16。转换时需要先截断到$[-1,1]$,再映射到16位整数范围:

$$ p[n]= \begin{cases} x[n]\times 32768, & x[n] < 0 \\ x[n]\times 32767, & x[n] \ge 0 \end{cases} $$

这个方向和TTS下行正好相反:

$$ \text{麦克风 Float32} \rightarrow \text{重采样} \rightarrow \text{PCM16} \rightarrow \text{WebSocket 上行} $$

因此,整个浏览器端实际上同时维护了两条音频格式转换链路:

$$ \text{下行TTS:PCM16} \rightarrow \text{Float32} \rightarrow \text{播放与嘴型分析} $$$$ \text{上行麦克风:Float32} \rightarrow \text{PCM16} \rightarrow \text{发送给服务端} $$

其中,嘴唇同步只依赖下行TTS播放链路中的Float32时域采样。

总结

这个方案的流程如下:

$$ \text{PCM音频流} \rightarrow \text{Float32时域采样} \rightarrow \text{RMS} \rightarrow \text{阈值与增益映射} \rightarrow \text{一阶低通平滑} \rightarrow \text{归零阈值} \rightarrow \text{嘴巴开合度} $$

完整的数学形式可以写成:

$$ r_t = \sqrt{\frac{1}{N}\sum_{n=0}^{N-1}x_t[n]^2} $$$$ u_t = \operatorname{clip}((r_t - \theta)g, 0, 1) $$$$ \tilde{m}_t = (1-\alpha)m_{t-1} + \alpha u_t $$$$ m_t = \begin{cases} 0, & \tilde{m}_t < \varepsilon \\ \tilde{m}_t, & \tilde{m}_t \ge \varepsilon \end{cases} $$

其中,各个符号的含义如下:

  • $t$表示当前动画更新帧,也可以理解为当前嘴型计算时刻;
  • $N$表示每次参与分析的时域采样点数量;
  • $x_t[n]$表示第$t$帧分析窗口中的第$n$个Float32音频采样;
  • $r_t$表示第$t$帧音频的RMS,用来估计当前声音强弱;
  • $\theta$表示静音阈值,用来过滤底噪和很小的残留波动;
  • $g$表示增益系数,用来把较小的RMS变化放大到嘴巴开合区间;
  • $u_t$表示由当前音频强度直接映射得到的目标嘴巴开合度;
  • $\tilde{m}_t$表示经过一阶低通平滑后、但尚未应用归零阈值的嘴巴开合度;
  • $m_t$表示最终用于驱动动画的实际嘴巴开合度;
  • $m_{t-1}$表示上一帧的嘴巴开合度;
  • $\alpha$表示平滑系数,用来控制嘴巴从上一帧状态靠近目标状态的速度;
  • $\varepsilon$表示归零阈值,用来避免嘴巴在接近闭合时残留极小开合;
  • $\operatorname{clip}(x,0,1)$表示把$x$限制在$[0,1]$区间内。

在当前实现中,参数取值为:

$$ N = 1024 $$$$ \theta = 0.012 $$$$ g = 9.5 $$$$ \varepsilon = 0.004 $$$$ \alpha = \begin{cases} 0.42, & u_t > m_{t-1} \\ 0.16, & u_t \le m_{t-1} \end{cases} $$

Demo演示

这里皮套人使用的是 hiyori_free_en live2d model

Final: Amadeus Plan…?

受《命运石之门》的影响,我一直想在AI时代写一个自己的Amadeus System。互联网上已经有了很多实现,但是他们都不够潮(例如skills等技术)、更多是实验性质,依赖很重很商业化的第三方库。

我想做的事情是从基础语音库开始(整合tts/asr/vad),写一个真正能生产使用的多模态、全双工、有优秀的后台管理系统(动态管理提示词、tools、memory、skills、live2D皮套等)的Amadeus System,真正复刻《命运石之门》里的 Amadeus System。

我其实去年就已经有Demo了,并且后来还写了一个landing page:AMADEUS SYSTEM — Multimodal Agent Infrastructure,建立了仓库amadeus-plan, 但后来因为种种原因一直搁置。最近终于有余力继续实现它。

今年年初,我的一个朋友去日本旅游时带着我的初版 Amadeus 站在了秋葉原ラジオ会館的门口,给我留下了这张人生照片:

img

那一刻,我突然觉得这不仅仅是一个被搁置的玩具。它就像椎名真由理那枚悄悄寄存在时间里的乌帕,绕过了那段时间我个人的疲惫、犹豫和挣扎,穿越到异国他乡的圣地,最后又回到了我的手中。屏幕里的 Amadeus 还很粗糙,语音用的是很重的第三方库,传输质量也很不稳定,嘴唇同步没有现在的实现,后台和 Agent 框架也远谈不上优雅,但它已经拥有了某种“存在”的轮廓:一个可以被呼唤、被记住、被持续塑造的数字灵魂。

所以在这条时间线里,我想认真把它写完。

不只是为了复刻某个虚构作品里的浪漫,也不只是为了追逐 AI 时代的潮水;而是因为我始终相信,技术最动人的地方,并不在于它能冰冷地替我们完成多少任务,而在于它偶尔会让我们产生一种错觉:那些曾经只存在于想象中的东西,真的可以从信号的波动中,从一行行代码里,慢慢醒来。