【M5Stack物联网开发】第五章 播放音频

1 内存

1.1 内存

内存是计算机中用来保存数据的关键硬件组件,根据其功能和用途可以分为许多种类型。每种类型的内存在计算机系统中都扮演着独特且重要的角色,确保系统能够高效地运行和处理数据。

首先,随机存取存储器(RAM,Random Access Memory)是最常见的一种内存类型。RAM用于临时存储正在被处理的数据和指令,提供快速读写访问,以支持计算机的实时操作。RAM的特点是数据在断电后会丢失,因此它主要用于存储临时数据和当前执行的程序。

其次,只读存储器(ROM,Read-Only Memory)是一种非易失性存储器,它在制造时就已经写入了数据,这些数据在之后的使用过程中只能读取,不能修改。ROM通常用于存储固件,即计算机启动和基本操作所需的程序和数据,即使在断电的情况下,这些数据也不会丢失。

再者,缓存(Cache)是一种高速存储器,用于临时存储频繁访问的数据和指令,以提高计算机的处理速度。缓存通常位于CPU内部或与CPU紧密相连,它的访问速度比RAM更快,但容量较小。缓存分为多级(如L1、L2、L3),每一级缓存的速度和容量都不同。

此外,还有虚拟内存(Virtual Memory),它是一种将硬盘空间划分为扩展内存使用的技术。当物理内存(RAM)不足时,操作系统会将部分数据暂时存储到硬盘的虚拟内存中,从而扩展可用内存容量。虽然虚拟内存的访问速度较慢,但它可以显著提升系统的多任务处理能力。

最后,闪存(Flash Memory)是一种非易失性存储器,广泛应用于固态硬盘(SSD)、USB闪存驱动器和存储卡等设备中。闪存具有快速读写性能和较高的存储密度,适合用于存储大量数据。

通过这些不同类型的内存,计算机系统能够高效地管理和存储数据,满足各种应用需求。无论是临时数据存储、固件存储、高速缓存还是扩展内存,每种内存类型都在计算机的整体性能和功能中起到了至关重要的作用。

但无论是那种类型的内存,他们的目的都是一样的:保存数据。

内存就好像一个一个的小盒子,每一个小盒子称为一个内存块,并且这些小盒子还有自己独特的编号,称为内存地址。每个内存地址就像是一个邮政编码,确保数据能够准确地存储和检索。内存块的大小是固定的,但数据的大小可能会有所不同。

如果数据的大小小于盒子的容量,那么数据就可以直接存放在这个盒子里,既简单又高效。然而,如果数据的大小超过了单个盒子的容量,就需要进行一些处理,才能妥善保存这些数据。例如,可以将数据切割成多个部分,分别存放在多个盒子里;或者在某些情况下,可以将多个小盒子合并成一个大的盒子,以便容纳较大的数据块。这种灵活的处理方式确保了内存能够高效地利用和管理各种大小的数据。

在计算机中,最小的内存单位称为1位(bit),或者一个二进制数,它只能保存0或1。尽管1bit的存储容量非常有限,但它是构成更大存储单元的基本单位。

将8个bit组合在一起,称为一个字节(byte)。一个字节是计算机中最常用的基本存储单元之一。例如,一个字符(char)类型的数据通常占用一个字节,一个布尔(bool)类型的数据也占用一个字节。字节是计算机处理中最常见的单位,因为它能够表示256种不同的值(8个二进制数,表示从0到255个十进制数),足以表示大多数基本数据类型。

将4个字节组合在一起,称为一个字(word)。一个字通常用于表示更大范围的数值或更复杂的数据类型。例如,一个整型(int)类型的数据,或者一个浮点型(float)类型的数据通常占用一个字。一个双精度浮点型(double)类型的数据则占用两个字,即8个字节。这种结构允许计算机高效地处理和存储更大范围的数据。

通过这种方式,内存能够灵活地适应和存储各种类型和大小的数据,从最基本的二进制数到复杂的浮点数和字符数据。内存的这种灵活性和结构化设计,是计算机能够高效运行和处理大量数据的基础。

1.2 内存地址

在上一节中,我们提到内存地址类似于邮政编码,每个小盒子都有一个独特的邮政编码。同样,每个小内存块也有一个独特的内存地址。内存地址的长度与CPU一次可以处理的二进制数据量直接相关。例如,Intel 8086系列CPU一次最多可以处理32位数据,因此称为32位CPU。而当前的Intel Core系列CPU一次最多可以处理64位数据,因此称为64位CPU。

以下是一些内存地址的示例,展示了不同位数的CPU如何表示内存地址:

32位CPU的内存地址

在32位CPU上,内存地址通常是一个32位的十六进制数。例如:

  • 0x00400000
  • 0x7FFFFFFF
  • 0x80000000
  • 0xFFFFFFFF

这些地址表示可以访问的内存范围从0x000000000xFFFFFFFF,共计4GB的地址空间。

注:1个bit可以表示2个数字,n个bit可以表示2n个数字,每个数字对应一个地址空间

232 bit = 232 / 8 byte = 232 / 8 / 210 KB = 222 / 8 / 210 MB = 212 / 8 / 210 GB = 4 GB

64位CPU的内存地址

在64位CPU上,内存地址通常是一个64位的十六进制数。例如:

  • 0x0000000000400000
  • 0x00007FFFFFFFFFFF
  • 0xFFFFFFFF80000000
  • 0xFFFFFFFFFFFFFFFF

这些地址表示可以访问的内存范围从0x00000000000000000xFFFFFFFFFFFFFFFF,共计16EB(Exabytes)的地址空间。

内存地址的类型

  • 物理地址(Physical Address):物理地址是指实际的硬件内存地址,它直接对应于内存芯片上的位置。

  • 物理地址由内存控制器管理,通常只有操作系统和硬件层能够直接访问。

  • 虚拟地址(Virtual Address):虚拟地址是指由操作系统提供给应用程序的地址。虚拟地址通过内存管理单元(MMU)映射到物理地址。

  • 虚拟地址空间的存在使得每个应用程序可以认为自己独占整个内存,从而简化了编程和内存管理。

内存地址的分配

操作系统通过不同的策略来管理内存地址的分配:

  • 堆(Heap):堆是用于动态内存分配的区域。程序可以在运行时通过函数(如C语言中的mallocfree、C++中的new和delete)来请求和释放内存。

  • 栈(Stack):栈是用于函数调用的区域。每次函数调用都会在栈上分配一个栈帧,用于存储函数的局部变量和返回地址。栈的大小通常是有限的,过深的递归调用可能导致栈溢出。

  • 全局/静态区(Global/Static Area):用于存储全局变量和静态变量。这些变量在程序开始时分配,并在程序结束时释放。

  • 代码区(Code Area):用于存储程序的机器指令。代码区通常是只读的,以防止程序意外地修改自己的指令。

内存地址的使用

  • 指针(Pointers):在编程语言中,指针用于存储内存地址。例如,在C++语言中,指针变量可以存储另一个变量的地址,并通过解引用(dereferencing)来访问该地址上的值。
  • 数组(Arrays):数组的元素在内存中是连续存储的,因此可以通过数组名和索引来计算每个元素的内存地址。例如,对于一个整数数组int arr[10]arr[0]的地址就是arr的地址,而arr[1]的地址是arr + sizeof(int)
  • 内存映射文件(Memory-Mapped Files):内存映射文件技术允许将文件的内容映射到内存地址空间,从而可以像访问内存一样访问文件内容。这在处理大文件时非常有用,因为它可以减少I/O操作的开销。

1.3 C++中的数据内存

C++中有许多数据类型,这些数据类型就相当于一个个小盒子,可以往里面放各种东西,根据类型的不同,这些小盒子(内存空间)的大小也不相同。 

除了之前学过的五种基本数据类型:char、int、float、double、bool,还可以为这些数据类型添加修饰词,改变其在内存中所占用的空间大小,以及可以表示数据范围。

比如:int类型,默认占用32bit内存空间,可以表示正负整数,取值范围为:-2^31 到 2^31 - 1,即 -2147483648 到 2147483647

如果使用unsigned修饰int,变为unsigned int类型,则只能表示所有正整数,取值范围为:0 到 2^32 - 1,即 0 到 4294967295

如果使用short修饰int,变为short int类型,默认占用16bit内存空间,虽然可以表示正负整数,但取值范围为:-2^15 到 2^15 - 1,即 -32768 到 32767

在C++中,longshortunsigned和 signed 是用于修饰基本整数类型的关键字。这些修饰符可以组合使用,以创建不同范围和大小的整数类型。以下是对这些修饰符的详细解释:

short 和 long

  • short:表示短整型,通常占用2字节(16位)。
  • long:表示长整型,在32位系统上通常占用4字节(32位),在64位系统上可能占用8字节(64位)。
  • long long:表示更长的整型,通常占用8字节(64位)。

signed 和 unsigned

  • signed:表示有符号整数类型,可以表示正数、负数和零。默认情况下,整数类型(如intshortlong)都是有符号的。
  • unsigned:表示无符号整数类型,只能表示非负数(正数和零)。

类型组合

这些修饰符可以组合使用,以创建不同的整数类型。以下是一些常见的组合及其含义:

  • short:等同于short int,表示短整型。
  • long:等同于long int,表示长整型。
  • long long:等同于long long int,表示更长的整型。
  • unsigned int:表示无符号整型。
  • signed int:表示有符号整型,等同于int
  • unsigned short:表示无符号短整型。
  • signed short:表示有符号短整型,等同于short
  • unsigned long:表示无符号长整型。
  • signed long:表示有符号长整型,等同于long
  • unsigned long long:表示无符号更长的整型。
  • signed long long:表示有符号更长的整型,等同于long long

1.4 数组

在C++中,数组是一种用于存储同类型数据的集合。数组中的每个元素都可以通过数组名和一个索引值来访问。char类型可以用来存储单个字符,char数组则可以保存一组字符,如果默认在字符最后添加一个表示字符串结束的转移字符“\0”,这样的字符数组就变成了String字符串类型。以下是一些关于C++数组的基本概念和使用方法:

一维数组

声明和初始化:数组的声明和初始化格式与单个变量相同,区别是要在变量名称后面标出数组中元素的个数,也称为数组长度。

int myArray[5]; // 声明一个包含5个整数的数组
int myArray[5] = {1, 2, 3, 4, 5}; // 声明并初始化数组

访问元素

int value = myArray[2]; // 访问数组中的第三个元素,索引从0开始 
myArray[0] = 10; // 修改第一个元素的值

遍历数组

// 注意索引从0开始,i的取值范围是0,1,2,3,4
for(int i = 0; i < 5; i++) 
{ 
    M5.Display.print(myArray[i]);
}

多维数组(以二维为例)

声明和初始化

int matrix[3][4]; // 声明一个3行4列的二维数组 
int matrix[3][4] = { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; // 声明并初始化二维数组

访问元素

int value = matrix[1][2]; // 访问第二行第三列的元素 
matrix[0][0] = 10; // 修改第一行第一列的元素

遍历数组

 for(int i = 0; i < 3; i++) { 
    for(int j = 0; j < 4; j++) { 
        M5.Display.print(matrix[i][j]); 
    } 
}

动态数组

使用new运算符

// *表示指针,下一小节讲授指针的内容
// 指针表示一段内存空间中起始内存块的地址
// 每一个内存块的大小由*前面的数据类型决定
// int* 表示每一个内存块的大小是32bit
int* dynamicArray
dynamicArray = new int[5]; // 动态分配一个包含5个整数的数组 
dynamicArray[0] = 1; // 赋值 
delete[] dynamicArray; // 释放内存

使用std::vector

复制代码
// STL是C++提供的一个标准容器库
// 各种复杂数据类型的声明、定义、处理方法都已经提前实现好了
// std::vector是是一种动态数组类型,可以再使用过程中随时添加/删除数组元素
#include <vector> 

std::vector<int> myVector(5); // 声明一个包含5个整数的动态数组 
myVector[0] = 1; // 赋值 
// myVector[5] = 2; 数组越界错误,因为数组大小就是5个元素
myVector.push_back(6); // 添加元素
myVector[5] = 2;
复制代码

注意事项

  • 数组的索引从0开始。
  • 访问数组越界会导致未定义行为。
  • 动态分配的数组需要手动释放内存。

1.5 结构体

在C++中,结构体(struct)是一种用户定义的数据类型,它允许你将不同类型的数据组合在一起。结构体在组织和管理复杂数据时非常有用。以下是关于C++结构体的一些基本概念和用法:

结构体的定义和声明

定义结构体

struct Person { 
    String name; 
    int age; 
    double height; 
}; // 注意最后有个分号

在上面的例子中,定义了一个名为Person的结构体,它包含三个成员:name(字符串类型)、age(整数类型)和height(双精度浮点数类型)。

声明结构体变量

Person person1; 
Person person2 = {"Alice", 30, 5.5}; // 初始化

 

访问和修改结构体成员

复制代码
// 使用"."来访问一个结构体类型变量的成员
person1.name = "Bob"; 
person1.age = 25; 
person1.height = 6.0; 
M5.Display.printf("Name:%s Age:%d Height:%f \n", person1.name, person1.age, person1.height);

// 修改成员
person2.age = 31; 
M5.Display.printf("Update Age:%d\n", person2.age);
复制代码

嵌套结构体

复制代码
// 结构体可以包含其他结构体作为其成员。
struct Address { 
    String city; 
    String state; 
    int zipCode; 
}; 

struct Person { 
    String name; 
    int age; 
    double height; 
    Address address; // 嵌套结构体 
}; 

Person person3; 
person3.address.city = "New York"; 
person3.address.state = "NY"; 
person3.address.zipCode = 10001; 
复制代码

结构体数组

// 定义结构体数组来存储多个结构体变量。
Person people[3] = { {"Alice", 30, 5.5}, {"Bob", 25, 6.0}, {"Charlie", 35, 5.8} }; 
for(int i = 0; i < 3; i++) 
{
    M5.Display.printf("Name:%s Age:%d Height:%f \n", person1.name, person1.age, person1.height);
 }

 

sizeof 是C++中的一个关键字,用于计算数据类型或对象在内存中的大小。它在编译时计算,并返回一个 size_t 类型的值,该值表示指定类型或对象所占的字节数。

复制代码
#include <M5Unified.h>

struct MyStruct {
    char a;
    int b;
};

void setup() 
{
    auto cfg = M5.config();
    M5.begin(cfg);
    
    M5.Display.printf("Size of int:  %d bytes\n", sizeof(int));
    M5.Display.printf("Size of double:  %d bytes\n", sizeof(double));
    M5.Display.printf("Size of char: %d bytes\n", sizeof(char));

    int a = 10;
    double b = 20.5;
    char c = 'A';

    M5.Display.printf("Size of a:  %d bytes\n", sizeof(a);
    M5.Display.printf("Size of b:  %d bytes\n", sizeof(b));
    M5.Display.printf("Size of c: %d bytes\n", sizeof(c));

    int arr[10];
    M5.Display.printf("Size of array arr: %d bytes\n", sizeof(arr));
    M5.Display.printf("Number of elements in arr: %d bytes\n", sizeof(arr) / sizeof(arr[0]));

    M5.Display.printf("Size of MyStruct: %d bytes\n", sizeof(MyStruct));
}
复制代码

 

1.5 指针

在C++中,取地址操作用于获取变量在内存中的地址。取地址操作符是&,它放在变量名前面。指针是一种特殊的变量,它存储的是另一个变量的内存地址。指针在C++编程中非常重要,因为它们允许你直接访问和操作内存,从而提高程序的灵活性和效率。

以下是关于取地址操作的一些基本概念和示例:

基本概念

  • 取地址操作符 (&):

    • 用于获取变量的地址。
    • 返回一个指向该变量的指针。
  • 指针变量:

    • 用于存储地址的变量。
    • 声明指针变量时,需要指定它指向的数据类型。

示例代码

以下是一些基本的示例代码,展示了如何使用取地址操作符和指针变量:

复制代码
#include <M5Unified.h>

void setup()
{
    auto cfg = M5.config();
    M5.begin(cfg);

    // &用来获取一个变量的地址
    // *用来获取一个地址中的值
    // type* 用来声明一个特定类型数据的指针

    int a = 10;  // 定义一个整数变量 a
    int *p = &a; // 定义一个指向整数的指针变量 p,并将 a 的地址赋给 p

    M5.Display.printf("value of a is %d\n", a);
    M5.Display.printf("add of a is %x\n", &a);
    M5.Display.printf("value of p is %x\n", p);
    M5.Display.printf("add of p is %x\n", &p);
    M5.Display.printf("value at add p is %d\n", *p);

    a = 5;
    M5.Display.printf("a = 5\n");
    M5.Display.printf("value of a is %d\n", a);
    M5.Display.printf("value at add p is %d\n", *p);
}

void loop()
{
}
复制代码

解释

  • int a = 10;:定义一个整数变量 a,并赋值为 10
  • int *p = &a;:定义一个指向整数的指针变量 p,并将 a 的地址赋给 p
  • &a:取 a 的地址。

在C++中,指针是一种特殊的变量,它存储的是另一个变量的内存地址。指针在C++编程中非常重要,因为它们允许你直接访问和操作内存,从而提高程序的灵活性和效率。

指针的基本概念

  • 指针的声明:

    • 指针变量的声明需要指定它指向的数据类型。
    • 语法:type *pointerName;,其中type是指针指向的数据类型,*表示这是一个指针。
  • 取地址操作符 (&):

    • 用于获取变量的地址。
    • 返回一个指向该变量的指针。
  • 取值操作符 (*):

    • 用于访问指针所指向的变量的值。
    • 语法:*pointerName
  • 注意 type* 和 * 是两个操作符

指针的其他用法

  • 指向数组的指针:

    • 可以定义指向不同数据类型的指针,如int*double*char*等指向数组的中的某个元素。
  • 指针的算术运算:

    • 指针可以进行算术运算,如加减操作,这在遍历数组时非常有用。
    • 如果不知道数组本身的长度时,可以与sizeof方法联用。
  • 指向指针的指针:

    • 可以定义指向指针的指针,即多级指针。
  • 空指针:

    • 空指针(nullptr)表示不指向任何对象的指针。
  • 动态内存分配:

    • 使用newdelete操作符进行动态内存分配和释放。

示例代码:

复制代码
#include <M5Unified.h>

void setup()
{
    auto cfg = M5.config();
    M5.begin(cfg);

    int arr[3] = {0,1,2};
    // sizeof函数可以计算一个变量占用的空间大小
    int arrlen = sizeof(arr) / sizeof(arr[0]); 
    int *arrp = &arr[0];

    M5.Display.printf("arr[2] = %d\n", arr[2]);
    arrp = arrp++; // arr[1]
    arrp = arrp++; // arr[2]
    *arrp = 10;
    M5.Display.printf("arr[2] = %d\n", *arrp);


    arrp = arrp++; // arr[3] 但是越界了 *arrp = 10;
    arrp = nullptr; // arrp 中没有值,不指向任何内存 *arrp = 10;

    int* dyarr;
    // dyarr此时不指向任何有意义的内存 dyarr[0] = 10
    dyarr = new int[20];
    int dyarrlen = 20;
    dyarr[2] = 5;
    M5.Display.printf("dyarr[2] = %d\n", dyarr[2]);
    delete dyarr;


    int a = 10;
    int *p = &a;
    // 定义指针的指针
    int **pp = &p;

    a = 5;
    M5.Display.printf("value of a is %d\n", a);
    M5.Display.printf("value at add p is %d\n", *p);

    *p = 7;
    M5.Display.printf("value of a is %d\n", a);
    M5.Display.printf("value at add p is %d\n", *p);

    **pp = 20;
    M5.Display.printf("value of a is %d\n", a);
    M5.Display.printf("value at add p is %d\n", *p);
}

void loop()
{
}
复制代码
复制代码
// 结构体指针
struct MyNumber
{
    int value;
}

MyNumber mu = MyNumber();
// 创建结构体指针,使用new初始化空间
MyNumber* mup = new MyNumber();

mu.value = 5;
// 访问指针的成员时,使用“->”
mup->value = 5;

// 功能是一样的,只是写法不同
复制代码

1.6 通用类型指针

在 C++ 中,强制类型转换(Type Casting)是将一个变量从一种数据类型转换为另一种数据类型的过程。C++ 提供了多种方式来进行类型转换,包括 C 风格的强制类型转换和 C++ 风格的强制类型转换。

C 风格的强制类型转换

这是最古老和最简单的类型转换方式,但它的安全性和可读性较差。

int a = 10; 
double b = (double)a; // C 风格的强制类

int *p = &b;
double *p = (int *)p;

理论上指针类型变量保存的值是一个内存地址,一旦CPU的位数确定,内存地址的长度也就确定了,所以指针类型的变量实际上可以指向任何类型的数据地址,只不过修改类型后,数据块的大小就会发生变化,从而引起数据错乱。

在 C++ 中,void* 指针是一种通用指针类型,它可以指向任何类型的数据,但不能直接解引用该地址中保存的值。void* 指针的主要用途是实现通用性和灵活性,允许在不知道具体类型的情况下处理数据。以下是对 void* 指针及其用途的详细解释。

void* 指针的定义

void* 指针是一种指向未指定类型的指针。由于它没有类型信息,因此不能直接进行解引用操作。

    void* ptr; 
    int a = 10; 
    ptr = &a; // ptr 现在指向一个 int 类型的变量

    // *ptr = 55.44; 无法赋值

void* 指针的用途

1. 通用数据结构

void* 指针常用于实现通用数据结构,例如链表、栈和队列,这些数据结构可以存储任何类型的数据。

struct Node { 
    void* data; // 任何类型数据
    Node* next; 
};

2. 通用函数接口

void* 指针也常用于实现通用函数接口,使得函数可以接受和返回任何类型的数据。

复制代码
void *getData()
{
    int *data = new int(10);
    return (void *)data;
}

int *processIntData(void *data)
{
    return (int *)data;
}

float *processFloatData(void *data)
{
    return (float *)data;
}
复制代码

1.7 引用

在C++中,引用(Reference)是一种用于为变量创建别名的方式。比如,你的名字是庞兴庆,你的同事可能管你叫小庞,你的学生可能管你叫庞老师,虽然名称不一样,但其实都指代了你这个人。引用提供了一种间接访问变量的方式,但与指针不同的是,引用在创建之后不能更改指向的对象。引用的语法相对简单,并且在许多情况下比指针更安全和简洁。

引用的基本语法

引用的基本语法如下:

类型 &引用名 = 变量名;

例如:

int a = 10; 
int &ref = a; // ref 是 a 的引用

注意:这里的&ref表示引用,而不是获取ref的地址,type&与&是两种操作符,type*与*也是两种操作符

特点和使用场景

复制代码
// 必须初始化:引用在声明时必须被初始化。
int x = 5; 
int &ref = x; // 正确 
 // int &ref2; 错误,引用必须初始化

// 不能重新绑定:一旦引用被初始化,它就不能被重新绑定到另一个变量。
int a = 10; 
int b = 20;
 int &ref = a; 
ref = b; // 这是给 a 赋值 20,而不是重新绑定 ref 到 b

// 常用于函数参数:引用常用于函数参数,以避免复制大对象,提高效率。
void func(int &x)
{
    x = x + 10;
}
int a = 5;
func(a); // a 变为 15

// 常用于返回值:函数可以返回引用,以允许修改调用者提供的对象。
int &getElement(int arr[], int index)
{
    return arr[index];
}
int myArray[5] = {1, 2, 3, 4, 5};
getElement(myArray, 2) = 10; // myArray[2] 变为 10

// 常量引用:可以使用常量引用来防止对引用对象的修改。
void printValue(const int &x)
{
    // x = 10;  错误,x 是常量引用,不能修改
}
复制代码

引用与指针的区别

  1. 语法不同:

    • 引用使用 & 符号,而指针使用 * 符号。
    • 引用在使用时与变量相同,指针在使用时需要使用取值符号“*”。
  2. 初始化与重新绑定:

    • 引用必须在声明时初始化,且不能重新绑定到另一个变量。
    • 指针可以在任何时候被赋值或重新指向另一个变量。
  3. 存储方式:

    • 引用本质上是一个别名,没有独立的存储空间。
    • 指针是一个变量,存储的是另一个变量的地址。

示例代码

以下是一个包含引用的示例代码:

复制代码
// 函数参数使用引用
void increment(int &n)
{
    n++;
}
// 函数返回值使用引用
int &getReference(int &n)
{
    return n;
}

int a = 5;
int &ref = a; // 引用
increment(a); // a 变为 6
int &ref2 = getReference(a);
ref2 = 10; // a 变为 10
复制代码

通过理解和使用引用,可以使代码更简洁和高效。

2 WAV文件结构

WAV 文件格式是基于 RIFF(Resource Interchange File Format)规范的,RIFF 是一种通用的文件格式,用于存储多媒体数据。RIFF 文件由一系列称为 "块"(Chunk)的数据块组成。每个块都有一个标识符和大小字段,后跟实际的数据。这种结构使得文件格式非常灵活和可扩展。

Chunk 的通用结构

每个 Chunk 的结构如下:

  • Chunk ID: 4 字节,标识 Chunk 类型。
  • Chunk Size: 4 字节,表示 Chunk 数据部分的大小,不包括 Chunk ID 和 Chunk Size 字段。
  • Chunk Data: 变长,实际的数据内容。

WAV 文件中的 Chunk

在 WAV 文件中,通常有以下几种主要的 Chunk:

  1. RIFF 头块 (RIFF Header):标识文件为 RIFF 格式,并且是 WAVE 类型。

    • Chunk ID: 4 字节,固定为 "RIFF"。
    • Chunk Size: 4 字节,文件大小减去 8 字节。
    • Format: 4 字节,固定为 "WAVE"。
  2. fmt 子块 (fmt Subchunk):包含音频格式信息,如音频格式、声道数、采样率等。

    • Subchunk1 ID: 4 字节,固定为 "fmt "。
    • Subchunk1 Size: 4 字节,PCM 格式时为 16。
    • Audio Format: 2 字节,1 表示 PCM(线性脉冲编码调制)。
    • Num Channels: 2 字节,音频通道数(1 表示单声道,2 表示立体声)。
    • Sample Rate: 4 字节,采样率(如 44100 Hz)。
    • Byte Rate: 4 字节,每秒字节数(Sample Rate * Num Channels * Bits Per Sample / 8)。
    • Block Align: 2 字节,每个采样帧的字节数(Num Channels * Bits Per Sample / 8)。
    • Bits Per Sample: 2 字节,每个样本的位数(如 16 位)。
  3. data 子块 (data Subchunk):包含实际的音频数据。

    • Subchunk2 ID: 4 字节,固定为 "data"。
    • Subchunk2 Size: 4 字节,音频数据的大小(Num Samples * Num Channels * Bits Per Sample / 8)。
    • Data: 实际的音频数据。

其他可能存在的 Chunk

除了上述主要的 Chunk,WAV 文件还可能包含其他的 Chunk,用于存储附加信息。例如:

  • LIST Chunk: 存储元数据,如标题、艺术家、版权信息等。
  • fact Chunk: 对于某些压缩格式,存储解压后的样本数。
  • cue Chunk: 存储标记点,用于音频编辑。

以下是一个 WAV 文件结构的示例:

Chunk ID(4 bytes)Chunk Size(4 bytes)Format(4 bytes)
"RIFF" FileSize - 8 "WAVE"

 

Subchunk1 ID(4 bytes)Subchunk1 Size(4 bytes)Audio Format(2 bytes)Num Channels(2 bytes)Sample Rate(4 bytes)Byte Rate(4 bytes)Block Align(2 bytes)Bits Per Sample(2 bytes)
"fmt " 16 1 2 44100 176400 4 16

 

Subchunk2 ID(4 bytes)Subchunk2 Size(4 bytes)Data(N bytes)
"data" NumSamples * NumChannels * BitsPerSample / 8 Actual Sound Data
复制代码
// uint32_t = unsigned int; 4 bytes
// uint16_t = unsigned short int; 2 bytes
    
struct wav_header_t
{
  char RIFF[4];  // RIFF Chunk ID(4 bytes)"RIFF"
  uint32_t chunk_size; // RIFF Chunk Size (4 bytes)
  char WAVEfmt[8]; // RIFF Format "WAVE" + fmt Chunk ID "fmt " (4 + 4 bytes)
  uint32_t fmt_chunk_size;
  uint16_t audiofmt;
  uint16_t channel;
  uint32_t sample_rate;
  uint32_t byte_per_sec;
  uint16_t block_size;
  uint16_t bit_per_sample;
};

struct sub_chunk_t
{
  char identifier[4];
  uint32_t chunk_size;
  
  // 数据大小未知,后续使用时需要动态分配内存
  uint8_t data[1]; // uint8_t = byte, 1 byte
};
复制代码

3 播放音频

复制代码
#include <SD.h>
#include <M5Unified.h>

// 使用三级缓冲形式,方式播放的同时向内存写入数据
// 缓冲区大小为1024 bytes
static constexpr const size_t buf_num = 3;
static constexpr const size_t buf_size = 1024;
static uint8_t wav_data[buf_num][buf_size];

// wav文件头
struct wav_header_t
{
  char RIFF[4];
  uint32_t chunk_size;
  char WAVEfmt[8];
  uint32_t fmt_chunk_size;
  uint16_t audiofmt;
  uint16_t channel;
  uint32_t sample_rate;
  uint32_t byte_per_sec;
  uint16_t block_size;
  uint16_t bit_per_sample;
};

// chunk结构
struct sub_chunk_t
{
  char identifier[4];
  uint32_t chunk_size;
  uint8_t data[1];
};


static bool playSdWav(const String filename)
{
  // 打开文件
  auto file = SD.open(filename);

  if (!file)
  {
    return false;
  }

  // 从文件起始位置开始读取
  // 读取大小为sizeof(wav_header_t)的数据至wav_header所在的地址
  wav_header_t wav_header;
  file.read((uint8_t *)&wav_header, sizeof(wav_header_t));

  // 将文件读取位置偏移(seek)至RIFF头与fmt头之后
  // 也就是data chunk开始的位置
  file.seek(offsetof(wav_header_t, audiofmt) + wav_header.fmt_chunk_size);

  // 读取data chunk的Chunk ID与Chunk Size
  sub_chunk_t sub_chunk;
  file.read((uint8_t *)&sub_chunk, 8);

  // 音频数据总大小
  int32_t data_len = sub_chunk.chunk_size;
  // 音频文件量化深度:bit_per_sample
  // 如果量化深度为16bit,bit_per_sample = 16 bit,对应的二进制就是0000 1111
  // >> 符号表示将二进制数向右移动n位,<< 表示向左移动;移动后补零
  // flg_16bit表示量化深度是否为16bit
  // 如果不是,则使用默认量化深度,默认bit_per_sample = 1 byte = 8 bit
  bool flg_16bit = (wav_header.bit_per_sample >> 4);

  size_t idx = 0;
  while (data_len > 0)
  {
    // 不停的将数据读取至缓冲区wav_data[idx]中
    // 默认每次读取buf_size的长度
    // 最后一次读取的长度可能小于缓冲区大小,所以只读取data__len长度
    size_t len = data_len < buf_size ? data_len : buf_size;
    len = file.read(wav_data[idx], len);
    data_len -= len;

    // 播放音频文件
    // playRaw(
    //    音频数据所在内存地址的指针
    //    音频数据长度 len >> 1 表示 len = len / 2, len << 1 表示 len = len * 2
    //    音频采样率
    //    是否为立体声
    //    播放次数
    //    虚拟声道数量
    //    是否停止当前播放
    // )
    if (flg_16bit)
    {
      M5.Speaker.playRaw((const int16_t *)wav_data[idx], len >> 1, wav_header.sample_rate, wav_header.channel > 1, 1, 0);
    }
    else
    {
      M5.Speaker.playRaw((const uint8_t *)wav_data[idx], len, wav_header.sample_rate, wav_header.channel > 1, 1, 0);
    }

    idx = idx < (buf_num - 1) ? idx + 1 : 0;
  }
  file.close();

  return true;
}
复制代码

4 小程序4:音频播放器

 4.1 功能分析

音频播放器是一种用于播放音频文件的软件或硬件设备。它的功能可以根据不同的用途和复杂程度有所不同,但以下是一些常见的功能分析:

  • 界面设计:简洁、美观、易于操作的用户界面。
  • 播放:控制音频的播放和暂停。
  • 播放进度条:显示和控制音频的播放进度。
  • 播放列表:管理和显示多个音频文件的列表。

4.2 伪代码

  • 开机后:
    • 绘制主界面,包括标题、播放列表等
  • 主循环
    • 检测是否有可播放的音频,如果有则继续下面的操作
    • 按键BtnA表示选择上一首音乐
    • 按键BtnC表示选择下一首音乐
    • 按键BtnB表示播放当前选择的音乐
  • 注意:由于播放音频、绘制进度条、检测按钮状态属于三个行为,但需要同时进行
  • 这就需要多线程技术的支持,ESP32默认支持多线程,只不过本章不涉及
  • 并且,一般情况下物联网单片机都以单任务为主,所以这里就不再赘述多线程相关内容
  • 在最后一章,会用多线程技术实现这部分需求

4.3 功能实现

复制代码
// wavehelper.h
#include <SD.h>
#include <M5Unified.h>

// 使用三级缓冲形式,方式播放的同时向内存写入数据
// 缓冲区大小为1024 bytes
static constexpr const size_t buf_num = 3;
static constexpr const size_t buf_size = 1024;
static uint8_t wav_data[buf_num][buf_size];

// wav文件头
struct wav_header_t
{
  char RIFF[4];
  uint32_t chunk_size;
  char WAVEfmt[8];
  uint32_t fmt_chunk_size;
  uint16_t audiofmt;
  uint16_t channel;
  uint32_t sample_rate;
  uint32_t byte_per_sec;
  uint16_t block_size;
  uint16_t bit_per_sample;
};

// chunk结构
struct sub_chunk_t
{
  char identifier[4];
  uint32_t chunk_size;
  uint8_t data[1];
};


static bool playSdWav(const String filename)
{
  // 打开文件
  auto file = SD.open(filename);

  if (!file)
  {
    return false;
  }

  // 从文件起始位置开始读取
  // 读取大小为sizeof(wav_header_t)的数据至wav_header所在的地址
  wav_header_t wav_header;
  file.read((uint8_t *)&wav_header, sizeof(wav_header_t));

  // 将文件读取位置偏移(seek)至RIFF头与fmt头之后
  // 也就是data chunk开始的位置
  file.seek(offsetof(wav_header_t, audiofmt) + wav_header.fmt_chunk_size);

  // 读取data chunk的Chunk ID与Chunk Size
  sub_chunk_t sub_chunk;
  file.read((uint8_t *)&sub_chunk, 8);

  // 音频数据总大小
  int32_t data_len = sub_chunk.chunk_size;
  // 音频文件量化深度:bit_per_sample
  // 如果量化深度为16bit,bit_per_sample = 16 bit,对应的二进制就是0000 1111
  // >> 符号表示将二进制数向右移动n位,<< 表示向左移动;移动后补零
  // flg_16bit表示量化深度是否为16bit
  // 如果不是,则使用默认量化深度,默认bit_per_sample = 1 byte = 8 bit
  bool flg_16bit = (wav_header.bit_per_sample >> 4);

  size_t idx = 0;
  while (data_len > 0)
  {
    // 不停的将数据读取至缓冲区wav_data[idx]中
    // 默认每次读取buf_size的长度
    // 最后一次读取的长度可能小于缓冲区大小,所以只读取data__len长度
    size_t len = data_len < buf_size ? data_len : buf_size;
    len = file.read(wav_data[idx], len);
    data_len -= len;

    // 播放音频文件
    // playRaw(
    //    音频数据所在内存地址的指针
    //    音频数据长度 len >> 1 表示 len = len / 2, len << 1 表示 len = len * 2
    //    音频采样率
    //    是否为立体声
    //    播放次数
    //    虚拟声道数量
    //    是否停止当前播放
    // )
    if (flg_16bit)
    {
      M5.Speaker.playRaw((const int16_t *)wav_data[idx], len >> 1, wav_header.sample_rate, wav_header.channel > 1, 1, 0);
    }
    else
    {
      M5.Speaker.playRaw((const uint8_t *)wav_data[idx], len, wav_header.sample_rate, wav_header.channel > 1, 1, 0);
    }

    idx = idx < (buf_num - 1) ? idx + 1 : 0;
  }
  file.close();

  return true;
}
复制代码
复制代码
// interface.hpp
#include <SD.h>
#include <M5Unified.h>

#include "wavhelper.hpp"

#define SelectionBoxStart 80
#define SelectionBoxHeight 30
String audioNames[100];
int audioCount = 0;
int currentIndex = 0;

void drawSelectionBox(int clearIndex, int drawIndex)
{
    if (clearIndex != -1)
    {
        M5.Display.setTextColor(WHITE);
        M5.Display.setTextSize(2);
        M5.Display.fillRect(0, SelectionBoxStart + SelectionBoxHeight * clearIndex, 320, SelectionBoxHeight, BLACK);
        M5.Display.drawCenterString(audioNames[clearIndex], 160, SelectionBoxStart + SelectionBoxHeight * clearIndex);
    }

    if (drawIndex != -1)
    {
        M5.Display.setTextColor(BLACK);
        M5.Display.setTextSize(2);
        M5.Display.fillRect(0, SelectionBoxStart + SelectionBoxHeight * drawIndex, 320, SelectionBoxHeight, WHITE);
        M5.Display.drawCenterString(audioNames[drawIndex], 160, SelectionBoxStart + SelectionBoxHeight * drawIndex);
    }
}

void drawWavList()
{
    auto files = SD.open("/AudioPlayer");
    if (!files)
        return;

    M5.Display.setTextSize(2);

    do
    {
        auto audio = files.openNextFile();
        if (!audio)
            break;

        String audioName = audio.name();
        if (audioName.endsWith(".wav"))
        {
            audioNames[audioCount] = audioName;
            M5.Display.drawCenterString(audioName, 160, SelectionBoxStart + SelectionBoxHeight * audioCount);
            audioCount += 1;
        }
        audio.close();
    } while (true);

    files.close();

    if (audioCount > 0)
    {
        currentIndex = 0;
        drawSelectionBox(-1, currentIndex);
    }
}

void drawTitle()
{
    M5.Display.setTextSize(4);
    M5.Display.setTextColor(WHITE);
    M5.Display.drawCenterString("Audio Player", 160, 20);
}

bool checkAudioCount()
{
    return audioCount > 0;
}

void checkPrewavButtonClicked()
{
    if (M5.BtnA.wasClicked())
    {
        if (currentIndex - 1 < 0)
        {
            currentIndex = audioCount - 1;
            drawSelectionBox(0, currentIndex);
        }
        else
        {
            currentIndex = currentIndex - 1;
            drawSelectionBox(currentIndex + 1, currentIndex);
        }
    }
}
void checkNextwavButtonClicked()
{
    if (M5.BtnC.wasClicked())
    {
        if (currentIndex + 1 >= audioCount)
        {
            currentIndex = 0;
            drawSelectionBox(audioCount - 1, currentIndex);
        }
        else
        {
            currentIndex = currentIndex + 1;
            drawSelectionBox(currentIndex - 1, currentIndex);
        }
    }
}
void checkPlaywavButtonClieck()
{
    if (M5.BtnB.wasClicked())
    {
        String path = "/AudioPlayer/" + audioNames[currentIndex];
        playSdWav(path);
    }
}
复制代码
复制代码
// main.cpp
#include <SD.h>
#include <M5Unified.h>

#include "interface.hpp"

void setup()
{
  auto cfg = M5.config();
  M5.begin(cfg);

  SD.begin(GPIO_NUM_4, SPI, 25000000);

  drawWavList();
  drawTitle();
}

void loop()
{
  if (checkAudioCount() > 0)
  {
    M5.delay(100);
    M5.update();

    checkPrewavButtonClicked();
    checkNextwavButtonClicked();
    checkPlaywavButtonClieck();
  }
}
复制代码

4.4 练习

为第三章的游戏加入音效支持,按下按钮的时候播放音效,lower、more、correct.wav

5 理论与术语总结

posted @   庞兴庆  阅读(41)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示