你有两条采样率不匹配的流:高频音频帧(每 20 毫秒一帧,一句话几百帧)和低频字符序列(几十个字)。难点是它们长度不等、又没告诉你哪几帧对应哪个字——像两个时钟不同步的分布式流要做 join,却没对齐键。CTC 的解法很像流处理的去抖动 + 折叠去重:每帧各吐一个符号(可以吐"空"),最后把连续重复和空折叠掉得到干净文本。
语音识别(STT / ASR,automatic speech recognition,把说话转成文字)最棘手的不是"听懂"而是对齐(alignment):训练数据只给"这段音频 = 这句话",没标哪几帧是"你"、哪几帧是"好",人手标对齐贵到不可行。CTC(Connectionist Temporal Classification,Graves 等 2006)的天才之处是:不需要对齐标注,把所有可能的对齐一次性"积分"掉。
具体做法:在字符表里加一个特殊的 blank(空白符 ⊘)。网络对每一帧都输出一个字符分布(可以是 ⊘)。一条"帧→符号"的路径,通过折叠规则变成文本:先合并相邻重复、再删掉所有 ⊘。于是文本 "你好" 可由无数条帧级路径产生,比如 你你⊘好、⊘你好好、你⊘⊘好……CTC 的损失就是让所有能折叠成正确文本的路径的概率之和最大:
P(文本 | 音频) = Σ_{所有能折叠成该文本的路径 π} P(π | 音频)
逐符号拆:π 是一条帧级路径;折叠把它压成文本;Σ 把千万条合法路径的概率加起来。为什么加?因为我们不在乎哪帧对哪字,只要最终文本对——把"对齐"这个不关心的隐变量边缘化(marginalize)掉。这个求和看似指数爆炸,但用动态规划(前向-后向算法)能高效算,跟 HMM 同源。blank 还顺手解决两个真问题:区分连续重复的真字符("嗯嗯"要用 ⊘ 隔开才不被折叠成一个"嗯")、以及沉默/停顿该输出什么。
CTC 是一派解法(帧独立、无自回归、快)。另一派是 Whisper(Radford 等 2022)走的 encoder-decoder:编码器把音频压成特征,解码器像翻译一样自回归逐字生成,对齐交给注意力隐式学习。还有 wav2vec 2.0(Baevski 等 2020)的第三条路——先自监督预训练(在海量无标注音频上做"完形填空",掩一段波形让模型对比预测),再用少量标注微调,让预训练表示先啃一半对齐难题。
# 用官方 Whisper 做一次转写;再看 CTC 折叠规则的本质 import whisper # pip install openai-whisper model = whisper.load_model("base") # 本地模型,无需 API key result = model.transcribe("audio.mp3", language="zh") print(result["text"]) # encoder-decoder 自回归输出文本 # CTC 折叠规则本身只有几行——这才是它的核心 def ctc_collapse(frames, blank="⊘"): out, prev = [], None for ch in frames: # frames: 每帧 argmax 出的符号 if ch != prev and ch != blank: # 跳过重复、删掉 blank out.append(ch) prev = ch return "".join(out) print(ctc_collapse(list("你你⊘好好"))) # → 你好
现代 TTS 像两阶段编译器:声学模型是前端,把文字编译成一份中间表示(IR)——梅尔频谱(mel-spectrogram,一张"声音的乐谱",横轴时间、纵轴频率能量);声码器(vocoder)是后端,把 IR"代码生成"成能播放的波形。好处和编译器一样:关注点分离——前端管内容韵律、后端管音质音色,各自可独立替换升级。
直接从文字一步生成 16kHz 波形,等于让模型逐个吐出每秒一万六千个高度相关的采样点——搜索空间大到几乎学不动。破局思路是降维中转:先生成信息密度低得多的梅尔频谱(每秒约几十帧,只留人耳敏感的频率能量、扔掉相位细节),再由声码器补回波形。
① 声学模型:文字 → 梅尔频谱。 Tacotron 2(Shen 等 2018)是经典:seq2seq + 注意力把字符序列映射成频谱帧。这里藏着 TTS 的核心难点——时长(duration):文本短、语音长,一个字摊几帧?哪里重读停顿?自回归靠注意力隐式解决,但易跳字/重复/念崩;后来的 FastSpeech 系改用显式时长预测器并行生成,更稳更快。
② 声码器:梅尔频谱 → 波形。 开山之作 WaveNet(van den Oord 等 2016)——自回归逐采样点生成,音质惊艳但慢到无法实用(生成 1 秒算 1.6 万步)。后续用 GAN(HiFi-GAN 等)和扩散声码器把它并行化、提速几个数量级而音质不降。
③ 新范式:codec 语言模型。 VALL-E(Wang 等 2023)把 TTS 当"语言建模"——先把语音用神经 codec 压成离散 token(下一节主角),再训 LLM 自回归预测音频 token,最后解码回波形。它模糊了声学模型/声码器的划分,还带来惊人能力:3 秒目标音频即可零样本克隆音色。
# 用 HuggingFace 官方管道跑一次两阶段 TTS(Bark/VITS 类同理) from transformers import pipeline import soundfile as sf tts = pipeline("text-to-speech", model="microsoft/speecht5_tts") # 说话人向量:决定音色的"身份指纹",可换成克隆的目标音色 import torch spk = torch.zeros((1, 512)) # 占位;实际用 speaker embedding out = tts("你好,这是一次语音合成演示。", forward_params={"speaker_embeddings": spk}) sf.write("out.wav", out["audio"], out["sampling_rate"]) # 管道内部:文本→梅尔频谱(声学模型)→波形(声码器),两阶段被封装好了
把连续音频量化成离散码,本质是数据库的字典编码(dictionary encoding):与其存一长串高精度浮点,不如学一本码本(codebook),把每小段声音映射成"第几号码字"的整数 ID。而 RVQ(残差向量量化)更像逐级 delta 编码:第一级粗略近似,第二级专编"第一级没搞定的残差(误差)",第三级再补残差……一层层逼近,精度越叠越高。
这节是把音频接进 LLM 世界的关键枢纽。LLM 整套机器(Transformer、下一 token 预测)都建在离散 token上,可音频是连续的。神经 codec(neural audio codec)就是那个"音频分词器":编码器把波形压成低频特征,量化器把每个连续特征向量吸附到码本里最近的码字、输出整数 ID,解码器再从 ID 重建波形。整条链路端到端训练,目标是"重建音质尽量接近原始"。
难点:单个码本不够用。要覆盖丰富音色,码本得巨大(几十万条),训练不稳也浪费。RVQ(residual vector quantization)的解法是分而治之——用一串小码本级联:
这样 N 个各含 K 条的小码本,组合出 Kᴺ 种表达力,却只需存 N·K 条码字——指数级表达、线性级存储。SoundStream(Zeghidour 等 2021)首创这套端到端 RVQ codec,EnCodec(Défossez 等 2022)做到更高保真。深远意义不止压缩:音频从此变成"离散 token 序列"——于是 VALL-E 能像写句子一样"写"语音,音乐音效也能被 LLM 生成。这正是多模态大模型能"听说"的底层地基。
# 用官方 EnCodec 把波形编成离散 token,再解码回来 from transformers import EncodecModel, AutoProcessor import torch model = EncodecModel.from_pretrained("facebook/encodec_24khz") proc = AutoProcessor.from_pretrained("facebook/encodec_24khz") wav = torch.randn(1, 24000) # 1 秒假音频;实际读取真波形 inp = proc(raw_audio=wav, sampling_rate=24000, return_tensors="pt") enc = model.encode(inp["input_values"], inp["padding_mask"]) codes = enc.audio_codes # 离散码:形状 [层数, 帧数] 的整数 ID print(codes.shape, codes.min().item(), codes.max().item()) # 这些整数 ID 就能当"音频 token"喂进语言模型 rec = model.decode(enc.audio_codes, enc.audio_scales, inp["padding_mask"])[0] # ID → 重建波形
离线识别像批处理(batch):等整段音频到齐,前后文都能看,慢慢算最优解。流式识别像流处理(stream):一边流进来一边出结果,不能等未来。这逼出一个硬约束——因果性(causality):t 时刻的输出只能依赖 t 及之前的输入。代价和流式系统里一模一样:看不到未来上下文,结果只能次优,除非多缓冲一点(增加延迟)换质量。
实时字幕、语音助手、同声传译都要求边说边出,不能等说完。但深度模型天生爱"看全局":标准注意力让每帧都能看到整段音频,双向卷积也读取未来帧。要流式化,必须给模型动因果手术,机制上有三招:
① 因果卷积/因果注意力(causal): 强制每帧只看过去、不看未来(掩码遮住未来帧),和自回归 LLM 的因果掩码同源。彻底流式、零延迟,但丢了未来上下文,准确率下降。
② 分块处理(chunk-based): 把音频切成小块(如 320 毫秒),块内双向看全(拿到局部未来)、块间保持因果。这是延迟与质量的折中旋钮:块越大、上下文越足、质量越高,但要多等一个块时长、延迟越大。
③ 有限前瞻(look-ahead): 允许模型偷看未来一小段(如 200 毫秒)再出当前结果——用一点固定延迟,显著回收因果性损失的精度。
结构上,CTC 这类无自回归模型天然适合流式(每帧独立、来一帧算一帧),Whisper 式自回归解码器要流式化则需额外设计——这也是低延迟场景常见 CTC/Transducer 系的原因。
# 流式的本质:维护滑动缓冲,来一块算一块,只依赖过去 import numpy as np class StreamingASR: def __init__(self, chunk=0.32, lookahead=0.2, sr=16000): self.buf = np.zeros(0, dtype=np.float32) # 累积缓冲 self.chunk = int(chunk * sr) # 每块样本数 self.la = int(lookahead * sr) # 前瞻窗口(换质量) def feed(self, audio): # 麦克风源源不断喂进来 self.buf = np.concatenate([self.buf, audio]) while len(self.buf) >= self.chunk + self.la: # 取"当前块 + 一点前瞻"送模型,但只输出当前块的结果 window = self.buf[: self.chunk + self.la] yield self.decode_causal(window) # 因果解码:不回看已定稿 self.buf = self.buf[self.chunk:] # 滑动,丢掉已处理 # chunk/lookahead 就是你手里的"延迟↔质量"两个旋钮