python WAV音频文件处理—— (2)处理PCM音频-- waveio包

简介: python WAV音频文件处理—— (2)处理PCM音频-- waveio包

破译 PCM-Encoded 的音频样本

这部分将变得稍微高级一些,但从长远来看,它将使在 Python 中处理 WAV 文件变得更加容易。

在本教程结束时,我们将构建出 waveio 包:

waveio/
├── __init__.py
├── encoding.py
├── metadata.py
├── reader.py
└── writer.py

  • encoding 模块将负责归一化幅度值和 PCM 编码样本之间的双向转换
  • metadata 模块将表示 WAV 文件头
  • reader 读取和解释音频帧
  • writer 写入 WAV 文件

枚举编码格式

waveio/encoding.py

创建PCMEncoding类继承枚举类IntEnum,并实现max, min, num_bits方法。

from enum import IntEnum

class PCMEncoding(IntEnum):
    UNSIGNED_8 = 1
    SIGNED_16 = 2
    SIGNED_24 = 3
    SIGNED_32 = 4

    @property
    def max(self):
        return 255 if self == 1 else -self.min -1
    
    @property
    def min(self):
        return 0 if self == 1 else -(2** (self.num_bits-1))
    
    @property
    def num_bits(self):
        return self * 8

Docode 将音频帧转换为振幅

继续向 PCMEncoding 类添加一个新方法decode,该方法将处理四种编码格式,将帧转换成(归一化的)振幅。

from enum import IntEnum
import numpy as np

class PCMEncoding(IntEnum):
    # ...

    def decode(self, frames):
        match self:
            case PCMEncoding.UNSIGNED_8:
                return np.frombuffer(frames, "u1") / self.max * 2 - 1
            case PCMEncoding.SIGNED_16:
              # little-endin 2-byte signed integer 
                return np.frombuffer(frames, "<i2") / -self.min
            case PCMEncoding.SIGNED_24:
                triplets = np.frombuffer(frames, "u1").reshape(-1, 3)
                padded = np.pad(triplets, ((0, 0), (0, 1)), mode="constant")
                samples = padded.flatten().view("<i4")
                samples[samples > self.max] += 2 * self.min
                return samples / -self.min
            case PCMEncoding.SIGNED_32:
                return np.frombuffer(frames, "<i4") / -self.min
            case _:
                raise TypeError("unsupported encoding")


Encode 将振幅编码为音频帧

添加.encoder()方法,将振幅转换成帧。

from enum import IntEnum

import numpy as np

class PCMEncoding(IntEnum):
    # ...
  def _clamp(self, samples):
        return np.clip(samples, self.min, self.max)
        
    def encode(self, amplitudes):
        match self:
            case PCMEncoding.UNSIGNED_8:
                samples = np.round((amplitudes + 1) / 2 * self.max)
                return self._clamp(samples).astype("u1").tobytes()
            case PCMEncoding.SIGNED_16:
                samples = np.round(-self.min * amplitudes)
                return self._clamp(samples).astype("<i2").tobytes()
            case PCMEncoding.SIGNED_24:
                samples = np.round(-self.min * amplitudes)
                return (
                    self._clamp(samples)
                    .astype("<i4")
                    .view("u1")
                    .reshape(-1, 4)[:, :3]
                    .flatten()
                    .tobytes()
                )
            case PCMEncoding.SIGNED_32:
                samples = np.round(-self.min * amplitudes)
                return self._clamp(samples).astype("<i4").tobytes()
            case _:
                raise TypeError("unsupported encoding")

封装 WAV 文件的元数据

管理WAV文件的多个元数据可能很麻烦,因此我们自定义一个数据类,将它们分组在一个命名空间下。

waveio/metadata.py

from dataclasses import dataclass

from waveio.encoding import PCMEncoding

@dataclass(frozen=True)
class WAVMetadata:
    encoding: PCMEncoding
    frames_per_second: float
    num_channels: int
    num_frames: int | None = None


考虑到人类认喜欢用秒表示声音持续时间,我们添加一个属性num_seconds进行帧–>秒的转换:

@dataclass(frozen=True)
class WAVMetadata:
    ...

    @property
    def num_seconds(self):
        if self.num_frames is None:
            raise ValueError("indeterminate stream of audio frames")
        return self.num_frames / self.frames_per_second


加载所有音频帧

使用原始的wave读取wav文件需要手动处理二进制数据,我们将创建reader 避免这一麻烦。

waveio/reader.py

import wave

from waveio.encoding import PCMEncoding
from waveio.metadata import WAVMetadata

class WAVReader:
    def __init__(self, path):
        self._wav_file = wave.open(str(path))
        self.metadata = WAVMetadata(
            PCMEncoding(self._wav_file.getsampwidth()),
            self._wav_file.getframerate(),
            self._wav_file.getnchannels(),
            self._wav_file.getnframes(),
        )

    def __enter__(self):
        return self

    def __exit__(self, *args, **kwargs):
        self._wav_file.close()

对于较小的文件,可以直接加载到内存:

class WAVReader:
    # ...

    def _read(self, max_frames=None):
        self._wav_file.rewind()
        frames = self._wav_file.readframes(max_frames)
        return self.metadata.encoding.decode(frames)

readframes()会向前移动文件指针,rewind()会将指针重置在开头,确保每次读取都是从头开始读取。


但是,在处理音频信号时,通常需要将数据视为帧/通道序列,而不是单个幅度样本。幸运的是,根据您的需要,您可以快速将一维 NumPy 数组重塑为合适的二维帧或通道矩阵。我们将通过reshape装饰器实现这一功能。

import wave
from functools import cached_property

from waveio.encoding import PCMEncoding
from waveio.metadata import WAVMetadata

class WAVReader:
    # ...

    @cached_property
    @reshape("rows")
    def frames(self):
        return self._read(self.metadata.num_frames)

    @cached_property
    @reshape("columns")
    def channels(self):
        return self.frames


reshape装饰器的实现如下:

import wave
from functools import cached_property, wraps

from waveio.encoding import PCMEncoding
from waveio.metadata import WAVMetadata

def reshape(shape):
    if shape not in ("rows", "columns"):
        raise ValueError("shape must be either 'rows' or 'columns'")

    def decorator(method):
        @wraps(method)
        def wrapper(self, *args, **kwargs):
            values = method(self, *args, **kwargs)
            reshaped = values.reshape(-1, self.metadata.num_channels)
            return reshaped if shape == "rows" else reshaped.T
        return wrapper

    return decorator

# ...

为了让WAVReader在外部可用,我们在waveio.__init__.py中暴漏WAVReader类:

from waveio.reader import WAVReader

__all__ = ["WAVReader"]


使用 Matplotlib 绘制静态波形

我们已经可以进行wav文件的读取了,一个很直接的应用是使用matplotlib绘制声音的波形。

plot_waveform.py

from argparse import ArgumentParser
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.ticker import FuncFormatter
from waveio import WAVReader

def main():
    args = parse_args()
    with WAVReader(args.path) as wav:
        plot(args.path.name, wav.metadata, wav.channels)

def parse_args():
    parser = ArgumentParser(description="Plot the waveform of a WAV file")
    parser.add_argument("path", type=Path, help="path to the WAV file")
    return parser.parse_args()

def plot(filename, metadata, channels):
    fig, ax = plt.subplots(
        nrows=metadata.num_channels,
        ncols=1,
        figsize=(16, 9),
        sharex=True, # 共享x轴
    )

    if isinstance(ax, plt.Axes):
        ax = [ax]

    time_formatter = FuncFormatter(format_time)
    timeline = np.linspace(
        start=0,
        stop=metadata.num_seconds,
        num=metadata.num_frames
    )

    for i, channel in enumerate(channels):
        ax[i].set_title(f"Channel #{i + 1}")
        ax[i].set_yticks([-1, -0.5, 0, 0.5, 1])
        ax[i].xaxis.set_major_formatter(time_formatter)
        ax[i].plot(timeline, channel)

    fig.canvas.manager.set_window_title(filename)
    plt.tight_layout()
    plt.show()

def format_time(instant, _):
    if instant < 60:
        return f"{instant:g}s"
    minutes, seconds = divmod(instant, 60)
    return f"{minutes:g}m {seconds:02g}s"

if __name__ == "__main__":
    main()


执行

python .\plot_waveform.py .\sounds\Bicycle-bell.wav

可以看到上面的波形图。

读取音频帧的切片

如果您有一个特别长的音频文件,则可以通过缩小感兴趣的音频帧的范围来减少加载和解码基础数据所需的时间。

我们将通过切片功能实现读取一个范围的音频

首先在脚本参数中添加起始点(start)和结束点(end)这两个参数。

# ...

def parse_args():
    parser = ArgumentParser(description="Plot the waveform of a WAV file")
    parser.add_argument("path", type=Path, help="path to the WAV file")
    parser.add_argument(
        "-s",
        "--start",
        type=float,
        default=0.0,
        help="start time in seconds (default: 0.0)",
    )
    parser.add_argument(
        "-e",
        "--end",
        type=float,
        default=None,
        help="end time in seconds (default: end of file)",
    )
    return parser.parse_args()
    
def main():
    args = parse_args()
    with WAVReader(args.path) as wav:
        plot(
            args.path.name,
            wav.metadata,
            wav.channels_sliced(args.start, args.end)
        )

# ...

plot中,时间轴不再从0开始,需要和切片时间匹配:

# ...

def plot(filename, metadata, channels):
    # ...

    time_formatter = FuncFormatter(format_time)
    timeline = np.linspace(
        channels.frames_range.start / metadata.frames_per_second,
        channels.frames_range.stop / metadata.frames_per_second,
        len(channels.frames_range)
    )

然后我们需要更新reader.py文件,读取音频的任意切片

# ...

class WAVReader:
    # ...

    @cached_property
    @reshape("rows")
    def frames(self):
        return self._read(self.metadata.num_frames, start_frame=0)

    # ...

    def _read(self, max_frames=None, start_frame=None):
        if start_frame is not None:
            self._wav_file.setpos(start_frame) # 设置起始位置
        frames = self._wav_file.readframes(max_frames)
        return self.metadata.encoding.decode(frames)


    @reshape("columns")
    def channels_sliced(self, start_seconds=0.0, end_seconds=None):
        if end_seconds is None:
            end_seconds = self.metadata.num_seconds
        frames_slice = slice(
            round(self.metadata.frames_per_second * start_seconds),
            round(self.metadata.frames_per_second * end_seconds)
        )
        frames_range = range(*frames_slice.indices(self.metadata.num_frames))
        values = self._read(len(frames_range), frames_range.start)
        return ArraySlice(values, frames_range)

我们借助了ArraySlice包装切片,包装了numpy array并且公开了便于绘制时间线的.frames_rage属性。

reader.py中添加ArraySlice的定义:

# ...

class ArraySlice:
    def __init__(self, values, frames_range):
        self.values = values
        self.frames_range = frames_range

    def __iter__(self):
        return iter(self.values)

    def __getattr__(self, name):
        return getattr(self.values, name)

    def reshape(self, *args, **kwargs):
        reshaped = self.values.reshape(*args, **kwargs)
        return ArraySlice(reshaped, self.frames_range)

    @property
    def T(self):
        return ArraySlice(self.values.T, self.frames_range)

# ...
python plot_waveform.py Bongo_sound.wav --start 3.5 --end 3.65
相关文章
|
5月前
|
数据可视化 Linux iOS开发
Python脚本转EXE文件实战指南:从原理到操作全解析
本教程详解如何将Python脚本打包为EXE文件,涵盖PyInstaller、auto-py-to-exe和cx_Freeze三种工具,包含实战案例与常见问题解决方案,助你轻松发布独立运行的Python程序。
1473 2
|
4月前
|
监控 机器人 编译器
如何将python代码打包成exe文件---PyInstaller打包之神
PyInstaller可将Python程序打包为独立可执行文件,无需用户安装Python环境。它自动分析代码依赖,整合解释器、库及资源,支持一键生成exe,方便分发。使用pip安装后,通过简单命令即可完成打包,适合各类项目部署。
930 68
|
6月前
|
数据处理 开发工具 开发者
requirement.txt 管理python包依赖
在 Python 项目中,`requirements.txt` 用于记录依赖库及其版本,便于环境复现。本文介绍了多种生成该文件的方法:基础方法使用 `pip freeze`,进阶方法使用 `pipreqs`,专业方法使用 `poetry` 或 `pipenv`,以及手动维护方式。每种方法适用不同场景,涵盖从简单导出到复杂依赖管理,并提供常见问题的解决方案,帮助开发者高效生成精准的依赖列表,确保项目环境一致性。
2031 4
|
6月前
|
缓存 数据可视化 Linux
Python文件/目录比较实战:排除特定类型的实用技巧
本文通过四个实战案例,详解如何使用Python比较目录差异并灵活排除特定文件,涵盖基础比较、大文件处理、跨平台适配与可视化报告生成,助力开发者高效完成目录同步与数据校验任务。
232 0
|
7月前
|
编译器 Python
如何利用Python批量重命名PDF文件
本文介绍了如何使用Python提取PDF内容并用于文件重命名。通过安装Python环境、PyCharm编译器及Jupyter Notebook,结合tabula库实现PDF数据读取与处理,并提供代码示例与参考文献。
|
7月前
|
编译器 Python
如何利用Python批量重命名文件
本文介绍了如何使用Python和PyCharm对文件进行批量重命名,包括文件名前后互换、按特定字符调整顺序等实用技巧,并提供了完整代码示例。同时推荐了第三方工具Bulk Rename Utility,便于无需编程实现高效重命名。适用于需要处理大量文件命名的场景,提升工作效率。
|
10月前
|
Python
使用Python实现multipart/form-data文件接收的http服务器
至此,使用Python实现一个可以接收 'multipart/form-data' 文件的HTTP服务器的步骤就讲解完毕了。希望通过我的讲解,你可以更好地理解其中的逻辑,另外,你也可以尝试在实际项目中运用这方面的知识。
485 69
|
8月前
|
编解码 Prometheus Java
当Python同时操作1000个文件时,为什么你的CPU只用了10%?
本文介绍如何构建一个高效的文件处理系统,解决单线程效率低、多线程易崩溃的矛盾。通过异步队列与多线程池结合,实现任务调度优化,提升I/O密集型操作的性能。
199 4
|
Python
Python音频处理-图解傅里叶分析
傅里叶分析是一种将函数表示为周期成分之和的方法,并通过离散傅里叶变换(DFT)将信号从时域转换到频域,提取频率特征。通过快速傅里叶变换(FFT),我们可分析方波的主要频率成分,并利用这些成分重建波形,直观展示频率域对原始信号的影响。
276 0
|
7月前
|
安全 Linux 网络安全
Python极速搭建局域网文件共享服务器:一行命令实现HTTPS安全传输
本文介绍如何利用Python的http.server模块,通过一行命令快速搭建支持HTTPS的安全文件下载服务器,无需第三方工具,3分钟部署,保障局域网文件共享的隐私与安全。
1799 0

推荐镜像

更多