跳到主内容
快讯直播
AI智模界
教程

把文本嵌入模型改造成语音转文字:动手实验

把一段音频喂给一个只会读文字的模型,让它吐出转写结果——这件事能做,但能做成什么样,取决于你对"改造"的预期。

这篇教程带你走完一次完整的实验:以 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、量化方案,具体支持情况以官方文档为准。
  • 记录你的评测协议。 把数据划分、归一化规则、对比模型版本固定下来,后续每次改动才有可比性。

最后留一句提醒:这次实验的产出,适合用来理解跨模态适配的原理,也适合做特定场景的检索。要把它当成通用语音识别来交付,还需要远多于教程级别的数据和算力。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。