把一段音频喂给一个只会读文字的模型,让它吐出转写结果——这件事能做,但能做成什么样,取决于你对"改造"的预期。
这篇教程带你走完一次完整的实验:以 AI 词典:Embedding">EmbeddingGemma 这类文本嵌入模型为起点,外挂一个语音编码器和一个适配层,把它拼成一条能出字的语音识别流水线。走完之后你会拿到两样东西:一份能跑通的代码,以及一个诚实的结论——在小规模数据下,它达不到专用 ASR 的水平,但它揭示了"音频进入文本嵌入空间"这条路径的可行性,这条路径在做语音检索、语音指令匹配时反而更自然。
这篇能做出什么
- 一条端到端的实验流水线:
wav → log-Mel → 语音编码器 → 适配层 → 文本嵌入模型 → CTC 头 → 文本 - 一个可训练的适配层,参数量不大,能在单卡上跑起来
- 一套评估与对比脚本:用 WER/CER 把这条流水线和专用 ASR 放在同一个评测集上比
- 一条替代路线:不做逐字生成,改做"语音向量 → 文本向量库最近邻检索",这条路线更贴合嵌入模型原本的训练目标
预期要放在合理位置:专用 ASR 在大量配对数据上训练过,还配有专门的语言模型解码,通用识别质量会明显更好。这次改造的价值在于理解结构、验证想法、以及在特定检索场景里拿到可用的东西。
前置条件清单
环境
```bash
python -m venv venv && source venv/bin/activate
pip install torch torchaudio transformers datasets soundfile jiwer accelerate
```
具体版本的兼容组合以 PyTorch 与 Hugging Face 官方文档当前版本为准。
硬件
一块支持 bf16 的 GPU 会让实验顺畅很多。显存紧张时用梯度检查点和 bf16 混合精度,具体开关以各库文档为准。
模型权重
- 一个文本嵌入模型(如 EmbeddingGemma 系列,具体仓库名与许可以官方页面为准)
- 一个预训练语音编码器(Hugging Face 上有多种开源选择,选一个带
last_hidden_state输出的即可)
部分模型需要先在模型页同意许可条款,再用 huggingface-cli login 配置访问令牌。
数据
公开语音数据集都可以用,例如 LibriSpeech、Common Voice、AISHELL 等,下载方式与许可条款以各自官方页面为准。每条样本需要两样东西:一段音频、一份与之逐字对应的转写文本。先在几百到几千条的量级上做验证,不要一上来就上全量。
分步骤
第 1 步:先想清楚改造的方向
文本嵌入模型的输出是句子级向量,它没有语言模型头,也不会自回归生成。所以"改成 STT"其实有三种做法:
1. CTC 路线:取文本编码器的 token 级隐藏状态,接一个线性层到字符表,用 CTC 损失训练。这是本篇的主线。
2. 自回归路线:加一个解码器头,用交叉熵训练。改动量大,且要处理双向注意力与因果注意力的差异。
3. 检索路线:不生成文本,把音频映射到文本嵌入空间,做最近邻匹配。改动最小,但输出被限制在候选库内。
先选一条。下面主线走 CTC。
第 2 步:统一音频前端
采样率不统一是后面一切错误的源头。先把所有音频统一到同一个采样率、单声道、float32。
```python
audio_io.py
import torch
import torchaudio
import soundfile as sf
TARGET_SR = 16000
def load_audio(path: str, target_sr: int = TARGET_SR) -> torch.Tensor:
wav, sr = sf.read(path, dtype="float32", always_2d=True)
wav = torch.from_numpy(wav).mean(dim=1) # 多声道降为单声道
if sr != target_sr:
wav = torchaudio.functional.resample(wav, sr, target_sr)
return wav # (T,)
def log_mel(wav: torch.Tensor, sample_rate: int = TARGET_SR) -> torch.Tensor:
mel = torchaudio.transforms.MelSpectrogram(
sample_rate=sample_rate,
n_fft=400,
hop_length=160,
n_mels=80,
power=2.0,
)(wav)
return torch.log(mel.clamp(min=1e-6)) # (n_mels, T')
```
不过如果你选的语音编码器本身接受原始波形输入,直接把 wav 喂给它也可以,前端这一步可以省掉。是否接受波形,看该模型的文档。
第 3 步:准备配对数据
写一个 Dataset,把音频路径和转写文本配成对,并且建立一个字符级词表。
```python
data.py
import torch
from torch.utils.data import Dataset
from audio_io import load_audio
class SpeechTextDataset(Dataset):
def __init__(self, records, char2id, max_sec=15.0):
records: [{"audio": "...", "text": "..."}],text 已做小写与去标点归一化
self.records = records
self.char2id = char2id
self.max_len = int(max_sec * 16000)
def __len__(self):
return len(self.records)
def __getitem__(self, i):
r = self.records[i]
wav = load_audio(r["audio"])[: self.max_len]
targets = torch.tensor(
[self.char2id[c] for c in r["text"] if c in self.char2id],
dtype=torch.long,
)
return wav, targets
def build_vocab(texts):
chars = sorted({c for t in texts for c in t})
char2id = {c: i + 1 for i, c in enumerate(chars)} # 0 预留给 CTC blank
id2char = {i: c for c, i in char2id.items()}
return char2id, id2char
```
中文数据建议直接按字建表,这会得到几千个字符的字符表,比用嵌入模型原生的超大词表小得多,CTC 头也小得多,训练更稳。
第 4 步:搭适配层
适配层要解决两件事:把语音编码器的特征维度对齐到文本嵌入模型的隐藏维度;把帧率降下来,让音频帧数与文本长度处在同一量级。
```python
adapter.py
import torch
import torch.nn as nn
class AudioAdapter(nn.Module):
"""(B, T, D_speech) -> (B, T//4, D_text)"""
def __init__(self, in_dim: int, out_dim: int):
super().__init__()
self.conv = nn.Sequential(
nn.Conv1d(in_dim, in_dim, kernel_size=5, stride=2, padding=2),
nn.GELU(),
nn.Conv1d(in_dim, out_dim, kernel_size=5, stride=2, padding=2),
nn.GELU(),
)
self.norm = nn.LayerNorm(out_dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = x.transpose(1, 2) # (B, D_speech, T)
x = self.conv(x)
x = x.transpose(1, 2) # (B, T//4, D_text)
return self.norm(x)
```
两层 stride=2 的卷积把帧率降到四分之一。如果你发现音频帧数仍然远大于字符数,再加一层。
第 5 步:把嵌入模型接上去
关键点:不要取池化后的句向量,要取 token 级的隐藏状态。池化层是为句子相似度准备的,它会把时间信息压没。
```python
model.py
import torch
import torch.nn as nn
class EmbeddingAsr(nn.Module):
def __init__(self, speech_encoder, text_encoder, adapter, vocab_size,
freeze_speech=True, freeze_text=True):
super().__init__()
self.speech = speech_encoder
self.text = text_encoder
self.adapter = adapter
d_text = text_encoder.config.hidden_size
self.head = nn.Linear(d_text, vocab_size)
if freeze_speech:
for p in self.speech.parameters():
p.requires_grad = False
if freeze_text:
for p in self.text.parameters():
p.requires_grad = False
def forward(self, wav, attention_mask=None):
with torch.no_grad():
feat = self.speech(wav).last_hidden_state # (B, T, D_speech)
h = self.adapter(feat) # (B, T//4, D_text)
把音频特征当作"输入的词嵌入"送进文本编码器
out = self.text(inputs_embeds=h, attention_mask=attention_mask)
logits = self.head(out.last_hidden_state) # (B, T//4, V)
return logits
```
这里用 inputs_embeds 把音频特征"伪装"成文本词向量,让嵌入模型的编码器对它做一轮加工。是否支持 inputs_embeds、是否接受 attention_mask,以该模型的文档为准。如果模型不接受,去掉 self.text 这一层,直接 self.head(h) 也能训练,只是少了一层上下文建模。
第 6 步:CTC 损失与解码
CTC 需要输入长度和标签长度,注意形状要转成 (T, B, V)。
```python
train_step.py
import torch
import torch.nn as nn
ctc_loss = nn.CTCLoss(blank=0, zero_infinity=True)
def compute_loss(model, wavs, targets, input_lens, target_lens):
logits = model(wavs) # (B, T', V)
log_probs = logits.log_softmax(-1).transpose(0, 1) # (T', B, V)
return ctc_loss(log_probs, targets, input_lens, target_lens)
@torch.no_grad()
def greedy_decode(logits, id2char):
ids = logits.argmax(-1) # (B, T')
results = []
for seq in ids:
prev, chars = None, []
for i in seq.tolist():
if i != prev and i != 0:
chars.append(id2char.get(i, ""))
prev = i
results.append("".join(chars))
return results
```
input_lens 要按适配层实际输出的长度来填,不能直接抄音频原始长度,卷积下采样后长度会变。
第 7 步:先过拟合一百条
在正式训练之前,先做一次"能不能记住"的测试:拿 100 条样本反复训,看训练损失能不能掉到接近零、解码结果能不能复现训练集文本。
```python
sanity_check.py
optimizer = torch.optim.AdamW(
[p for p in model.parameters() if p.requires_grad], lr=1e-3
)
for step in range(2000):
wavs, targets, in_lens, tgt_lens = next(iter(loader))
loss = compute_loss(model, wavs, targets, in_lens, tgt_lens)
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(
[p for p in model.parameters() if p.requires_grad], 1.0
)
optimizer.step()
if step % 200 == 0:
print(step, float(loss))
```
如果这一步都过不了,后面上全量数据也没意义。这一步能过,说明结构、形状、损失函数都是对的。
第 8 步:训练、评估、算 WER
过拟合测试通过后,换成完整训练集,冻结语音塔和文本塔,只训适配层和 CTC 头。之后在留出的测试集上算 WER 与 CER。
```python
evaluate.py
import jiwer
def normalize(s: str) -> str:
return " ".join(s.lower().split())
def score(refs, hyps):
refs = [normalize(r) for r in refs]
hyps = [normalize(h) for h in hyps]
return {
"wer": jiwer.wer(refs, hyps),
"cer": jiwer.cer(refs, hyps),
}
```
文本归一化要在对比双方身上做同样处理:统一大小写、去标点、数字统一写法。归一化不一致会让 WER 数据完全失真。
第 9 步:和专用 ASR 放在一起比
拉一个专用的语音识别模型,用同一批音频、同一套归一化规则算出结果。
```python
compare.py
from transformers import pipeline
from evaluate import score
asr = pipeline("automatic-speech-recognition", model="<专用ASR模型>",
generate_kwargs={"language": "chinese"})
refs, hyps_dedicated, hyps_ours = [], [], []
for sample in test_set:
refs.append(sample["text"])
hyps_dedicated.append(asr(sample["audio"])["text"])
hyps_ours.append(run_our_pipeline(sample["audio"]))
print("专用 ASR:", score(refs, hyps_dedicated))
print("本次改造:", score(refs, hyps_ours))
```
具体用哪个专用 ASR,以你手上可用的开源模型为准。对比时注意三点:同一批音频、同一套归一化、同一个说话人不重叠的划分。如果训练集和测试集里有同一个说话人,两边的分数都会虚高。
第 10 步:换成检索路线再试一次
CTC 路线要求模型逐字输出,这对嵌入模型来说有点勉强。更贴合它本性的做法是检索:
1. 把一批候选文本(常用口令、意图短句、标题)用嵌入模型编码成向量,建一个向量库。
2. 训练语音编码器 + 适配层,用对比损失把音频向量拉近对应文本向量、推远其他文本向量。
3. 推理时,音频向量在向量库里做最近邻,取 top-k。
```python
retrieval.py
import torch
import torch.nn.functional as F
def info_nce(audio_emb, text_emb, temperature=0.07):
a = F.normalize(audio_emb, dim=-1)
t = F.normalize(text_emb, dim=-1)
logits = a @ t.t() / temperature
labels = torch.arange(a.size(0), device=a.device)
loss_a = F.cross_entropy(logits, labels)
loss_t = F.cross_entropy(logits.t(), labels)
return (loss_a + loss_t) / 2
```
这条路线的输出被限制在候选库内,不能自由转写。但它能给出置信度排序,也更容易在小数据上收敛,很适合语音指令、语音检索这类任务。
常见坑与排错
采样率不一致。 训练用 16 kHz、推理用 44.1 kHz,模型会直接失效。所有入口都强制走 load_audio。
别拿句子向量做序列任务。 池化层会把时间维度压掉。要用 last_hidden_state。
CTC 的 blank 与 padding 混了。 blank 是词表里的 0 号,用于对齐;padding 是批次补齐。两者不能共用一个索引。
input_lens 写错。 下采样会改变长度,务必用适配层输出张量的实际长度,而不是音频样本数。
损失变 NaN。 常见原因是学习率过大、没做梯度裁剪、或 log_softmax 用错位置。CTC 要在 log 域计算,先 log_softmax 再传给 CTCLoss。
损失在降但字全是乱的。 说明模型学到了长度分布但没学到内容。检查转写文本的归一化是否和词表构建时一致,检查是否把音频和文本错位配对了。
小数据全过拟合。 训练集 WER 很低、测试集 WER 很高,这是数据量不足的典型表现。加数据比调参有用。也可以试试只微调文本侧的低秩适配器(LoRA)而不是全量冻结,但要注意过拟合。
验证集有说话人泄漏。 按说话人划分训练/验证/测试,不要按样本随机划分。
评估时忘了归一化。 加一句 normalize 前后对比一下分数,你会看到差别有多大。
下一步建议
- 加语言模型解码。 在 CTC 输出上接一个 beam search 加 n-gram 或神经语言模型,通常能明显降低错误率。相关工具以各库官方文档为准。
- 换更强的语音编码器。 语音侧的能力上限很大程度上由编码器决定,换一个预训练更充分的编码器往往比调适配层的收益更大。
- 把数据量提上去。 几百小时和几十小时是两个世界。在数据没到位之前,不要指望这条流水线能通用。
- 认真做检索路线。 如果你的实际需求是"识别有限集合里的语音指令",检索式方案的成本和效果平衡通常更好。
- 做量化与推理优化。 部署前可以看看 ONNX Runtime、量化方案,具体支持情况以官方文档为准。
- 记录你的评测协议。 把数据划分、归一化规则、对比模型版本固定下来,后续每次改动才有可比性。
最后留一句提醒:这次实验的产出,适合用来理解跨模态适配的原理,也适合做特定场景的检索。要把它当成通用语音识别来交付,还需要远多于教程级别的数据和算力。
