音频、视频与统一多模态系统
一张照片能告诉我们杯子在哪里,却不一定能告诉我们谁刚刚把它拿走。一句文字能告诉我们说了什么,却未必保留犹豫、停顿和说到一半的改口。
声音和视频给前面熟悉的表示学习加上了一条不能随便丢掉的轴:时间。同样的画面换个顺序,可能从“拿起”变成“放下”;同样的词换种语调,也可能从陈述变成疑问。
这一章先把声音变成可以计算的数组,再让两个真实模型完成“文字 → 语音 → 文字”。接着用一个亮点移动的小视频,亲手验证为什么平均几帧会丢掉运动方向。最后把这些观察放回实时系统:模型算得快,和用户觉得回应及时,中间还隔着哪些步骤?
1. 声音首先是一串随时间变化的数
麦克风把空气振动转换成电信号,数字系统再按一定间隔记录数值。每秒记录多少次,叫采样率,记作 ,单位是 Hz。
若 ,每秒就有 16000 个采样点。第 个采样点对应的时间为:
于是,两秒单声道声音的形状可以是 (32000,)。如果每个样本用 16 位整数保存,未压缩音频数据约占 字节,还没算文件头。双声道则需要分别保存两个通道。
数组里的数字本身不携带秒数。把一段 16 kHz 音频错误标成 8 kHz,播放器会按一半速度读这些数,时长与音高一起改变。改文件里的采样率声明,不等于正确重采样。 重采样需要计算新的采样点;降低采样率前,还要处理原信号中过高的频率。
为什么不能想采多慢,就采多慢
看下面两条余弦波:一条每秒振动 300 次,另一条振动 700 次。连续曲线明显不同,但每隔 1 ms 看一眼,它们恰好给出相同的数。
图 1:绿色点是数字系统实际记录的值。只留下这些点,就无法分辨上下两条连续曲线。
因为对于整数 :
这叫混叠。在理想带限条件下,要完整保留最高频率为 的信号,需要采样率高于 ;真实设备还要为滤波器留出过渡空间。16 kHz 音频不能完整保留 8 kHz 以上的频率内容。
这里讨论的是采样时信息能否区分,不是说“音频采样率高,模型就一定更聪明”。模型通常有自己的输入采样率约定,喂错了会让后续所有时间与频率解释都错位。
2. 从波形到频谱:这一小段里有哪些振动
只看波形,往往能辨认哪里安静、哪里声音较强,却很难直接看出发了哪个音。我们可以换个问题:在一小段时间里,不同频率的振动各有多强?
方法与第一章的点积有些相像。拿一个给定频率的正弦、余弦,与这一小段波形逐项相乘再相加;如果节奏相合,对应的分量就会较强。对一组频率重复这个过程,就得到了频谱。FFT 是高效完成这类计算的方法。
人说话时,频率组成一直在变,所以不能只给整句话做一次频谱。我们把一个小窗口沿时间移动:分析这一小段,再向前挪一点,继续分析。这就是短时傅里叶变换(STFT)的基本思路。
本章的简化实现使用:
- 每个窗口 400 个采样点,在 16 kHz 下为 25 ms。
- 每次移动 160 个点,也就是 10 ms。
- 先乘 Hann 窗,让片段两端平滑变小,再计算频谱。
对一秒、16000 点的信号,不在边缘补零时,窗口数为:
实数信号保留非负频率后,有 个频率格。因此这份频谱的形状是 (201, 98):一条轴是频率,一条轴是时间。频率格间隔为 Hz;它不等于在任意复杂声音中都能以 40 Hz 精度分开两个声源。
下图来自本章真实合成的一句语音:
图 2:下方颜色表示相对功率,越亮越强,显示范围为峰值以下 60 dB;纵轴是线性频率。它是本章画的说明图,不是直接从识别模型内部导出的特征。
语音模型还常用 Mel 频谱:将频率格按一组滤波器汇总,在较低频率处分得更细,然后常对强度取对数。不要把“80 个 Mel 频带”理解成“只听见 80 个频率”,它们各自汇总了一段频率范围。
本章使用的 Whisper 处理器将短音频补齐到 30 秒,并产生 (1, 80, 3000) 的对数 Mel 特征,三条轴是批次、频带和时间位置。补齐长度不表示原句真的有 30 秒,也不表示所有模型都采用这个形状。
还有一些模型直接从波形学习表示,例如 wav2vec 2.0。预训练时可以遮住部分位置,利用上下文学习声音的结构,再接识别等任务。手工定义的频谱与学习得到的特征是不同选择;都需要保留清楚的时间尺度。
3. 语音识别与语音合成,方向相反,难点不同
自动语音识别(ASR)把音频转换成文字。以编码器—解码器结构为例,编码器读取声音特征,解码器逐步输出文字词元:
是音频表示, 是第 个输出词元。这和之前的条件语言模型很接近,只是条件从一段文字换成了声音。
文字与音频位置并不一一对应:一个词可能拖得很长,停顿可能没有对应文字。另一类识别方法使用 CTC 等目标,把逐帧预测与更短的文字序列对齐。本章采用的 Whisper则是编码器—解码器模型;识别、翻译、说话人分离和情绪判断也不是同一个任务,不能因为能转写就默认全都会。
文本转语音(TTS)把文字转换成声音。同一句话可以由不同声音、语速和重音说出来,所以它也有上一章那种“一对多”的生成性质。系统既要决定各部分持续多久,还要产生连续波形。
本章使用 MMS 项目的英文 TTS 检查点,它采用 VITS 架构。可以先把它理解为:文本提供条件,模型安排时长、形成声学潜表示,再把表示解码成波形。具体的变分建模、可逆变换与对抗训练,见 VITS 原论文;语言覆盖与数据工作见 MMS 论文。这里的 TTS 只做英文朗读,没有训练或克隆某个指定人的声音。
用两个真实模型做一次往返
我们固定四句自编英文:
| 编号 | 朗读目标 |
|---|---|
| 0 | A blue cup is on the table. |
| 1 | The workshop opens at nine and closes at six. |
| 2 | Please pick up order number three seven two. |
| 3 | There are two red circles and one blue square. |
先用 facebook/mms-tts-eng 合成,再让 openai/whisper-tiny.en 识别。每句有干净、20 dB 加噪、0 dB 加噪三个版本,另外加一段四秒静音,共 13 次识别。所有提示、种子与噪声强度都先固定,再运行。
信噪比在这里按整段音频的平均功率定义:
20 dB 表示信号功率是噪声的 100 倍,0 dB 表示两者相同。我们加入高斯白噪声;它只是一个受控干扰,不能代表回声、多人同时说话、风声或真实麦克风失真。
混合后若峰值过高,整段共同缩小,再保存为 16 kHz、单声道、16 位 PCM WAV,避免削波。识别读取的就是这些保存文件;共同缩放不会改变理想信噪比,量化只引入很小的数值误差。
可以比较同一句话的两份音频:
干净的合成语音:
加入 0 dB 白噪声:
这两段是模型生成的教学音频,不是真实人物的录音。试听时也可以问自己:哪些词还容易分辨,哪些位置开始含混?本书没有开展主观音质评测,不会把试听感受写成测得的 MOS 分数。
4. 一份转写结果,怎样评分才不误导
先看完整的文字输出。20 dB 条件下的四条转写与干净条件逐字一致:
| 编号 | 干净 / 20 dB 转写 | 0 dB 转写 |
|---|---|---|
| 0 | A blue cup is on the table. | A blue cop is on the table. |
| 1 | The workshop opens at 9 and closes at 6. | The work ship opens at mine and closes at stake. |
| 2 | Please pick up order number 372. | Please pick up order number 272. |
| 3 | There are two red circles and one blue square. | There are two red circles and one blue square. |
干净条件下,nine 变成了 9,three seven two 变成了 372。这些写法变化与强噪声下的 cup → cop、372 → 272,显然不该被解释成完全一样的错误。
常见的词错误率(WER)先用最少编辑把转写与参考文本对齐,再数替换、删除和插入:
是参考文本的词数, 分别是替换、删除、插入次数。比如参考 a red cup,转写为 a blue cup now,需要一次替换、一次插入,WER 为 。插入很多时,WER 可以超过 1。
本例采用很简单且明确的规范化:转成小写,取连续英文字母或数字作为词,忽略标点,不把数字改写成英文,也不替换同义词。四句参考文本合计 33 个词。于是:
| 条件 | 总编辑次数 / 参考词数 | 往返 WER |
|---|---|---|
| 干净合成音频 | 5 / 33 | 0.152 |
| 20 dB | 5 / 33 | 0.152 |
| 0 dB | 8 / 33 | 0.242 |
图 3:统计来自全部十二条有朗读目标的结果。采用本章的词面规范化,数字写法不同也会计错。
干净音频的五次编辑全部来自数字写法:nine/six 各有一次替换,three seven two → 372 需要一次替换与两次删除。这个 0.152 不能被表述成“模型听错了 15.2% 的语义”。若研究订单号能否正确恢复,就应该另外检查规范化后的订单号字段,并先说明规范化规则。
还要注意,这里参考的是送入 TTS 的朗读目标,不是人工逐句确认过的真实转写。如果 TTS 自己把某个词说得含混,往返结果同时包含合成与识别两侧的问题。因此这些数是受控往返实验,不是 Whisper 在真实人声上的独立基准成绩,也不是对 TTS 自然度的评价。
没有声音,也可能生成文字
四秒全零静音的原始输出是 you。参考文本为空,WER 分母为零,我们将它记为未定义,并单独记录“一次无依据的词插入”,不把它混进前面的 33 词分母。
这次调用没有语音活动检测(VAD)或额外静音拒绝逻辑,模型仍执行了一次短音频解码。它说明“调用成功、输出像一句话”不能证明声音里真的有这些词。实际应用可以加入声音活动判断和无语音分数等机制,但阈值也要评估,避免漏掉轻声、短词或句尾。
重跑与检查记录
先运行轻量检查,不下载模型:
python3 src/examples/speech_roundtrip.py
输出为:
4 句合成语音;干净、20 dB、0 dB 各 4 段,另有 1 段静音。
clean:往返词错误 5/33,WER=0.152
snr20:往返词错误 5/33,WER=0.152
snr0:往返词错误 8/33,WER=0.242
静音输出:' you';插入词数=1
默认模式只核对保存记录;这不是人声基准,也没有测量主观音质。
要重新合成并识别,另选空目录:
uv run --python 3.12 \
--with-requirements src/examples/requirements-audio.txt \
python src/examples/speech_roundtrip.py --run --out runs/speech-new
固定修订为:
- MMS-TTS:
c71de0fe7204c83f1c10820a7d696d0b450048ba,模型说明标示 CC BY-NC 4.0。 - Whisper tiny.en:
87c7102498dcde7456f24cfd30239ca606ed9063,该模型仓库说明标示 Apache-2.0。
权重文件分别为 145,227,512 和 151,060,136 字节,不随书分发。环境为 Python 3.12.12、Transformers 4.57.3、PyTorch 2.14.1、NumPy 2.5.4;CPU FP32、4 线程,识别使用 eager attention、贪心解码、最多 64 个新词元,不请求时间戳。英文模型的任务和起始标记沿用固定修订的生成配置,不加入参考答案提示。
完整记录保留四次合成、十三次识别的输入、种子、WAV 指纹、模型指纹、全部输出词元和计时。代码使用完整生成返回值保留开始与结束标记;只看库默认清理过的词元列表,可能误以为没有生成结束标记。
展开语音往返实验代码
"""第 43 章:MMS-TTS 合成与 Whisper 识别;默认只核对随书记录。"""
from argparse import ArgumentParser
from hashlib import sha256
import json
from pathlib import Path
import platform
import re
from time import perf_counter
import wave
MODELS = {
"tts": ("facebook/mms-tts-eng", "c71de0fe7204c83f1c10820a7d696d0b450048ba"),
"asr": ("openai/whisper-tiny.en", "87c7102498dcde7456f24cfd30239ca606ed9063"),
}
TEXTS = [
"A blue cup is on the table.",
"The workshop opens at nine and closes at six.",
"Please pick up order number three seven two.",
"There are two red circles and one blue square.",
]
CONDITIONS = [("clean", None), ("snr20", 20), ("snr0", 0)]
DEFAULT = Path(__file__).parent / "data/speech/report.json"
def file_hash(path):
h = sha256()
with Path(path).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def words(text):
# 明确的教学规范化:小写,字母/数字连续串;不改写数字、同义词。
return re.findall(r"[a-z0-9]+", text.lower())
def edit_counts(reference, hypothesis):
a, b = words(reference), words(hypothesis)
cost = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
for i in range(len(a) + 1):
cost[i][0] = i
for j in range(len(b) + 1):
cost[0][j] = j
for i in range(1, len(a) + 1):
for j in range(1, len(b) + 1):
cost[i][j] = min(cost[i-1][j-1] + (a[i-1] != b[j-1]),
cost[i-1][j] + 1, cost[i][j-1] + 1)
i, j = len(a), len(b)
counts = {"substitutions": 0, "deletions": 0, "insertions": 0, "reference_words": i}
while i or j:
if i and j and cost[i][j] == cost[i-1][j-1] + (a[i-1] != b[j-1]):
counts["substitutions"] += int(a[i-1] != b[j-1])
i, j = i - 1, j - 1
elif i and cost[i][j] == cost[i-1][j] + 1:
counts["deletions"] += 1
i -= 1
else:
counts["insertions"] += 1
j -= 1
counts["errors"] = cost[-1][-1]
counts["wer"] = counts["errors"] / len(a) if a else None
return counts
def write_wav(path, values, sample_rate=16000):
import numpy as np
peak = float(np.abs(values).max(initial=0))
gain = min(1.0, 0.95 / peak) if peak else 1.0
pcm = np.rint(values * gain * 32767).astype("<i2")
with wave.open(str(path), "wb") as output:
output.setnchannels(1)
output.setsampwidth(2)
output.setframerate(sample_rate)
output.writeframes(pcm.tobytes())
return gain
def read_wav(path):
import numpy as np
with wave.open(str(path), "rb") as audio:
assert audio.getnchannels() == 1 and audio.getsampwidth() == 2
return np.frombuffer(audio.readframes(audio.getnframes()), dtype="<i2").astype("float32") / 32767, audio.getframerate()
def add_noise(clean, snr_db, seed):
import numpy as np
generator = np.random.default_rng(seed)
noise = generator.normal(size=len(clean))
scale = np.sqrt(np.mean(clean ** 2) / (10 ** (snr_db / 10) * np.mean(noise ** 2)))
return noise * scale
def validate(path=DEFAULT):
report = json.loads(path.read_text())
assert report["source_sha256"] == file_hash(__file__), "脚本与记录版本不同"
assert report["models"] == {key: list(value) for key, value in MODELS.items()}
assert report["texts"] == TEXTS and len(report["tts"]) == 4
assert [row["id"] for row in report["asr"]] == [
f"{i}-{name}" for i in range(4) for name, _ in CONDITIONS] + ["silence"]
for row in report["asr"]:
filename = path.parent / row["audio"]
assert file_hash(filename) == row["audio_sha256"]
with wave.open(str(filename), "rb") as audio:
assert audio.getframerate() == 16000
assert audio.getnchannels() == 1 and audio.getsampwidth() == 2
assert audio.getnframes() == row["samples"]
assert row["score"] == edit_counts(row["reference"], row["text"])
assert row["eos"] == (row["token_ids"][-1] == report["eos_token_id"])
assert row["feature_shape"] == [1, 80, 3000]
assert row["seconds"] > 0
return report
def run(folder):
import numpy as np
import torch
import transformers
from huggingface_hub import snapshot_download
from transformers import AutoTokenizer, VitsModel, WhisperProcessor, WhisperForConditionalGeneration
if folder.exists() and any(folder.iterdir()):
raise ValueError("请使用空输出目录,保留已有实验结果")
folder.mkdir(parents=True, exist_ok=True)
torch.set_num_threads(4)
torch.use_deterministic_algorithms(True)
paths = {name: Path(snapshot_download(
model, revision=revision,
allow_patterns=["*.json", "*.txt", "model.safetensors", "README.md"]))
for name, (model, revision) in MODELS.items()}
tokenizer = AutoTokenizer.from_pretrained(paths["tts"], local_files_only=True)
tts = VitsModel.from_pretrained(paths["tts"], torch_dtype=torch.float32,
local_files_only=True).eval()
sample_rate = tts.config.sampling_rate
assert sample_rate == 16000
report = {
"models": {key: list(value) for key, value in MODELS.items()}, "texts": TEXTS,
"source_sha256": file_hash(__file__), "tts": [], "asr": [],
"environment": {"python": platform.python_version(), "platform": platform.platform(),
"torch": torch.__version__, "numpy": np.__version__,
"transformers": transformers.__version__,
"device": "cpu", "dtype": "float32", "threads": 4},
"config": {"sample_rate": 16000, "max_new_tokens": 64, "do_sample": False,
"num_beams": 1, "return_timestamps": False,
"return_dict_in_generate": True, "vad": False,
"tts_seed_start": 2026, "noise_seed_start": 3030},
"weights": {key: {"bytes": (path / "model.safetensors").stat().st_size,
"sha256": file_hash(path / "model.safetensors")}
for key, path in paths.items()},
"tts_config": tts.config.to_dict(),
}
cases = []
for i, text in enumerate(TEXTS):
torch.manual_seed(2026 + i)
inputs = tokenizer(text, return_tensors="pt")
start = perf_counter()
with torch.inference_mode():
values = tts(**inputs).waveform[0].numpy()
elapsed = perf_counter() - start
gain = write_wav(folder / f"{i}-clean.wav", values, sample_rate)
clean, _ = read_wav(folder / f"{i}-clean.wav")
report["tts"].append({"id": i, "text": text, "seed": 2026 + i,
"seconds": elapsed, "samples": len(clean), "pcm_gain": gain,
"raw_sha256": sha256(values.tobytes()).hexdigest()})
for name, snr in CONDITIONS:
filename = f"{i}-{name}.wav"
mix_gain = 1.0
if snr is not None:
noise = add_noise(clean, snr, 3030 + i)
mix_gain = write_wav(folder / filename, clean + noise, sample_rate)
cases.append({"id": f"{i}-{name}", "condition": name, "audio": filename,
"reference": text, "snr_db": snr, "mix_gain": mix_gain})
print(f"TTS {i}:{len(clean) / sample_rate:.2f} 秒音频", flush=True)
del tts
write_wav(folder / "silence.wav", np.zeros(4 * sample_rate))
cases.append({"id": "silence", "condition": "silence", "audio": "silence.wav",
"reference": "", "snr_db": None, "mix_gain": 1.0})
processor = WhisperProcessor.from_pretrained(paths["asr"], local_files_only=True)
asr = WhisperForConditionalGeneration.from_pretrained(
paths["asr"], torch_dtype=torch.float32, attn_implementation="eager",
local_files_only=True).eval()
report["eos_token_id"] = asr.generation_config.eos_token_id
report["asr_generation_config"] = asr.generation_config.to_dict()
report["feature_config"] = processor.feature_extractor.to_dict()
for case in cases:
values, rate = read_wav(folder / case["audio"])
start = perf_counter()
inputs = processor(values, sampling_rate=rate, return_tensors="pt",
return_attention_mask=True)
with torch.inference_mode():
generated = asr.generate(**inputs, do_sample=False, num_beams=1,
max_new_tokens=64, return_timestamps=False,
return_dict_in_generate=True)
# 普通返回值会移除 EOS;完整返回值保留开始/结束标记供核对。
ids = generated.sequences[0]
text = processor.decode(ids, skip_special_tokens=True)
elapsed = perf_counter() - start
row = {**case, "samples": len(values), "duration": len(values) / rate,
"audio_sha256": file_hash(folder / case["audio"]),
"feature_shape": list(inputs.input_features.shape),
"token_ids": ids.tolist(), "text": text,
"eos": bool(ids[-1] == report["eos_token_id"]),
"seconds": elapsed, "rtf": elapsed / (len(values) / rate),
"score": edit_counts(case["reference"], text)}
report["asr"].append(row)
(folder / "report.json").write_text(json.dumps(
report, ensure_ascii=False, indent=2) + "\n")
print(f"ASR {case['id']}:{text}", flush=True)
validate(folder / "report.json")
return report
def show(report):
print("4 句合成语音;干净、20 dB、0 dB 各 4 段,另有 1 段静音。")
for name, _ in CONDITIONS:
rows = [r["score"] for r in report["asr"] if r["condition"] == name]
errors = sum(r["errors"] for r in rows)
total = sum(r["reference_words"] for r in rows)
print(f"{name}:往返词错误 {errors}/{total},WER={errors / total:.3f}")
silence = report["asr"][-1]
print(f"静音输出:{silence['text']!r};插入词数={silence['score']['insertions']}")
print("默认模式只核对保存记录;这不是人声基准,也没有测量主观音质。")
if __name__ == "__main__":
parser = ArgumentParser(description=__doc__)
parser.add_argument("--run", action="store_true")
parser.add_argument("--out", type=Path)
parser.add_argument("--record", type=Path, default=DEFAULT)
args = parser.parse_args()
if args.run and args.out is None:
parser.error("--run 需要 --out 指定空输出目录")
show(run(args.out) if args.run else validate(args.record))
也可下载脚本。第一次运行需要下载依赖和权重;检查已有记录与真实重新生成是两个不同操作。
5. 视频不能只当作一袋图片
假设一个小亮点从左向右移动。把所有帧倒过来播放,图片集合没有变,运动方向却反过来了。
图 4:每段共八帧,按 8 帧/秒解释,图中抽取四帧。下方是上方完整序列的逆序,时间仍从左向右前进。图片由程序生成。
若先给每帧提取一个向量,再取平均:
交换帧的顺序不会改变结果。后面即使接一个很强的分类器,也无法从这个平均值里恢复已经丢掉的顺序。
我们做一个小实验。每帧只提取亮度质心的横、纵坐标,再比较两个逻辑回归分类器:
mean:只看八帧的平均位置,共两个特征。ordered:保留第一帧和最后一帧的位置,共四个特征。
训练、验证、测试分别有 160、40、80 段视频。每条随机生成的向右轨迹,都与它的向左逆序成对,整对放在同一个划分;划分之间使用不同随机种子。分类器的 C=1 预先固定,没有在测试集上挑设置。
平均位置模型在验证、测试上都为 0.500;保留首尾位置的模型都为 1.000。这首先是一个信息是否存在的对照:前者对正反样本得到完全相同的输入,后者可以看到横坐标增加还是减少。
它并不是“首尾两帧足够理解所有视频”的证明。亮点若先向右再回到起点,首尾位置也会丢失过程;“打开柜门再关上”与“始终关着”就有类似问题。均匀抽帧还可能恰好错过短事件,所以需要报告采样时间,而不只是报告“取了八帧”。
更一般的视频模型会把帧序号、真实时间戳、空间位置一起编码,利用时间卷积、循环结构或时空注意力建模变化。TimeSformer是研究空间与时间注意力的一条入口。本章只实现亮点位置的对照,不把它称为自然视频理解模型。
6. 让声音和画面说的是同一时刻
设想一段视频里有人敲桌子。画面中手落下的时刻,与声音中敲击出现的时刻,应当对应起来。若音轨整体晚了 0.2 秒,单独看画面、单独听声音都可能合理,合在一起却会让动作与声音错位。
因此,多模态输入不仅要带“这是音频”“这是图片”,还要知道每一段对应的起止时间。常见表示可以包含:
这只是说明三类信息的示意写法,不是所有模型都直接做这三个向量相加。视频可能需要空间与时间位置,音频可能是一段连续区间,文字也可能对应某个时间范围。
本章的同步小例子用三次模拟事件:视频在 0.3、0.8、1.5 秒出现,音频在 0.5、1.0、1.7 秒出现。把两串事件沿时间平移,寻找重合最多的位置,可以找回“音频晚了 0.2 秒”。这相当于一个简单互相关计算。
它只验证已知对应事件上的时间偏移,不会自动识别“这个声音是否真的来自画面中的手”。多个相似事件、缺失事件或变化的时钟误差,都需要更细的处理。
图 5:两部分均为明确给定参数的计算模拟。上方讨论时间对齐,下方讨论队列积压;不把它们当作真实多模态模型的测速。
串联组件与统一模型
一种容易搭起来的语音助手是:
语音识别 → 文本语言模型 → 语音合成。
每一段都能单独替换、检查和评分。不过,如果中间只留下普通文字,语调、停顿、背景声音等信息可能消失;一个名字听错了,也可能继续传给后面的组件。
另一条路线让模型直接读取音频、视频表示,并生成文字或音频表示。音频生成可以使用神经音频编解码器:把一小段声音压成一组离散编号,模型预测编号,解码器再恢复波形。这些编号不必对应文字里的字或词;某些编号更关心声音细节。
Moshi研究同时建模用户声音与系统声音的并行流,以支持交叠、插话等对话现象;Qwen2.5-Omni讨论音视频的时间对齐,以及文字推理与语音输出模块的协作。这些例子说明了“统一”可以怎样组织,不意味着所有任务都使用同一个输入格式、同一个输出头或同一种损失。
这一章没有运行这些大型统一模型。这里的真实实验是独立 TTS 与 ASR 的离线往返;统一架构用于建立后续阅读论文时的比较框架。
7. 实时,不只是平均速度够快
处理四秒音频用了半秒,看起来很快。但如果必须等用户把四秒的话全部说完,再等待半秒识别、再生成回答,用户感受到的等待依然可能明显。
一个常用指标是实时率(real-time factor,RTF):
RTF 小于 1,表示处理这段音频花的时间短于它本身的时长。它没有告诉我们“第一段有用输出何时可用”,也没有包含所有排队、采集和播放等待。
本次十三次离线识别的实际调用耗时约 0.09~1.81 秒,RTF 约 0.023~0.816。计时从读取 WAV 后开始,包含特征提取、模型生成和文字解码,不含模型加载;没有做正式预热与重复计时。这些只是本次运行成本,不能据此声称已经实现实时语音对话。
分块之后,队列会发生什么
假设每 200 ms 到达一块音频,单个处理器按顺序工作:
是第 块到达的时刻, 是开始处理的时刻, 是处理时间, 是完成时刻。上一块还没结束,新块就要排队。
若每块处理 120 ms,每块都能在下一块到来之前完成,到达后的完成延迟始终是 120 ms。若每块处理 260 ms,每次多积压 60 ms;到第十块,完成延迟已经变成 800 ms。图 5 下方画的就是这两个假设。
真实系统还会有计算时间波动。平均值够快,也可能偶尔长时间停顿,所以应观察尾部延迟、队列长度和播放缓冲,而不只报一个平均 RTF。
流式处理也不等于“把一个离线模型每隔一小段重新跑一次”。模型是否读取了未来内容、窗口之间是否重复文字、缓存怎样继承、部分结果会不会被修正,都影响实际体验。TTS 能一次产生完整声音,也不表示它能及时交出第一小块音频。
打断之后,旧回答还会不会继续播放
用户说“等一下”,系统需要停止或调整旧回答。除了生成模型,还要处理已经排入播放队列、尚未播放的音频。常见做法是给每轮响应一个标识;新一轮接管后,丢弃旧标识对应的待播块,避免模型虽已取消,扬声器却还在把旧话说完。
这属于交互协议。评估时可以记录:用户开始插话、系统检测到插话、旧音频停止、新回答开始这几个时刻,分别定位感知、控制和播放延迟。本章没有实现在线麦克风、打断检测或播放服务,不能把上述队列模拟当成它们已经工作的证据。
8. 把表示、顺序与时间实验一起运行
基础脚本不下载模型:
uv run --python 3.12 --with-requirements requirements.txt \
python src/examples/audio_video_basics.py
输出如下:
1 kHz 采样:300 Hz 与 700 Hz 余弦的样本相同(数值误差 < 1e-12)。
短时频谱:[201, 98];前后主频=440/880 Hz
视频训练/验证/测试:160 / 40 / 80;每对正反序列在同一划分。
mean:验证准确率=0.500,测试准确率=0.500
ordered:验证准确率=1.000,测试准确率=1.000
正反视频的平均位置特征差=0.0
模拟音频比视频晚 0.2 秒。
假设每块处理 120 ms:第 10 块完成延迟=120 ms
假设每块处理 260 ms:第 10 块完成延迟=800 ms
视频只使用亮点质心;队列和同步是教学模拟,不是真实视频大模型或在线性能。
其中频谱部分使用一秒合成信号:前半秒 440 Hz,后半秒 880 Hz;它只是纯音实验,不假装是一段人声。视频部分每帧的质心直接从像素算出,没有使用预训练视觉编码器。
展开基础实验完整代码
"""第 43 章:采样、短时频谱、视频顺序和延迟队列;无模型下载。"""
from argparse import ArgumentParser
from hashlib import sha256
import json
from pathlib import Path
import platform
import numpy as np
from sklearn.linear_model import LogisticRegression
def tone_signal(rate=16000):
t = np.arange(rate) / rate
frequency = np.where(t < 0.5, 440.0, 880.0)
return 0.6 * np.sin(2 * np.pi * frequency * t)
def spectrum(values, window=400, hop=160):
frames = np.lib.stride_tricks.sliding_window_view(values, window)[::hop]
taper = np.hanning(window)
return np.abs(np.fft.rfft(frames * taper, axis=1)).T ** 2
def moving_video(rng):
# 一个发亮的圆点;背景恒为零,不使用外部视频。
start, end = rng.uniform(5, 10), rng.uniform(22, 27)
yy = rng.uniform(8, 24)
progress = np.linspace(0, 1, 8) ** rng.uniform(0.7, 1.5)
cx = start + (end - start) * progress
y, x = np.mgrid[:32, :32]
return np.exp(-((x[None] - cx[:, None, None]) ** 2
+ (y[None] - yy) ** 2) / (2 * 1.5 ** 2))
def centroids(frames):
yy, xx = np.mgrid[:frames.shape[-2], :frames.shape[-1]]
mass = frames.sum(axis=(-2, -1))
if np.any(mass <= 0):
raise ValueError("空白帧不能计算亮度质心")
return np.stack([(frames * xx).sum(axis=(-2, -1)) / mass,
(frames * yy).sum(axis=(-2, -1)) / mass], axis=-1) / 31
def video_data(split):
size, seed = {"train": (80, 2026), "validation": (20, 2027), "test": (40, 2028)}[split]
rng = np.random.default_rng(seed)
videos, labels, ids = [], [], []
for i in range(size):
forward = moving_video(rng)
for direction, frames in [(1, forward), (0, forward[::-1].copy())]:
videos.append(frames)
labels.append(direction)
ids.append(f"{split}-{i:03d}-{'right' if direction else 'left'}")
pixels = np.stack(videos)
return pixels, centroids(pixels), np.array(labels), ids
def features(points, kind):
if kind == "mean":
# 舍去不影响位置含义的浮点累加尾差,使正反序列得到完全相同特征。
return np.round(points.mean(axis=1), 12)
return np.concatenate([points[:, 0], points[:, -1]], axis=1)
def queue_schedule(process_ms, count=10, interval_ms=200):
finished = 0
rows = []
for i in range(1, count + 1):
arrived = i * interval_ms
started = max(arrived, finished)
finished = started + process_ms
rows.append({"chunk": i, "arrived_ms": arrived, "started_ms": started,
"finished_ms": finished, "delay_ms": finished - arrived})
return rows
def run():
rate = 1000
t = np.arange(20) / rate
low = np.cos(2 * np.pi * 300 * t)
high = np.cos(2 * np.pi * 700 * t)
power = spectrum(tone_signal())
frequencies = np.fft.rfftfreq(400, 1 / 16000)
data = {split: video_data(split) for split in ["train", "validation", "test"]}
models = {}
for kind in ["mean", "ordered"]:
model = LogisticRegression(C=1, max_iter=1000).fit(
features(data["train"][1], kind), data["train"][2])
row = {}
for split, (_, points, labels, ids) in data.items():
predicted = model.predict(features(points, kind))
row[split] = {"accuracy": float(np.mean(predicted == labels)),
"predicted": predicted.tolist(), "labels": labels.tolist(), "ids": ids}
row["weights"], row["bias"] = model.coef_.tolist(), model.intercept_.tolist()
models[kind] = row
signal = np.zeros(20)
signal[[3, 8, 15]] = 1
delayed = np.concatenate([np.zeros(2), signal[:-2]])
lag = int(np.argmax(np.correlate(delayed, signal, mode="full")) - (len(signal) - 1))
return {
"source_sha256": sha256(Path(__file__).read_bytes()).hexdigest(),
"environment": {"python": platform.python_version(), "numpy": np.__version__},
"alias_max_error": float(np.max(np.abs(low - high))),
"spectrum": {"shape": list(power.shape), "window": 400, "hop": 160,
"first_peak_hz": float(frequencies[power[:, :40].mean(1).argmax()]),
"second_peak_hz": float(frequencies[power[:, -40:].mean(1).argmax()])},
"videos": models,
"data_sha256": {name: sha256(v[0].tobytes()).hexdigest() for name, v in data.items()},
"sample_video": data["test"][0][:2].tolist(),
"sample_points": data["test"][1][:2].tolist(),
"pair_mean_difference": float(np.max(np.abs(
features(data["test"][1][::2], "mean") - features(data["test"][1][1::2], "mean")))),
"alignment": {"video": signal.tolist(), "audio": delayed.tolist(),
"grid_seconds": 0.1, "audio_lag_steps": lag},
"queues": {str(ms): queue_schedule(ms) for ms in [120, 260]},
}
def show(report):
print("1 kHz 采样:300 Hz 与 700 Hz 余弦的样本相同(数值误差 < 1e-12)。")
spec = report["spectrum"]
print(f"短时频谱:{spec['shape']};前后主频={spec['first_peak_hz']:.0f}/{spec['second_peak_hz']:.0f} Hz")
print("视频训练/验证/测试:160 / 40 / 80;每对正反序列在同一划分。")
for kind in ["mean", "ordered"]:
row = report["videos"][kind]
print(f"{kind}:验证准确率={row['validation']['accuracy']:.3f},"
f"测试准确率={row['test']['accuracy']:.3f}")
print(f"正反视频的平均位置特征差={report['pair_mean_difference']:.1f}")
print(f"模拟音频比视频晚 {report['alignment']['audio_lag_steps'] * .1:.1f} 秒。")
for key, rows in report["queues"].items():
print(f"假设每块处理 {key} ms:第 10 块完成延迟={rows[-1]['delay_ms']} ms")
print("视频只使用亮点质心;队列和同步是教学模拟,不是真实视频大模型或在线性能。")
if __name__ == "__main__":
parser = ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path)
args = parser.parse_args()
result = run()
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
show(result)
也可下载脚本。加上 --out runs/audio-video-basics.json 可以保存一次新的完整运行;随书记录保留全部预测、样本 ID、两个分类器的参数、示例帧、时间模拟与源码指纹。
9. 第九篇实践:让检索与生成各交出一份可检查的结果
这一篇从“图片和文字怎样相遇”走到了“声音和视频怎样保留时间”。现在把第 40~42 章的代码组织成两项完整实践:一个小型图文检索系统,以及一个真实预训练模型的条件生成实验。
第一步:建立可以查询的小图库
先训练第 40 章的小型双编码器,保存权重与报告,并做一次查询:
uv run --python 3.12 --with-requirements requirements.txt \
python src/examples/cross_modal_alignment.py \
--checkpoint runs/multimodal-project/alignment.pt \
--out runs/multimodal-project/alignment.json \
--query "red triangle"
程序创建带 ID 的合成图库,把查询编码成向量,再按余弦相似度排序。支持的查询是三种颜色与三种形状组成的九个短语;不是任意自然语言搜索。保存后会重新加载权重,核对检索分数与保存前一致。
本次查询的前三项是:
查询:red triangle
test-2-00;实际 red triangle;余弦 0.9059
test-2-13;实际 red triangle;余弦 0.8762
test-2-09;实际 red triangle;余弦 0.8690
这个组合没有进入训练和验证,本次查询成功了。但不能只展示这一条:完整测试中,文找图 Hit@1 为 1.000,图找文 Hit@1 为 0.681;未见组合的图找文准确率仅 0.396,blue square 的 24 张图中只有一张选对描述。九个查询至少各看一遍,并回看第 40 章的全部类别结果。
如果选择文档问答方向,可以按第 41 章的单据、图表、位置与空图对照继续实验。已经能读对两个时间,不等于能稳定定位,也不等于空图会拒答。检索系统和文档问答系统选择一条做完整即可,不要求把两种任务硬拼成一个总分。
第二步:用预训练模型完成条件生成
运行第 42 章已经固定的六组 SD-Turbo 对照:
uv run --python 3.12 \
--with-requirements src/examples/requirements-generation.txt \
python src/examples/pretrained_image_generation.py \
--run --out runs/multimodal-project/generation
已有结果可以先用默认检查命令核对,无需再次下载或生成:
python3 src/examples/pretrained_image_generation.py
输出目录应保留全部六张图片与 report.json,不要用“最好看的一张”替代完整记录。对颜色、数量、文字和构图分别判断:本书实际完成了颜色对照,却没有满足最后一张的数量和文字要求。失败结果本身也是交付的一部分。
第三步:写清楚系统做到了哪一步
可以按下面的表整理自己的报告。本书的对应记录已经在各章中保留:
| 项目 | 需要交出的内容 | 本书这次得到的结果 |
|---|---|---|
| 检索输入与范围 | 图库 ID、九条支持查询、留出组合 | 完整合成数据与固定划分 |
| 检索计算 | 编码、归一化、相似度、排序、保存/加载 | 真实对比训练和查询结果 |
| 检索评价 | 两个方向分别评分,全部类别结果 | 保留组合泛化失败 |
| 生成条件 | 模型修订、完整提示、种子、步数 | 六组事先固定的配置 |
| 生成结果 | 全部原图与参数,逐项检查条件 | 保留截断、双把手、五只杯子与错误文字 |
| 可复现材料 | 环境、代码、数据或模型指纹、运行命令 | 随书脚本、依赖清单与 JSON 记录 |
| 能力边界 | 明确没有测试或没有实现的部分 | 不支持自由查询,不声称通用视觉可靠性 |
音频和视频可以作为扩展:给图库增加语音查询,或让系统回答物体移动方向。先保存中间转写或抽取帧,再评价最终任务;否则回答错误时,很难判断是听错了、看漏了,还是后面的推理出错。
10. 换一种输入,再追问一次证据
- 16 kHz 单声道音频有 48000 个采样点,时长是多少?只把采样率声明改成 8 kHz,播放会发生什么?
- 为什么 1 kHz 采样无法区分本章的 300 Hz 与 700 Hz 余弦?若已经只剩采样值,换更大的识别模型能保证恢复原频率吗?
- 用 400 点窗口、160 点步长处理一秒 16 kHz 信号,不补边时有多少个时间位置?窗口变长,会带来什么取舍?
- 参考
a red cup,转写a blue cup now,WER 是多少?如果参考为空,应怎样处理? - 为什么干净音频的 5/33 不能直接叫作“语义听错比例”?如果任务是恢复订单号,应另外检查什么?
- 0 dB 是否表示没有信号?20 dB 的信号与噪声功率比是多少?
- 对正序与逆序帧取平均,为什么无法区分方向?设计一个首尾帧也无法区分、但中间过程不同的例子。
- 每 200 ms 到一块音频、每块处理 260 ms,第五块到达后多久完成?这与 RTF 有什么关系?
- 两秒音频处理只需 0.2 秒,为什么仍不能证明首包延迟为 0.2 秒?还缺少哪些时刻的记录?
- 给第九篇实践增加一个新查询或新提示之前,怎样保留原有结果,避免最后只剩成功样例?
展开参考答案与实验提示
- 时长是 秒。只改声明后会播放六秒,速度与音高一起下降;这没有完成正确重采样。
- 在整数采样位置,两条余弦的值相同。观测中已没有区分两者的信息;模型可以基于先验猜测,却不能保证恢复真实来源。
- 有 98 个时间位置。窗口更长通常能提供更细的频率划分,却会把更长时间内的变化混在一起;时间分辨与频率分辨需要取舍。
- 一次替换、一次插入,共 。参考为空时 WER 分母为零,应另报无依据输出或插入词数,不能当成 0 分错误率。
- 数字的英文词与阿拉伯数字被当前规则计为不同词。订单任务应预先规定数字规范化,并检查规范化后的订单号字段;不能在看到结果后只替有利样例改规则。
- 0 dB 表示两者平均功率相等;20 dB 表示信号功率为噪声的 100 倍。这里功率由整段采样的平方均值估计。
- 平均对排列不敏感。“亮点一直停在左边”与“先向右移动、再回左边”可以有相同首尾,却有不同过程。
- 第五块的延迟是 ms。这里每块对应 200 ms 音频,处理 RTF 为 ,持续到达时积压增长。
- 可能先等两秒音频全部收齐才开始算,也可能还需等后续生成和播放。要记录采集开始/结束、第一份可用识别结果、第一段生成音频与实际播放开始等时刻。
- 先写明新增输入、种子和评分规则,使用新目录保留全部输出,报告相对于原配置改变了什么。旧失败也应留在报告中,不能用一次新的成功覆盖它。
到这里,我们已经从表格、文字和图片走到声音与视频。下一篇开始阅读论文:把作者声称解决的问题、实际实验和证据边界分开看,让每个新名词都能落到可检查的工作上。