3. 时域特征

音频文件读写,是声学特征提取的第一步。说白了,你连数据都读不进来,后面那些花里胡哨的特征计算全是空谈。我见过不少同学,模型搭得挺漂亮,结果卡在文件读取上——采样率对不上、通道数搞错、时长算不准,折腾半天。

这一章,我们就来搞定这些基本功。我会用 librosa 和 soundfile 这两个库,带你走一遍音频读写的完整流程。

3.1 为什么选这两个库?

Python 里能读音频的库不少,比如 scipy.io.wavfile、pydub、audioread。但我个人习惯,主力用 librosa 和 soundfile。原因很简单:

  • librosa:生态好,读进来直接就是 numpy 数组,后面做特征提取无缝衔接。它底层其实也调了 soundfile 或 audioread。
  • soundfile:轻量、稳定,对 WAV/FLAC/OGG 支持极好。我在项目里遇到过大文件读取崩溃的问题,换成 soundfile 就稳了。
我的建议:日常开发用 librosa,生产环境或处理大批量文件时,优先用 soundfile。它更底层,出错概率小。

3.2 读取音频文件:获取采样率、时长、通道数

先看最基础的操作——读文件。我们用一个实际例子来说明。

import librosa
import soundfile as sf
import numpy as np

# 用 librosa 读取
file_path = "speech_sample.wav"
y, sr = librosa.load(file_path, sr=None)  # sr=None 保持原始采样率

# 用 soundfile 读取
y_sf, sr_sf = sf.read(file_path)

print(f"librosa: 采样率={sr}, 样本点数={len(y)}, 时长={len(y)/sr:.2f}s")
print(f"soundfile: 采样率={sr_sf}, 样本点数={len(y_sf)}, 时长={len(y_sf)/sr_sf:.2f}s")

这里有个细节:librosa.load 默认会把音频重采样到 22050 Hz,并且转成单声道。如果你不想它乱改,记得传 sr=None 和 mono=False。嗯,这个坑我踩过不止一次。

获取通道数,不同库方式不一样:

# librosa 默认返回单声道,shape 是 (n_samples,)
# 如果想保留多通道,需要指定 mono=False
y_stereo, sr = librosa.load(file_path, sr=None, mono=False)
if y_stereo.ndim == 1:
    channels = 1
else:
    channels = y_stereo.shape[0]

# soundfile 更直接
info = sf.info(file_path)
print(f"通道数: {info.channels}")
print(f"采样率: {info.samplerate}")
print(f"时长: {info.duration:.2f}s")
关键区别:librosa 的数组形状是 (channels, samples),soundfile 是 (samples, channels)。写代码时一定要注意,不然矩阵运算会出大问题。

3.3 音频裁剪:精确到样本点

音频裁剪,说白了就是从时间轴上切一段。我做过一个语音唤醒项目,需要从长录音里精确切出“你好小X”这个片段。那时候用的就是下面这套逻辑。

def crop_audio(y, sr, start_time, end_time):
    """
    裁剪音频片段
    :param y: 音频数组
    :param sr: 采样率
    :param start_time: 起始时间(秒)
    :param end_time: 结束时间(秒)
    :return: 裁剪后的音频数组
    """
    start_sample = int(start_time * sr)
    end_sample = int(end_time * sr)
    
    # 边界检查,防止越界
    start_sample = max(0, start_sample)
    end_sample = min(len(y), end_sample)
    
    return y[start_sample:end_sample]

# 使用示例
y, sr = librosa.load("long_recording.wav", sr=None)
cropped = crop_audio(y, sr, 5.0, 10.5)  # 切出 5~10.5 秒
print(f"原始长度: {len(y)/sr:.2f}s, 裁剪后: {len(cropped)/sr:.2f}s")

为什么会强调边界检查?我曾经在处理一批数据时,有个标注文件的时间戳超出了音频总长,程序直接崩了。从那以后,我所有裁剪函数都会加 max/min 保护。

3.4 音频拼接:无缝连接

拼接比裁剪稍微复杂一点。最简单的做法就是 np.concatenate,但要注意两个问题:采样率必须一致,而且直接拼接会在连接点产生“咔哒”声。

def concatenate_audio(y_list, sr_list):
    """
    拼接多个音频片段
    :param y_list: 音频数组列表
    :param sr_list: 对应采样率列表
    :return: 拼接后的音频数组
    """
    # 检查采样率是否一致
    if len(set(sr_list)) != 1:
        raise ValueError("所有片段的采样率必须一致")
    
    return np.concatenate(y_list)

# 示例:拼接两个片段
y1, sr = librosa.load("part1.wav", sr=None)
y2, _ = librosa.load("part2.wav", sr=None)

y_combined = concatenate_audio([y1, y2], [sr, sr])
sf.write("combined.wav", y_combined, sr)
注意:直接拼接会产生不连续点。如果对音质要求高,建议做交叉淡入淡出(cross-fade)。我后面会专门讲这个技巧。

3.5 音频文件的写入

写完数据要存盘,soundfile.write 是我的首选。它支持 WAV、FLAC、OGG 等格式,而且写入质量可控。

# 写入 WAV 文件
sf.write("output.wav", y_combined, sr)

# 写入 FLAC(无损压缩,文件小一半)
sf.write("output.flac", y_combined, sr)

# 指定子类型(比如 24-bit PCM)
sf.write("output_24bit.wav", y_combined, sr, subtype='PCM_24')

librosa 也提供了 librosa.output.write_wav,但新版已经废弃了,官方推荐直接用 soundfile。我个人也一直这么用。

3.6 本章知识体系

下面这张图,帮你把本章的核心逻辑串起来:

音频文件读写与基础操作流程 音频文件 librosa.load() soundfile.read() 采样率 / 时长 / 通道数 采样率 / 时长 / 通道数 裁剪 / 拼接 / 写入 核心原则:采样率一致、边界检查、注意数组形状差异(librosa vs soundfile)

3.7 常见问题与避坑

问题 原因 解决方案
读取后音频变短 librosa 默认重采样到 22050 Hz 传参 sr=None
多通道变单通道 librosa 默认 mono=True 传参 mono=False
拼接处有爆音 波形不连续 做交叉淡入淡出
写入后文件损坏 数组类型不对(如 float64) 转成 float32 或 int16
避坑指南:我曾经在批量处理 10 万条音频时,发现有几条读出来全是静音。排查了半天,原来是文件头损坏但文件大小正常。后来我加了一步校验:读取后检查 np.max(np.abs(y)),如果接近 0 就标记异常。

好了,这一章的内容就到这里。音频读写看似简单,但细节不少。把基础打牢,后面做特征提取才能顺手。记住我上面提到的几个关键点:采样率一致性、数组形状差异、边界检查——这些在工程里都是血泪教训换来的。