【ESP32|音频】一文读懂WAV音频文件格式【详解】

简介

最近在学习I2S音频相关内容,无可避免会涉及到关于音频格式的内容,所以刚开始接触的时候有点一头雾水,后面了解了下WAV相关内容,大致能够看懂wav音频格式是怎么样的了。本文主要为后面ESP32 I2S音频系列文章做铺垫,所以本篇将介绍WAV音频文件格式,并通过C代码生成一段1S的正弦波WAV音频写入到SD卡里面。


WAV(Waveform Audio File Format) 是一种音频文件格式,用于存储音频数据。它是由 微软IBM 开发的,通常用于存储高质量的原始音频数据。

如果一段单声道音频的采样率为 44100 Hz,一分钟的音频数据大约有 5.04MB。这个值是可以大致计算的,后面我们会提到。WAV 文件一般未经过压缩,因此能够提供音频的 高保真度,但相比其他音频格式,相同时间内的文件会显得较大。所以一开始我打算用SPIFFS存储WAV音频的时候发现好像不太现实,毕竟ESP32 SPIFFS空间太小了,而 WAV文件几秒的音频动不动就好几M了,这样子的话只能播放短时间的音频就不符合我的要求了。

WAV文件基于RIFF格式,这是一种用于存储多媒体数据的通用格式。

也就是说WAV是基于RIFF格式的一种具体应用,RIFF格式还被用于许多其他文件类型。

什么是RIFF格式

RIFFResource Interchange File Format,资源交换文件格式)是一种通用的文件格式标准,由微软和IBM1991年联合开发,用于存储和交换多媒体数据,如音频、视频、图像等。RIFF格式以其灵活性可扩展性著称,能够容纳各种类型的数据,并被广泛应用于多种文件类型,例如:

  • WAV音频
  • AVI视频
  • ANI动画光标

可以简单理解为它是一种通用的文件容器格式,它通过一个个的形式(称之为chunk)存储多媒体数据。

以下是基于RIFF格式的不同文件类型及其用途的表格:

文件类型扩展名用途
WAV.wav存储音频数据
AVI.avi存储音频和视频数据
RMI.rmi存储MIDI音乐数据
ANI.ani存储动画光标
WEBP.webp存储图像数据(主要用于Web)

可以看到除了WAV是基于RIFF格式的,还有其他文件类型也是基于RIFF的,这里我们也可以看到很多文件格式会用特定的标识符,比如WAV, AVI,这里就涉及到FOURCC标识符。RIFF 文件的结构通常以标识符 “RIFF” 开头,紧接着是文件大小(4 字节),再后面跟着的就是一个四字符代码(FOURCC),用于指明文件的数据类型。

FOURCC标识符

FOURCC(Four-Character Code,四字符代码)是由 4 个字节组成的标识符,通常使用可打印的 ASCII 字符,它在 RIFF 文件中用来标识数据的具体格式。比如:

WAV 文件:以 “RIFF” 开头,FOURCC“WAVE”,表示这是一个音频文件
AVI 文件:以 “RIFF” 开头,FOURCC"AVI “”(注意末尾有空格),表示这是一个视频文件

FOURCC 的设计要求正好 4 个字符,如果不足则用空格填充,且对大小写敏感。这种标识方式不仅用于文件类型的最顶层定义,还用于文件内部的各个数据块,每个数据块称作一个chunk,比如 WAV 文件中包含 "fmt "(格式信息)和 “data”(音频数据)这两个chunk。

字节序

WAV文件的字节数据还涉及到字节序的问题。字节序(Byte Order)是指多字节数据(如整数、浮点数等)在计算机内存中存储的顺序。不同的计算机体系结构可能采用不同的字节序方式,这可能会导致在不同平台之间传输数据时出现问题。字节序问题主要体现在多字节数据的存储顺序上,尤其是在跨平台的数据交换和存储中需要特别注意。根据字节存储时从低位开始还是从高位开始分为两种:大端序小端序

大端序(Big-Endian)

大端字节序是一种字节顺序,其中数据的高字节存储在内存的低地址处,低字节存储在高地址处。

例如,对于一个4字节的整数 0x12345678,它的字节序会按以下顺序存储:

地址0123
数据0x120x340x560x78

这种存储方式类似于我们阅读数字的顺序,从左到右。

小端序(Little-Endian)

小端字节序是一种字节顺序,其中数据的低字节存储在内存的低地址处,高字节存储在高地址处。

对于同样的4字节整数 0x12345678,它的字节序会按以下顺序存储:

地址0123
数据0x780x560x340x12

这种存储方式将数字的低位放在前面,更符合计算机内部的处理逻辑。

WAV文件结构

WAV文件基于RIFF格式。RIFF格式的结构是一个个构成的,一个块称为一个chunk,每个chunk都有一个4字节的ID(FOURCC),紧随其后的是4字节的块大小(chunk size),然后是块数据 (data) 。 最外层的是RIFF chunk,里面在套着"fmt" chunk和"data" chunk。

在这里插入图片描述

我们来看一下WAV文件的结构:

在这里插入图片描述

这张图的最左边是字节序,然后是偏移量,每个数据字段区域的名称及对应区域的字节大小。

字节序
前面我们提到WAV的字节序问题,那在WAV中每个chunk里面的字节数据是以什么方式存储的呢?在RIFF格式中,所有多字节的 数值数据(如块大小、音频采样率等)都以小端序存储。而ID,即FOURCC标识符,是4个ASCII字符的组合,按照ASCII字符的顺序直接存储, 所以它的字节序是大端序

偏移量
偏移量是指当前数据字段相对于文件开始位置的字节数。比如ChunkID的偏移量是0,表示它是文件的开始部分;ChunkSize的偏移量是4,表示它从文件的第4个字节开始,

WAVE音频文件结构主要分为三个部分:


1. RIFF Chunk Descriptor (偏移量0-12)

这是文件的头部,提供文件的身份和基本信息:

  • ChunkID (偏移量0,4字节) 标识文件为RIFF类型,通常为字符串 "RIFF"。 每个字符在ASCII表中都对应一个十六进制数。比如,R的ASCII码是0x52,I是0x49,F是0x46,第二个F也是0x46,那连起来的话, "RIFF"这四个字母对应的ASCII码就是0x52 0x49 0x46 0x46。
  • ChunkSize (偏移量4,4字节) 表示整个文件的大小(不包括前8字节,即 ChunkID 和 ChunkSize)。
  • 整个文件大小(不包含前8字节)= 36 + SubChunk2Size 4 + (8 + SubChunk1Size) + (8 + SubChunk2Size)文件总大小-8
  • Format (偏移量8,4字节) 指定文件格式为 "WAVE"。对应57 41 56 45。

  • 2. fmt Sub-chunk (偏移量12-36)

    这部分描述音频的格式信息,是播放或处理音频时必须了解的关键数据:

  • Subchunk1ID (偏移量12,4字节) 标识这是 "fmt " 子块。和上面的"RIFF"一样,使用ASCII字符标识,不足四个字符,末尾用空格补齐。对应66 6D 74 20
  • Subchunk1Size (偏移量16,4字节) 表示此子块的大小(对于PCM通常为16字节)。
  • AudioFormat (偏移量20,2字节) 指定音频格式,例如PCM(未压缩音频,值为1)。
  • NumChannels (偏移量22,2字节) 声道数,例如1(单声道)或2(立体声)。
  • SampleRate (偏移量24,4字节) 采样率,例如44100 Hz(CD音质)。
  • ByteRate (偏移量28,4字节) 每秒字节数,计算公式为: SampleRate * NumChannels * BitsPerSample / 8
  • BlockAlign (偏移量32,2字节) 每个采样块的字节数,计算公式为: NumChannels * BitsPerSample / 8
  • BitsPerSample (偏移量34,2字节) 每个样本采样的位数,例如8位或16位。

  • 3. data Sub-chunk (偏移量36起)

    这部分存储实际的音频数据:

  • Subchunk2ID (偏移量36,4字节) 标识这是 "data" 子块。对应64 61 74 61。
  • Subchunk2Size (偏移量40,4字节) 表示音频数据的大小。 datasize = NumSamples × NumChannels × BitsPerSample / 8,其中NumSamples 是总样本数
  • data (偏移量44起,可变大小) 包含原始的音频采样数据。

  • WAV文件头

    WAV文件的前44字节称为 WAV的文件头 ,剩下的data为WAV文件实际的音频数据。所以整个WAV文件的大小应等于文件头44字节 + data字节大小

    在这里插入图片描述

    这个文件头主要注意ChunkSizeSubchunk2SizeByteRateBlockAlign 这几个参数,我们重点介绍一下。

    ChunkSize

    ChunkSize字段里面存储着 “它之后的数据总大小” 的这个数据 (对于当前chunk的剩余部分)。所以 ChunkSize 指 对于ChunkSize字段后面的数据大小,不包括前8字节,即 4字节的ChunkID 和4字节的ChunkSize,所以ChunkSize大小是文件总大小-8。 (从ChunkID到data是一个WAV文件,ChunkSize实际就是从下个地址08开始到WAV文件结尾的总字节数)

    在这里插入图片描述

    ChunkSize大小还等于36 + SubChunk2Size。(下图红色框+蓝色框)。

    因为同理Subchunk2Size 指 对于Subchunk2Size字段后面的数据大小,而这个数据刚好就是WAV真正的音频数据,即 datasize

    在这里插入图片描述

    ChunkSize还等于 4 + (8 + SubChunk1Size) + (8 + SubChunk2Size),这个式子比较长,主要是分的比较细,如下图:
    在这里插入图片描述

    ByteRate

    ByteRate表示每秒传输的字节数,比如一段采样率8000hz,采样深度16bit的音频,单声道,则一秒采样8000个样本,每个样本16位,每秒采样样本字节大小为8000 * 16 / 8 * 1声道 = 16000字节,除以8是为了转换为字节,所以ByteRate = SampleRate * NumChannels * BitsPerSample / 8

    BlockAlign

    BlockAlign每个采样块的字节数,或者说一帧的样本,如果是单声道音频,一帧样本就包含一个声道数据;如果是双声道音频,一帧样本包含左声道数据和右声道数据。比如一段采样深度16bit的音频,单声道,一帧就是16 / 8 * 1声道 = 2字节。所以BlockAlign = NumChannels * BitsPerSample / 8

    讲到采样帧这里顺便提一下之前学习遇到的困惑,之前学习I2S了解到在对音频样本采样时,如果是双声道音频,左声道和右声道是一帧样本,在同一时刻采样,那为什么WS又区分WS=0和WS=1呢? 在之前学过I2S的通信格式的那个图里一般左边是左声道,右边是右声道,这样子看起来并不是在同一个时刻。这里其实是我混淆了采样和传输的过程,采样确实是同时采样的,但是传输是先传输左声道,再传输右声道。这里参考了别人画的图,很形象借用一下。

    假设一个 buffer 包含 4 个周期、而一个周包含 1024 帧、一帧包含两个样本(左、右两个声道),每个样本长度为2bytes。

    在这里插入图片描述

    Subchunk2Size

    Subchunk2Size表示音频数据的大小(字节),一般可以预估计算,有了ByteRate ,一般乘以时间,就可以得到音频总大小。 或者知道样本数也可以估算出来,比如一段采样率44100,采样深度16bit的音频,单声道,时间一分钟60s,字节速率ByteRate=44100 * 16 / 8 = 88200,即每秒传输字节数88200字节,再乘以时间,88200 * 60 = 5292000字节 ≈ 5.04 MB。Subchunk2Size大小因为表示的是WAV音频实际数据大小,所以也叫datasize,后面编写程序时我们将使用datasize这个字段名称。 使用时间去估计音频数据大小可能会有误差,但是这个误差一般不会很大。我们还可以通过样本数去估计音频数据大小,即NumSamples × NumChannels × BitsPerSample / 8,其中NumSamples是总样本数,NumChannels × BitsPerSample / 8 就是每个采样样本的字节数(即BlockAlign), 乘以总样本数,就可以得到总样本字节大小。

    以上我们讲了ChunkSize ,Subchunk2Size,ByteRate ,BlockAlign 这几个比较主要的参数,还有一些其他参数在WAV文件中是默认的。为了方便查看,将以上内容整理为表格:

    偏移大小字段名内容/说明
    04ChunkID"RIFF"(52 49 46 46)
    44ChunkSize文件大小 - 8

    36 + SubChunk2Size


    4 + (8 + SubChunk1Size) + (8 + SubChunk2Size)
    84Format"WAVE"(57 41 56 45)
    124Subchunk1ID"fmt "(66 6D 74 20)
    164Subchunk1Size16(表示 PCM 格式时)
    202AudioFormat1 表示 PCM;其他为压缩格式
    222NumChannels声道数(1=单声道,2=立体声)
    244SampleRate采样率(如 44100)
    284ByteRate每秒传输的字节数 = SampleRate * NumChannels * BitsPerSample / 8
    322BlockAlign每个采样块的字节数 = NumChannels × BitsPerSample / 8
    342BitsPerSample每个样本的位数(如 16)
    364Subchunk2ID"data"(64 61 74 61)
    404Subchunk2Size音频数据的大小(字节) = NumSamples × NumChannels × BitsPerSample / 8

    WAV音频文件格式示例

    了解了RIFF格式,字节序和WAV文件结构等相关参数后,我们先举一个WAV音频文件格式示例,再来看看实际的音频文件格式是什么样子的。
    假设有一段WAV音频文件如下(十六进制显示)

    52 49 46 46 24 08 00 00 57 41 56 45 66 6d 74 20 10 00 00 00 01 00 02 00
    22 56 00 00 88 58 01 00 04 00 10 00 64 61 74 61 00 08 00 00 00 00 00 00
    24 17 1e f3 3c 13 3c 14 16 f9 18 f9 34 e7 23 a6 3c f2 24 f2 11 ce 1a 0d

    对音频数据按照上面WAV文件结构进行划分:

    在这里插入图片描述
    我们可以得到RIFF chunk, ChunkSize, Subchunk1Size,AudioFormat等相关参数,这里要注意除了ASCII字符,其他数据都是以小端序存储的。 比如ByteRate为 88 58 01 00,小端序应为:0x00015888,对应的十进制为88200。
    在这里插入图片描述
    再比如BlockAlign=4, 根据我们前面举的例子计算(双倍),它是一段双声道音频。

    那对于一段实际音频,我们如何查看它的十六进制格式呢?我们可以通过 Hex Editor这个软件,

    HxD Hex Editor 是一款功能强大的十六进制编辑器和磁盘编辑器,它可以让你直接查看和编辑二进制文件的内容。你可以使用HxD Hex
    Editor来分析、修改和处理各种数据格式,包括程序文件、磁盘映像、内存转储以及其他二进制文件。

    这里我自己生成了一段30S的WAV音频。我们用HxD软件打开它看看。

    在这里插入图片描述

    在这里插入图片描述

    当我们框选头四个字节时,可以看到右边也有显示它的对应文本为:RIFF,表示这是一个基于RIFF格式的文件。我们将每个数据按照上面的结构进行划分,可以看到这个数据格式和我们介绍的WAV格式相符。除了框选的部位,后面都是真正的WAV音频数据即data。 框选的所有部分我们称之为 文件头,以四个字节为一组,数一下可以发现刚好有11组,11 * 4= 44字节,刚好是WAV文件头的字节数。 而WAV数据大小就是上面图片最后红色框的2646016字节,则整个WAV文件字节数应为2646016 + 44 = 2646060字节。右键查看这个音频的文件属性:
    在这里插入图片描述
    这和我们的计算结果一致。

    关于这个WAV文件头的详细信息如下:

    52 49 46 46 RIFF标识
    24 60 28 00 ChunkSize = 2646052(除去前8个字节文件大小)
    57 41 56 45 WAV标识
    66 6D 74 20 fmt标识
    10 00 00 00 , Subchunk1Size =16(表示 PCM 格式时固定为16)
    01 00 AudioFormat=1 ,音频格式:PCM(未压缩)(表示 PCM 格式时固定为1)
    01 00 声道数:1(单声道)
    44 ac 00 00 采样率:44100 Hz
    88 58 01 00 字节率:88200 字节/秒
    02 00 块对齐:2字节(每个采样点的字节数)
    10 00 位深度:16位(每个采样点2字节)
    64 61 74 61 data标识
    00 60 28 00 Subchunk2Size = 2646016 (音频数据大小)

    WAV文件大小:2646060 字节

    现在我们是通过WAV文件信息得到这些参数,比如音频数据大小 2646016 。前面我们说过WAV文件大小可以预估,那我们来计算一下看看有什么差异。以上面我生成的audio.wav文件为例, 假设我们已经知道一些基本参数,一段采样率44100, 采样深度16bit, 单声道WAV音频,如果我们通过字节速率ByteRate去计算再乘以时间,则估计总音频文件大小应为44100 * 16 / 8 * 30 = 2646000字节,但实际大小为2646016字节,我们估计出来的音频大小比实际小。这是因为采样音频时长并不是精确的30 秒, 如果是精确30秒,采样点数量应该是44100 × 30 = 1323000个,我们通过 Subchunk2Size (实际音频大小),计算实际样本数却为2646016 / 2 = 1323008,比 1323000 多 8 个采样点,而每个采样点占 2 字节,所以实际整体多了16字节。 反过来我们可以计算实际采样时间为1323008 / 44100 ≈ 30.0001814058956秒, 多出8个采样点的时间刚好为1 / 44100 * 8 = 0.0001814058956秒。所以我们通过时间去预估WAV音频数据大小的话和实际相比是有差异的,但是我们一般会先预估大小,然后再更新WAV文件头。

    使用ESP32将WAV文件写入SD卡

    以上我们介绍了WAV相关内容后,我们将介绍一个例子,将WAV音频文件写入SD卡,生成的WAV音频为一段1S的正弦波音频。
    上面我们知道通过一段WAV文件头信息,可以得到它的一些参数;反过来我们也可以写入一些参数到WAV文件头里,生成WAV文件,所以WAV头部的定义是不可避免的。

    【定义WAV文件头】

    假设我们要生成的WAV音频参数,采样率8000,采样深度16bit, 单声道,那么我们可以预估ChunkSize,Subchunk2Size(即datasize)大小,因为采样率是8000Hz,我们要生成1秒的音频,则1秒有8000个样本,每个样本大小为2字节(采样深度16bit),则 datasize = 16000, 根据公式直接计算的话就是NumSamples × NumChannels × BitsPerSample / 8 = 8000 x 1 x 16 /8 = 16000字节ChunkSize = 36 + datasize = 16036字节。其他参数可以参考上面的表格,这里就不赘述了。将其转化为16进制,小端序,

    定义WAV文件头:

    const uint8_t wavHeader[44] = {0x52, 0x49, 0x46, 0x46, // "RIFF"0xA4, 0x3E, 0x00, 0x00, // chunksize: 160360x57, 0x41, 0x56, 0x45, // "WAVE"0x66, 0x6D, 0x74, 0x20, // "fmt "0x10, 0x00, 0x00, 0x00, // fmt块大小 (16)0x01, 0x00,             // 音频格式 (1 = PCM)0x01, 0x00,             // 声道数 (1)0x40, 0x1F, 0x00, 0x00, // 采样率 (8000 Hz)0x80, 0x3E, 0x00, 0x00, // 字节率 (16000)0x02, 0x00,             // 块对齐 (2)0x10, 0x00,             // 每样本位数 (16)0x64, 0x61, 0x74, 0x61, // "data"0x80, 0x3E, 0x00, 0x00  // datasize: 16000
    };
    

    【创建并打开文件】

    为了写入SD卡,我们还要初始化SD卡。创建一个文件取名为test.wav并打开它:

    #define SD_CS_PIN 5// 初始化SD卡
    if (!SD.begin(SD_CS_PIN)) {
    Serial.println("SD卡初始化失败!");
    return;
    }
    Serial.println("SD卡初始化成功。");//创建并打开文件
    File wavFile = SD.open("/test.wav", FILE_WRITE);
    if (!wavFile) {
    Serial.println("无法创建文件!");
    return;
    }
    

    【写入WAV头部】

    File 类是Arduino SD库的一部分,这里我们创建了一个 File 类对象取名为wavFile,wavFile.write用于向 SD 卡上的文件写入数据。使用size_t write(const uint8_t *buf, size_t size)将文件头写入前面创建的文件中,这里要注意第一个参数类型是 uint8_t *类型的,如果写入的buffer不是uint8_t *类型,需要进行强制类型转换。

    wavFile.write(wavHeader, 44);
    

    【 生成440Hz正弦波音频】

    正弦波公式为:y = A * sin(ωt+φ)

    其中,

    A:振幅,那么y的取值范围就是[-A, A]
    ω:角频率,ω = 2 * π * f,其中f为频率,周期T = 1 / f
    φ:初相位;

    以下是生成一段1秒440Hz正弦波音频的示例:

    // 生成并写入440Hz正弦波音频数据
    const int sampleRate = 8000;  // 采样率
    const int frequency = 440;    // 正弦波频率
    const int numSamples = sampleRate * 1; // 1秒的样本数
    for (int i = 0; i < numSamples; i++) {
    float time = (float)i / sampleRate;
    int16_t sample = (int16_t)(32767.0 * sin(2.0 * PI * frequency * time));
    }
    

    【写入WAV音频文件并关闭文件】

    使用size_t write(const uint8_t *buf, size_t size)将前面生成的正弦波音频数据写入前面创建的文件中并进行强制类型转换。

    wavFile.write((uint8_t*)&sample, 2); // 写入16位样本
    wavFile.close();
    Serial.println("WAV文件写入完成。");
    

    整合后的代码如下:

    #include <SD.h>
    #include <SPI.h>// SD卡片选引脚
    #define SD_CS_PIN 5// WAV文件头部(44字节)
    const uint8_t wavHeader[44] = {0x52, 0x49, 0x46, 0x46, // "RIFF"0xA4, 0x3E, 0x00, 0x00, // chunksize: 160360x57, 0x41, 0x56, 0x45, // "WAVE"0x66, 0x6D, 0x74, 0x20, // "fmt "0x10, 0x00, 0x00, 0x00, // fmt块大小 (16)0x01, 0x00,             // 音频格式 (1 = PCM)0x01, 0x00,             // 声道数 (1)0x40, 0x1F, 0x00, 0x00, // 采样率 (8000 Hz)0x80, 0x3E, 0x00, 0x00, // 字节率 (16000)0x02, 0x00,             // 块对齐 (2)0x10, 0x00,             // 每样本位数 (16)0x64, 0x61, 0x74, 0x61, // "data"0x80, 0x3E, 0x00, 0x00  // datasize: 16000
    };void setup() {
    Serial.begin(115200);// 初始化SD卡
    if (!SD.begin(SD_CS_PIN)) {
    Serial.println("SD卡初始化失败!");
    return;
    }
    Serial.println("SD卡初始化成功。");// 创建并打开文件
    File wavFile = SD.open("/test.wav", FILE_WRITE);
    if (!wavFile) {
    Serial.println("无法创建文件!");
    return;
    }// 写入WAV头部
    wavFile.write(wavHeader, 44);// 生成并写入440Hz正弦波音频数据
    const int sampleRate = 8000;  // 采样率
    const int frequency = 440;    // 正弦波频率
    const int numSamples = sampleRate * 1; // 1秒的样本数
    for (int i = 0; i < numSamples; i++) {
    float time = (float)i / sampleRate;
    int16_t sample = (int16_t)(32767.0 * sin(2.0 * PI * frequency * time));
    wavFile.write((uint8_t*)&sample, 2); // 写入16位样本
    }// 关闭文件
    wavFile.close();
    Serial.println("WAV文件写入完成。");
    }void loop() {
    }
    

    这里我们观察到如果使用数组定义WAV文件头的话需要计算它的十六进制比较麻烦,我们可以定义一个WAV头部结构体,写入ASCII字符和公式,这样可以更方便地计算 WAV 文件头的信息,而不用手动去处理十六进制数据。

    使用结构体定义WAV文件头:

    // 定义 WAV 头部结构体
    struct WavHeader {char     riff[4] = {'R', 'I', 'F', 'F'};    // "RIFF"uint32_t chunkSize;                         // 文件大小 - 8char     wave[4] = {'W', 'A', 'V', 'E'};    // "WAVE"char     fmt[4] = {'f', 'm', 't', ' '};     // "fmt "uint32_t fmtChunkSize = 16;                 // fmt 块大小 (16 for PCM)uint16_t audioFormat = 1;                   // 音频格式 (1 = PCM)uint16_t numChannels = 1;                   // 声道数 (1 = 单声道)uint32_t sampleRate = SAMPLE_RATE;          // 采样率 (8000 Hz)uint32_t byteRate = SAMPLE_RATE * 2;        // 字节率 (sampleRate * numChannels * bitsPerSample / 8)uint16_t blockAlign = 2;                    // 块对齐 (numChannels * bitsPerSample / 8)uint16_t bitsPerSample = 16;                // 每样本位数 (16 bits)char     data[4] = {'d', 'a', 't', 'a'};    // "data"uint32_t dataSize;                          // 数据块大小
    };
    

    使用结构体定义WAV文件头的话我们只是定义了一个类型,所以我们需要定义一个结构体变量,因为我们没有直接给出 chunkSize datasize ,所以我们需要计算音频数据大小,创建并初始化WAV文件头。这里由于样本比较简单,所以我们直接可以确定样本数去计算音频数据大小,后面就不需要再更新WAV文件头了。

     // 计算音频数据大小const int numSamples = SAMPLE_RATE * 1;     // 1 秒的样本数const int bytesPerSample = 2;               // 16 位,每个样本 2 字节uint32_t dataSize = numSamples * bytesPerSample; // 数据大小:16000 字节uint32_t chunkSize = 36 + dataSize;         // 文件总大小 - 8:16036 字节// 创建并初始化 WAV 头部WavHeader header;header.chunkSize = chunkSize;               // 设置 chunkSizeheader.dataSize = dataSize;                 // 设置 dataSize
    

    完整代码

    修改后的完整代码如下:

    #include <SD.h>
    #include <SPI.h>// 定义常量
    #define SD_CS_PIN 5         // SD卡片选引脚
    #define SAMPLE_RATE 8000    // 采样率(8000 Hz)
    #define PI 3.1415926535     // π 值// 定义 WAV 头部结构体
    struct WavHeader {char     riff[4] = {'R', 'I', 'F', 'F'};    // "RIFF"uint32_t chunkSize;                         // 文件大小 - 8char     wave[4] = {'W', 'A', 'V', 'E'};    // "WAVE"char     fmt[4] = {'f', 'm', 't', ' '};     // "fmt "uint32_t fmtChunkSize = 16;                 // fmt 块大小 (16 for PCM)uint16_t audioFormat = 1;                   // 音频格式 (1 = PCM)uint16_t numChannels = 1;                   // 声道数 (1 = 单声道)uint32_t sampleRate = SAMPLE_RATE;          // 采样率 (8000 Hz)uint32_t byteRate = SAMPLE_RATE * 2;        // 字节率 (sampleRate * numChannels * bitsPerSample / 8)uint16_t blockAlign = 2;                    // 块对齐 (numChannels * bitsPerSample / 8)uint16_t bitsPerSample = 16;                // 每样本位数 (16 bits)char     data[4] = {'d', 'a', 't', 'a'};    // "data"uint32_t dataSize;                          // 数据块大小
    };void setup() {Serial.begin(115200);// 初始化 SD 卡if (!SD.begin(SD_CS_PIN)) {Serial.println("SD卡初始化失败!");return;}Serial.println("SD卡初始化成功。");// 创建并打开文件File wavFile = SD.open("/test.wav", FILE_WRITE);if (!wavFile) {Serial.println("无法创建文件!");return;}// 计算音频数据大小const int numSamples = SAMPLE_RATE * 1;     // 1 秒的样本数const int bytesPerSample = 2;               // 16 位,每个样本 2 字节uint32_t dataSize = numSamples * bytesPerSample; // 数据大小:16000 字节uint32_t chunkSize = 36 + dataSize;         // 文件总大小 - 8:16036 字节// 创建并初始化 WAV 头部WavHeader header;header.chunkSize = chunkSize;               // 设置 chunkSizeheader.dataSize = dataSize;                 // 设置 dataSize// 写入 WAV 头部wavFile.write((uint8_t*)&header, sizeof(header));// 生成并写入音频数据(440 Hz 正弦波)const int frequency = 440;for (int i = 0; i < numSamples; i++) {float time = (float)i / SAMPLE_RATE;int16_t sample = (int16_t)(32767.0 * sin(2.0 * PI * frequency * time));wavFile.write((uint8_t*)&sample, 2);}// 关闭文件wavFile.close();Serial.println("WAV文件写入完成。");
    }void loop() {
    }
    

    以上通过ESP32生成的一段1S的正弦波音频写入SD卡模块,硬件上只需ESP32和SD模块。下面我们介绍如何将ESP32和SD模块进行接线。

    ESP32

    在这里插入图片描述

    SD卡模块

    在这里插入图片描述

    ESP32和SD模块接线

    ESP32SD模块
    D5CS
    D18SCK
    D23MOSI
    D19MISO
    5VVCC
    GNDGND

    按照以上步骤,编译上传代码后,应能在SD卡找到生成的名为test.wav的音频文件,播放会听到1秒的正弦波声音。

    同样我们用HxD软件打开我们生成的test.wav文件

    在这里插入图片描述

    对比我们代码里的WAV文件头数据,可以发现数据是一样的,这说明WAV文件头确实是按照我们的要求写入了WAV文件了,而且使用数组或者结构体表示 WAV 头部这两种方法都可以实现,建议采用第二个代码的方式,使用结构体和动态计算 chunkSize等数据,确保 WAV 文件头部的正确性、灵活性和兼容性。

    总结

    以上我们介绍了什么是WAV音频文件,还有一些音频格式的相关概念、参数,并实际观察了WAV文件的数据内容,对WAV文件结构有了更深入的了解,然后我们通过ESP32生成了一段1S的正弦波音频,并将其写入SD模块,方法是通过将音频参数写入WAV文件头,并通过SD和文件系统相关函数将文件头写入我们创建的文件里,这样我们就可以在SD卡里通过读卡器读取里面的正弦波音频数据了。

    关于WAV文件头的每个参数是如何计算和填写的,在我们介绍WAV文件头的时候,已经举例并且说明了,我们也可以直接参考一开始总结的表格,里面有详细说明和相关公式,这些公式并不需要死记硬背,理解了每个参数的含义还是比较容易理解的。在介绍WAV文件头的时候,还有一些参数没有详细说明,比如AudioFormat, 1表示PCM,至于其他值表示的压缩格式应该是什么样子这里没有提到,还有LIST 块相关本文也没有提到,因为我们主要针对WAV文件进行介绍,所以这里不作提及,感兴趣的小伙伴可以自行去了解下~

    本文是为后面ESP I2S音频学习内容作为铺垫,因为WAV文件格式的内容还是比较多的,所以单独写一篇介绍。后面大家关于WAV文件有疑惑的地方,可以参考这篇文章。因为本人也是初学,以上是个人理解加上搜索资料学习到的,如果有什么问题,可以提出交流讨论,欢迎指正!需要HxD软件和想听一下源代码工程生成的WAV音频文件是什么声音的可以评论区留言!已经整理好所有文件 ~ 创作不易,多多点赞收藏哦! ~

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/bicheng/77237.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

端侧大模型综述On-Device Language Models: A Comprehensive Review

此为机器翻译&#xff0c;仅做个人学习使用 设备端语言模型&#xff1a;全面回顾 DOI&#xff1a;10.48550/arXiv.2409.00088 1 摘要 大型语言模型 &#xff08;LLM&#xff09; 的出现彻底改变了自然语言处理应用程序&#xff0c;由于减少延迟、数据本地化和个性化用户体验…

推流265视频,网页如何支持显示265的webrtc

科技发展真快&#xff0c;以前在网页上&#xff08;一般指谷歌浏览器&#xff09;&#xff0c;要显示265的视频流&#xff0c;都是很鸡肋的办法&#xff0c;要么转码&#xff0c;要么用很慢的hls&#xff0c;体验非常不好&#xff0c;而今谷歌官方最新的浏览器已经支持265的web…

redis的sorted set的应用场景

Redis 的 Sorted Set&#xff08;有序集合&#xff0c;简称 ZSet&#xff09; 结合了 Set 的去重特性 和 按分数&#xff08;score&#xff09;排序 的特性&#xff0c;非常适合需要 高效排序 或 范围查询 的场景。以下是它的典型应用场景及示例&#xff1a; 实时排行榜 场景&…

18-21源码剖析——Mybatis整体架构设计、核心组件调用关系、源码环境搭建

学习视频资料来源&#xff1a;https://www.bilibili.com/video/BV1R14y1W7yS 文章目录 1. 架构设计2. 核心组件及调用关系3. 源码环境搭建3.1 测试类3.2 实体类3.3 核心配置文件3.4 映射配置文件3.5 遇到的问题 1. 架构设计 Mybatis整体架构分为4层&#xff1a; 接口层&#…

未启用CUDA支持的PyTorch环境** 中使用GPU加速解决方案

1. 错误原因分析 根本问题&#xff1a;当前安装的PyTorch是CPU版本&#xff0c;无法调用GPU硬件加速。当运行以下代码时会报错&#xff1a;model YOLO("yolov8n.pt").to("cuda") # 或 .cuda()2. 解决方案步骤 步骤1&#xff1a;验证CUDA可用性 在Pyth…

JVM-基于Hotspot

前言 Java虚拟机&#xff08;Java Virtual Machine简称JVM&#xff09;是运行所有Java程序的抽象计算机&#xff0c;是Java语言的运行环境&#xff0c;其主要任务为将字节码装载到内部&#xff0c;解释/编译为对应平台上的机器指令执行。 Java虚拟机规范定义了一个抽象的——…

智能合约安全审计平台——可视化智能合约漏洞扫描

目录 可视化智能合约漏洞扫描 —— 理论、实践与安全保障1. 引言2. 理论背景与漏洞原理2.1 智能合约简介2.2 常见漏洞类型2.3 漏洞扫描与安全评估原理3. 系统架构与工作流程3.1 系统总体架构3.2 模块说明4. 漏洞扫描流程详解4.1 代码上传与静态解析4.2 漏洞模式检测4.3 风险评估…

【MySQL数据库】数据类型详解

目录 数据类型tinyint类型(整形)bit类型小数浮点数 float、doubledecimal 字符串类型charvarcharchar与varchar的比较 日期时间类型enum和set总结 数据类型 tinyint类型(整形) 例&#xff1a; mysql> create table tt1(num tinyint);mysql> insert into tt1 values(1)…

咪咕MG101_晨星MSO9380芯片_安卓5.1.1_免拆卡刷固件包

咪咕MG101_晨星MSO9380芯片_安卓5.1.1_免拆卡刷固件包&#xff08;内有教程&#xff09; 刷机教程简单说明&#xff1a; 1、把下载好的刷机包&#xff0c;U盘里建立一个upgrade文件夹&#xff0c;固件放入此文件夹里&#xff0c;放入U盘中&#xff0c;注意升级包为压缩包不要对…

CS61A:STRING REPRESENTATION

Python 规定所有对象都应该产生两种不同的字符串表示形式&#xff1a;一种是人类可解释的文本&#xff0c;另一种是 Python 可解释的表达式。字符串的构造函数 str 返回一个人类可读的字符串。在可能的情况下&#xff0c;repr 函数会返回一个计算结果相等的 Python 表达式。rep…

LangChain缓存嵌入技术完全指南:CacheBackedEmbedding原理与实践(附代码示例)

一、嵌入缓存技术背景与应用场景 1.1 为什么需要嵌入缓存&#xff1f; 算力消耗问题&#xff1a;现代嵌入模型&#xff08;如text-embedding-3-small&#xff09;单次推理需要约0.5-1秒/文本 资源浪费现状&#xff1a;实际业务中约30%-60%的文本存在重复计算 成本压力&#…

精益数据分析(3/126):用数据驱动企业发展的深度解析

精益数据分析&#xff08;3/126&#xff09;&#xff1a;用数据驱动企业发展的深度解析 大家好&#xff01;一直以来&#xff0c;我都坚信在当今竞争激烈的商业环境中&#xff0c;数据是企业获得竞争优势的关键。最近深入研究《精益数据分析》这本书&#xff0c;收获颇丰&…

wpf ScaleTransform

在WPF中&#xff0c;ScaleTransform是用于实现元素缩放的核心类&#xff0c;属于System.Windows.Media命名空间下的变换类型。以下是其主要特性与使用方式的总结&#xff1a; ‌核心属性‌ ‌缩放比例‌ ScaleX&#xff1a;水平方向缩放比例&#xff08;默认1.0&#xff0c;即…

用纯Qt实现GB28181协议/实时视频/云台控制/预置位/录像回放和下载/事件订阅/语音对讲

一、前言 在技术的长河中探索&#xff0c;有些目标一旦确立&#xff0c;便如同璀璨星辰&#xff0c;指引着我们不断前行。早在2014年&#xff0c;我心中就种下了用纯Qt实现GB28181协议的种子&#xff0c;如今回首&#xff0c;一晃十年已逝&#xff0c;好在整体框架和逻辑终于打…

0x01、Redis 主从复制的实现原理是什么?

Redis 主从复制概述 Redis 的主从复制是一种机制&#xff0c;允许一个主节点&#xff08;主实例&#xff09;将数据复制到一个或多个从节点&#xff08;从实例&#xff09;。通过这一机制&#xff0c;从节点可以获取主节点的数据并与之保持同步。 复制流程 开始同步&#xf…

整活 kotlin + springboot3 + sqlite 配置一个 SQLiteCache

要实现一个 SQLiteCache 也是很简单的只需要创建一个 cacheManager Bean 即可 // 如果配置文件中 spring.cache.sqlite.enable false 则不启用 Bean("cacheManager") ConditionalOnProperty(name ["spring.cache.sqlite.enable"], havingValue "t…

深入探索如何压缩 WebAssembly

一、初始体积&#xff1a;默认 Release 构建 我们从最基础的构建开始&#xff0c;不开启调试符号&#xff0c;仅使用默认的 release 模式&#xff1a; $ wc -c pkg/wasm_game_of_life_bg.wasm 29410 pkg/wasm_game_of_life_bg.wasm这是我们优化的起点 —— 29,410 字节。 二…

多角度分析Vue3 nextTick() 函数

nextTick() 是 Vue 3 中的一个核心函数&#xff0c;它的作用是延迟执行某些操作&#xff0c;直到下一次 DOM 更新循环结束之后再执行。这个函数常用于在 Vue 更新 DOM 后立即获取更新后的 DOM 状态&#xff0c;或者在组件渲染完成后执行某些操作。 官方的解释是&#xff0c;当…

前端面试-自动化部署

基础概念 什么是CI/CD&#xff1f;在前端项目中如何应用&#xff1f;自动化部署相比手动部署有哪些优势&#xff1f;常见的自动化部署工具有哪些&#xff1f;举例说明它们的区别&#xff08;如Jenkins vs GitHub Actions&#xff09;。如何通过Git Hook实现自动化部署&#xf…

架构生命周期(高软57)

系列文章目录 架构生命周期 文章目录 系列文章目录前言一、软件架构是什么&#xff1f;二、软件架构的内容三、软件设计阶段四、构件总结 前言 本节讲明架构设计的架构生命周期概念。 一、软件架构是什么&#xff1f; 二、软件架构的内容 三、软件设计阶段 四、构件 总结 就…