第4章 音频人工智能:让模型识别声音

本章技术主题: 音频分类——把连续声音转换为可学习的频谱特征。
本章技术实验: 训练声音分类模型,观察频谱、类别分数和未知状态。
家庭项目: “制作一个家庭声音提醒器”是本章主项目。
跨章位置: 保存声音模型、阈值和独立测试,第12章可把它接入低风险互动反馈。
技术主线: 声音片段 → 波形 → 频谱图 → 模型训练 → 类别概率 → 测试与使用判断。

学习目标

学完本章,你将能够:

  1. 说明采样率、声音窗口、波形和频谱图分别表示什么;
  2. 完成声音样本采集、标签确认、训练集与测试集划分,并运行一个Keras/TensorFlow声音分类模型;
  3. 解释模型为什么输出类别概率,以及other类别和unknown状态为何不同;
  4. 用噪声、距离、陌生声音和故障文件测试模型,比较误报、漏报与人工复核量;
  5. 判断一个课堂声音分类器适合做什么、不适合做什么。

项目导入

声音分类是音频人工智能的一项基础任务。它不要求模型把声音完整转写成文字,而是判断一段声音更接近哪些已知类别。例如,家庭环境中的门铃、烧水提示和宠物叫声,教室环境中的上课铃、掌声和桌椅移动声,都可以成为分类对象。

声音并不是一个稳定不变的符号。同一个门铃在近处和隔门处的音量不同;同一个上课铃在安静教室和走廊噪声中具有不同背景;手机提示音还可能与门铃相似。如果训练材料只来自一个设备、一个距离和一个房间,模型可能记住录音条件,而没有学会需要识别的声音模式。

本章正式项目是一个低风险的“家庭声音提醒器”。它默认在本地读取已获授权的一秒WAV文件并完成模型推理,输出各类别分数以及“已知类别”或“未知”的判断,再把结果记录下来;原始录音不上传。作品不进行连续监听,不保存谈话内容,不连接门锁、电源、炉具和报警设备,也不替代火灾报警器等专业安全装置。

本章成果

考点提示

本章不要求推导傅里叶变换公式,但要能解释声音为什么需要采样、为什么要切成固定窗口,以及频谱图怎样同时表示时间和频率。还要分清训练与推理、类别分数与现实正确性、模型输出与应用动作。训练集、验证集和测试集应按录制批次或环境分开;同一长录音切出的相邻片段不能分别放入训练集和测试集,否则会造成数据泄漏。

第一节 采集声音并训练第一个声音分类模型

一、把声音任务定义为可检验的分类问题

首版任务使用四个已知类别:doorbell(门铃)、kettle(烧水提示)、class_bell(教室铃声)和other(其他常见声音)。每个输入都是一秒、16 kHz、单声道WAV。模型输出四个类别的相对分数,普通程序再根据最高分和前两名分差决定接受该类别还是输出unknown。

other与unknown不能混为一谈。other是训练时已经收集和标注的类别,例如普通敲击声和拉椅子声;unknown不是一个训练标签,而是应用在证据不足时作出的拒绝判断。强迫模型把每段声音都分到四个已知类别,会使陌生声音也得到一个看似确定的名称。

最小成功样例是:输入独立录制的清晰门铃,程序显示波形、频谱图和全部类别分数;输入拍手、混合声或分数接近的声音时,程序保留分数并输出unknown。项目只记录低风险事件,不因任何分类结果自动控制设备。

二、采集、标注和划分声音数据

数据目录按用途和标签组织:

ch04-sound-project/
├─ input/audio/
│  ├─ train/doorbell|kettle|class_bell|other/
│  ├─ valid/doorbell|kettle|class_bell|other/
│  ├─ test/doorbell|kettle|class_bell|other/
│  └─ target.wav
├─ src/main.py
├─ tests/cases.json
├─ output/
└─ evidence/dialogue.md、change.diff、regression.md、decision-card.md

每个样本的数据卡至少记录类别、录制批次、房间、距离、设备、背景声和授权情况。训练集用于调整模型参数,验证集用于观察训练过程和选择配置,测试集只在模型和规则基本确定后使用。划分时应把同一录制时段或同一长录音的切片整体放在一侧,不能随机拆到训练和测试两边。

四类数量应大致均衡,并尽量覆盖近距离、远距离、不同音量和不同背景。标签不确定的声音进入“待确认”区,不应为了凑数量随意归类。涉及谈话、姓名或其他可识别个人的信息不进入课堂数据集。

采集数量相同也不代表数据已经平衡。如果门铃样本全部在安静近距离录制,而other样本都来自嘈杂走廊,模型可能把背景安静程度当成类别线索。检查数据时要同时比较数量、设备、距离和背景分布,并保留至少一个训练阶段没有出现的环境作为泛化测试。

三、与AI协同形成首版工程

本章的应用中的AI是声音分类模型,它在程序运行时接收频谱图并输出类别分数。开发与学习AI帮助学生澄清数据格式、生成和解释程序、分析错误日志、提出限定修改;它不替学生决定录音是否获得授权、标签是否正确以及错误后果是否可以接受。

与开发与学习AI协同时,可以先提交下面的任务说明:

目标:把一秒WAV分为doorbell、kettle、class_bell、other;证据不足时输出unknown。
数据:train、valid、test来自不同录制批次,target.wav用于单次观察。
格式:16 kHz单声道;不足一秒补零,超过一秒截取。
实现:Python与Keras/TensorFlow;显示波形、频谱图和全部类别分数。
边界:只记录低风险事件,不连续监听,不连接门锁、电源和报警器。
验收:保存模型、测试明细、观察图和结果JSON;测试静音、陌生声、混合声和坏文件。
请先检查数据泄漏、标签顺序和测试划分,再给出完整程序。

自然语言说明不是运行证据。学生仍需检查AI生成代码是否真正使用独立测试集、预处理是否一致、标签顺序是否固定,并亲自运行程序保存首轮结果。

第二节 从波形、频谱图到类别概率

一、声音如何变成模型可以处理的数据

麦克风把空气振动转换成随时间变化的数值。采样率16 kHz表示每秒记录16000个振幅值。一秒声音因此可以表示为长度16000的一维波形。波形容易观察声音何时出现、持续多久和大致强弱,却不容易直接分辨音色。

程序把波形切成许多有重叠的短窗口,再计算每个窗口中不同频率成分的强弱,得到频谱图。频谱图的横向表示时间,纵向表示频率区间,每个位置的数值表示该时间和频率附近的能量。对幅度作对数变换,可以让较弱但有辨别价值的成分更容易观察。

频谱图是一种二维表示,因此可以用小型卷积网络学习局部形状。卷积层寻找短促提示音、持续嗡鸣或重复节奏等模式,最后的Softmax层为四个已知类别分配相对分数。最高分只表示模型在当前标签集合中更偏向该类,并不表示现实中一定是该声音。

二、完整核心程序

下面的src/main.py读取三个独立数据目录,完成频谱转换、模型训练、验证、测试、单个目标声音推理和结果保存。运行前需在项目环境中安装TensorFlow、NumPy和Matplotlib,并按前述目录放入WAV文件。

from pathlib import Path
import json

import matplotlib.pyplot as plt
import numpy as np
import tensorflow as tf

ROOT = Path(__file__).resolve().parents[1]
AUDIO = ROOT / "input" / "audio"
OUT = ROOT / "output"
OUT.mkdir(exist_ok=True)

RATE = 16000
LENGTH = 16000
SEED = 42
CONFIDENCE = 0.70
MARGIN = 0.15
NAMES = ["class_bell", "doorbell", "kettle", "other"]
tf.keras.utils.set_random_seed(SEED)


def load_wave(path):
    """读取16 kHz单声道WAV,并统一为一秒。"""
    wave, rate = tf.audio.decode_wav(
        tf.io.read_file(path), desired_channels=1
    )
    tf.debugging.assert_equal(rate, RATE, message="采样率必须为16 kHz")
    wave = tf.squeeze(wave, axis=-1)[:LENGTH]
    wave = tf.pad(wave, [[0, LENGTH - tf.shape(wave)[0]]])
    wave.set_shape([LENGTH])
    return wave


def spectrogram(wave):
    """把一维波形转换为对数频谱图。"""
    value = tf.signal.stft(
        wave, frame_length=400, frame_step=160, fft_length=512
    )
    return tf.math.log1p(tf.abs(value))[..., tf.newaxis]


def make_dataset(split, shuffle=False):
    paths, labels = [], []
    for label, name in enumerate(NAMES):
        files = sorted((AUDIO / split / name).glob("*.wav"))
        if not files:
            raise ValueError(f"{split}/{name}中没有WAV文件")
        paths.extend(str(path) for path in files)
        labels.extend([label] * len(files))
    dataset = tf.data.Dataset.from_tensor_slices((paths, labels))
    if shuffle:
        dataset = dataset.shuffle(len(paths), seed=SEED)
    dataset = dataset.map(
        lambda path, label: (spectrogram(load_wave(path)), label),
        num_parallel_calls=tf.data.AUTOTUNE,
    )
    return dataset.batch(16).prefetch(tf.data.AUTOTUNE)


def choose(scores, confidence=CONFIDENCE, margin=MARGIN):
    order = np.argsort(scores)[::-1]
    first, second = int(order[0]), int(order[1])
    top = float(scores[first])
    gap = float(scores[first] - scores[second])
    decision = NAMES[first] if top >= confidence and gap >= margin else "unknown"
    return decision, top, gap


def audit(model, dataset):
    rows = []
    for features, labels in dataset:
        predictions = model.predict(features, verbose=0)
        for scores, truth in zip(predictions, labels.numpy()):
            decision, top, gap = choose(scores)
            rows.append(
                {
                    "truth": NAMES[int(truth)],
                    "decision": decision,
                    "top_score": top,
                    "top_two_margin": gap,
                }
            )
    return {
        "correct": sum(row["decision"] == row["truth"] for row in rows),
        "wrong": sum(
            row["decision"] not in {row["truth"], "unknown"} for row in rows
        ),
        "unknown": sum(row["decision"] == "unknown" for row in rows),
        "rows": rows,
    }


train = make_dataset("train", shuffle=True)
valid = make_dataset("valid")
test = make_dataset("test")
input_shape = tuple(spectrogram(tf.zeros(LENGTH)).shape)

model = tf.keras.Sequential(
    [
        tf.keras.layers.Input(input_shape),
        tf.keras.layers.BatchNormalization(),
        tf.keras.layers.Conv2D(16, 3, activation="relu"),
        tf.keras.layers.MaxPooling2D(),
        tf.keras.layers.Conv2D(32, 3, activation="relu"),
        tf.keras.layers.GlobalAveragePooling2D(),
        tf.keras.layers.Dense(len(NAMES), activation="softmax"),
    ]
)
model.compile(
    optimizer="adam",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)
model.fit(train, validation_data=valid, epochs=10, verbose=2)
_, test_accuracy = model.evaluate(test, verbose=0)
model.save(OUT / "sound_model.keras")

test_report = audit(model, test)
test_report["test_accuracy"] = float(test_accuracy)
(OUT / "test_decisions.json").write_text(
    json.dumps(test_report, ensure_ascii=False, indent=2), encoding="utf-8"
)

target = load_wave(str(AUDIO / "target.wav"))
target_spec = spectrogram(target)
scores = model.predict(target_spec[tf.newaxis, ...], verbose=0)[0]
decision, top, gap = choose(scores)

figure, axes = plt.subplots(2, 1, figsize=(8, 5))
axes[0].plot(np.arange(LENGTH) / RATE, target.numpy())
axes[0].set(title="Waveform", xlabel="Time (s)", ylabel="Amplitude")
axes[1].imshow(
    tf.squeeze(target_spec).numpy().T, origin="lower", aspect="auto"
)
axes[1].set(title="Log spectrogram", xlabel="Time frame", ylabel="Frequency bin")
figure.tight_layout()
figure.savefig(OUT / "target.png", dpi=150)
plt.close(figure)

result = {
    "probabilities": {name: float(score) for name, score in zip(NAMES, scores)},
    "top_score": top,
    "top_two_margin": gap,
    "decision": decision,
    "test_accuracy": float(test_accuracy),
    "notice": "只用于低风险声音分类学习,不替代安全报警装置。",
}
(OUT / "result.json").write_text(
    json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps(result, ensure_ascii=False, indent=2))

阅读程序时先沿数据形状追踪:WAV成为(16000,)波形,波形成为二维频谱图,批量频谱图进入模型,模型输出四个分数。CONFIDENCE和MARGIN位于模型之外,它们不改变模型参数,只决定应用何时接受候选。test_decisions.json保留每个测试声音的真实标签、最终判断、最高分和分差,便于以后使用同一批样本比较规则。

三、只改变置信度阈值

保持模型、测试集、预处理和分差规则不变,只把CONFIDENCE从0.55改为0.80。运行前先预测:提高阈值通常减少确定分类和误报,但可能增加未知状态或漏报。运行后记录正确判断、错误判断、未知数量,以及每个变化样本的具体声音条件。

阈值取舍必须联系错误后果。把桌椅声误报成上课铃会产生不必要提醒;把一次低风险门铃判为未知会失去辅助提醒。两种错误都需要记录,但代价可能不同。高风险场景不能通过“继续调阈值”转交给本课堂模型负责。

这个实验只说明同一个模型在两种接受规则下的表现,不能证明阈值0.80普遍优于0.55。如果同时更换模型、声音和预处理,就不能把输出差异归因于阈值。

第三节 测试噪声、距离、陌生声音与误报漏报

一、建立能够暴露失败的测试集

只测试安静环境中的清晰声音,会高估作品能力。固定测试集应覆盖:

类型 示例 观察重点 期望行为
正常 独立批次的清晰门铃或上课铃 基本分类是否可完成 输出全部分数并给出候选类别
边界 隔门、低音量、只有半段提示音 分数是否接近阈值 证据不足时输出unknown
陌生 拍手、钥匙碰撞、未训练手机铃声 是否被强行归类 保留分数,不触发确定提醒
故障 损坏WAV、错误采样率、文件缺失 能否安全停止 显示原因,不生成事件结论

还应把同一目标声放在近距离、远距离、不同房间和背景噪声下测试。测试材料必须来自独立录制批次,不能把为调试反复使用的声音继续称为“从未见过的测试证据”。

误报是目标事件没有发生却给出目标提醒;漏报是目标事件发生却没有相应提醒。混淆矩阵可以显示错误从哪个类别流向哪个类别,精确率回答“报出的目标中有多少是真的”,召回率回答“真实目标中有多少被发现”。样本很少时,应同时报告实际错误条数,不用一个百分数制造普遍性。

二、根据错误进行一次限定修改

首版只依据最高分是否超过阈值。一段门铃与电视声混合的声音得到doorbell=0.61、other=0.57,仍被确定为门铃。问题不在于最高分过低,而在于前两类几乎无法区分。可以要求开发与学习AI只修改“分数转判断”的函数:

实际行为:混合声最高两类分数接近,仍输出doorbell。
期望行为:最高分达到阈值且前两名差值不少于0.15时才接受,否则输出unknown。
允许修改:choose函数。
禁止修改:训练数据、模型结构、预处理、原始分数和固定测试集。
完成后:重跑正常、边界、陌生和故障样例,给出文件差异。

修改时不得把失败声音偷偷加入训练集,也不得删除原始分数。增加未知状态可能减少误报,也可能把原来正确的远距离门铃改为未知,因此必须回归全部样例,而不是只重跑已修好的混合声。

三、形成使用判断

判断卡至少回答以下问题:使用的模型、设备、房间和输入格式是什么;哪些声音经过独立测试;误报与漏报各有多少;低分、类别竞争和故障时怎样停止;谁对录音授权和最终使用负责。

合理结论可以是:作品只在类别有限、录音已授权、设备和环境接近测试条件时辅助记录低风险事件;默认在本地推理,低置信度、前两类接近、陌生声音或文件故障时输出未知;模型或录音设备改变后重新测试;不连续监听,不上传家庭或教室谈话,不连接真实设备,不替代专业报警装置。

在这些边界内,学生还可以把模型迁移为声音互动作品:经过人工确认的类别只改变屏幕卡片、虚拟指示灯或播放短提示音,unknown和故障状态保持不行动。这个反馈层由普通程序实现,不是模型“自动理解了该做什么”。界面和反馈方式可以由学生创意设计,但必须保留原始分数、确认步骤和停止条件,不得扩展为真实门锁、电源、炉具或报警控制。

如果只需识别一种固定电子提示音,还应比较简单的频率规则。若规则已经稳定满足目标,就没有必要为了使用更复杂的模型而增加数据、算力和维护负担。

本章小结

声音先被采样为波形,再按短窗口转换成频谱图。声音分类模型从带标签的频谱图中学习局部模式,并为已知类别输出相对分数。训练集用于学习参数,验证集用于观察训练,独立测试集用于形成使用证据。

模型不知道世界上所有声音。other是训练过的类别,unknown是应用拒绝在证据不足时强行分类。置信度阈值和前两名分差属于模型外规则。噪声、距离、房间和设备变化会暴露模型泛化边界,误报、漏报与未知数量必须结合实际后果解释。

应用中的AI承担频谱图到类别分数的映射,开发与学习AI协助形成和修改程序,人负责授权、标签、测试和采用决定。一个可靠的课堂作品不仅能在正常样例上运行,还能在陌生输入和故障出现时明确停止。

关键术语

目标测试

一、单项选择题

  1. 频谱图更直接呈现的是( )。
    A.录音文件名 B.时间和频率成分的强弱 C.声音事件的真实名称 D.设备权限
  2. 把同一长录音的相邻切片随机分到训练集和测试集,最可能造成( )。
    A.类别增多 B.数据泄漏 C.采样率下降 D.声音变短
  3. 最高两个类别分数非常接近时,较合适的处理是( )。
    A.永远选择第一名 B.删除第二名 C.保留分数并输出未知 D.直接触发设备
  4. 保持模型不变而提高置信度阈值,通常会使( )。
    A.模型自动重训 B.确定分类减少 C.采样率变化 D.标签数量增加

二、判断并改错

  1. 测试准确率较高的课堂声音分类器可以直接替代家庭安全报警器。请判断并改正。
  2. other类别和unknown状态含义完全相同。请判断并改正。

三、简答与操作题

  1. 不使用公式,说明一秒WAV从波形、频谱图到类别概率和最终判断的过程。
  2. 设计一条边界声音和一条陌生声音,写出采集条件、期望行为和通过标准。
  3. 设计一次只把置信度阈值从0.55改为0.80的实验,说明固定项、记录项和结论边界。
  4. 说明应用中的AI、开发与学习AI、普通规则、低风险反馈层和人在本章项目中的不同职责。

答案编号:A1-4-01—A1-4-10。完整答案和评价要点统一放入书后“目标测试参考答案”。