读写wav格式文件
读写wav格式文件
本文所有相关代码(包括未来的)均可在该代码库找到
https://gitcode.net/PeaZomboss/learnaudios
本文代码在MinGW-w64 gcc/g++和MSVC(vs2022)环境下编译测试通过。
MinGW gcc/g++可以在以下链接下载:
https://github.com/niXman/mingw-builds-binaries/releases
https://sourceforge.net/projects/mingw-w64/files/
https://github.com/niXman/mingw-builds-binaries/releases/tag/8.5.0-rt_v10-rev0
以上链接中:
第一个版本较新(gcc 12.x),没有历史遗留问题的话选这个最好
第二个版本(8.1.0的)较老(现在不推荐)
第三个是第二个的最新修订版(8.5.0),也是gcc 8.x系列的最终版,再用8.1.0的话建议更新。
上次讲到了wav格式的组织存储方式,现在我们根据其格式进行wav文件的读写操作。
在此之前先将上篇文章部分关于wav格式的内容整理成头文件在这里贴出:
types.h
#pragma once typedef char Int8; typedef short Int16; typedef long Int32; typedef long long Int64; typedef unsigned char UInt8; typedef unsigned short UInt16; typedef unsigned long UInt32; typedef unsigned long long UInt64; typedef UInt8 Byte; typedef UInt16 Word; typedef UInt32 DWord; typedef UInt64 QWord; typedef struct { DWord D1; Word D2; Word D3; Byte D4[8]; } Guid; typedef union { DWord dw; char chr[4]; } FourCC;
wavfmt.h
#pragma once #include "types.h" typedef struct { FourCC id; // 区块类型 DWord size; // 区块大小(不包括id和size字段的大小) } RIFFChunkHeader; typedef struct { FourCC id; // 必须是 "RIFF" DWord size; // 文件大小(字节数)-8 FourCC type; // 必须是 "WAVE" } RIFFHeader; /* 下面这些格式字段的具体含义上篇文章都有说明 */ typedef struct { Word FormatTag; Word Channels; DWord SampleRate; DWord BytesRate; Word BlockAlign; Word BitsPerSample; } WaveFormat; typedef struct { Word FormatTag; Word Channels; DWord SampleRate; DWord BytesRate; Word BlockAlign; Word BitsPerSample; Word ExSize; } WaveFormatEx; typedef struct { Word FormatTag; Word Channels; DWord SampleRate; DWord BytesRate; Word BlockAlign; Word BitsPerSample; Word ExSize; Word ValidBitsPerSample; DWord ChannelMask; Guid SubFormat; } WaveFormatExtensible;
读取wav文件相对麻烦一些,我们先从写入开始吧。
写一个wav文件
一般我们需要写入wav文件的情况就是将PCM数据封装起来,所以我们需要一段原始PCM数据。获得PCM数据的方法有很多,比如可以用麦克风录制一段声音,但是这个要留到后面讲DirectSound的时候,所以这一次,我们自己创建一段PCM数据,并把它写入到文件,用现有的播放器来播放试听效果。
创建一段PCM数据
众所周知,声音是物体震动发出的,记录声音的方式就是把振幅值随时间的变化曲线记录下来,但是由于计算机是以离散的方式存储数据的,所以我们需要每过一定的时间间隔就记录一次振幅并量化,这样存储下来的数据就是PCM数据。这个PCM数据是没有任何信息的,你用不同的速度播放效果是不一样的,所以我们需要同时拥有采样率、量化位数等信息才能正确播放,而wav格式就存储了这些必要数据。
用来生成波形的设备叫振荡器(oscillator),当其生成的频率在20HZ-20kHZ范围内就可以让扬声器播放出声音(能不能听见接近两端频率的声音取决于多种因素),由于奈奎斯特采样定理,这个采样率至少为该频率的两倍。
因为声音记录下来的数据是波形,所以这里我们用sin函数生成一段正弦波数据,作为我们的PCM数据。由于数据存储的方式,为了生成这个数据,我们需要同时设置采样率和频率。
本文我们将以44100HZ的采样率生成一段10秒钟的1000HZ的正弦波,单声道,量化位数16位。
typedef struct { double increase; // 相位步进 double phase; // 当前相位 double gain; // 增益 } oscillator; void init_osc(oscillator *osc, int sample_rate, int frequency, double gain) { osc->increase = TWOPI * frequency / sample_rate; osc->gain = gain; osc->phase = 0; } // 获取下一个采样点 double osc_next(oscillator *osc) { double sample = sin(osc->phase) * osc->gain; osc->phase += osc->increase; if (osc->phase > TWOPI) osc->phase -= TWOPI; return sample; }
这段代码实现了一个简单的正弦波振荡器。
解释一下,其原理是这个公式:
是时刻,是振幅,是频率,但是由于在计算机这里时间不能是连续的,所以我们就把公式改成:
用来表示当前采样点,表示采样率,就是时间,这样上下两个公式就对的上了,而第二个公式是离散的。
那么对应一下代码,就是增益,就是相位步进了,因为是每次+1的,而由于浮点数是存在误差的,加上函数的周期是,所以用"if (osc->phase > TWOPI) osc->phase -= TWOPI;
"这段代码来使相位始终保持在,也就是画圈圈。
生成PCM采样的方法如下:
oscillator osc; init_osc(&osc, 44100, 1000, 0.25); short *buffer = (short *)malloc(441000 * sizeof(short)); for (int i = 0; i < 441000; i++) buffer[i] = 32767 * osc_next(&osc); // ... free(buffer);
这里init_osc
第四个参数gain设为0.25(等效于约-12dB)是为了播放的时候声音不要太大,不然1kHZ正弦波的声音还是很刺耳难听的。
写入到文件
写入文件的方法就简单了,依次按照RIFF文件头,fmt块,data块的顺序写入文件即可。
完整代码如下,使用gcc直接编译即可,无需链接任何库。
#include "wavfmt.h" #include <math.h> #include <stdlib.h> #include <stdio.h> #include <string.h> #define TWOPI (2*3.1415926535897932) typedef struct { double increase; double phase; double gain; } oscillator; void init_osc(oscillator *osc, int sample_rate, int frequency, double gain) { osc->increase = TWOPI * frequency / sample_rate; osc->gain = gain; osc->phase = 0; } double osc_next(oscillator *osc) { double sample = sin(osc->phase) * osc->gain; osc->phase += osc->increase; if (osc->phase > TWOPI) osc->phase -= TWOPI; return sample; } #define BUFFER_LENGTH 441000 int main() { // RIFF header RIFFHeader riff; strncpy((char *)&riff.id, "RIFF", 4); riff.size = 4 + sizeof(RIFFChunkHeader) * 2 + sizeof(WaveFormat) + BUFFER_LENGTH * sizeof(short); // 计算实际大小 strncpy((char *)&riff.type, "WAVE", 4); // Format header RIFFChunkHeader fmt_header; strncpy((char *)&fmt_header.id, "fmt ", 4); fmt_header.size = sizeof(WaveFormat); // Format WaveFormat fmt; fmt.FormatTag = 1; fmt.Channels = 1; fmt.BitsPerSample = 16; fmt.SampleRate = 44100; fmt.BlockAlign = 2; fmt.BytesRate = 44100 * 2; // Data header RIFFChunkHeader data_header; strncpy((char *)&data_header.id, "data", 4); data_header.size = BUFFER_LENGTH * sizeof(short); // Generate PCM oscillator osc; init_osc(&osc, 44100, 1000, 0.25); short *buffer = (short *)malloc(BUFFER_LENGTH * sizeof(short)); for (int i = 0; i < BUFFER_LENGTH; i++) buffer[i] = 32767 * osc_next(&osc); // Write to file FILE *f = fopen("sin_1khz.wav", "wb"); fwrite(&riff, sizeof(RIFFHeader), 1, f); // 写入RIFF头 fwrite(&fmt_header, sizeof(RIFFChunkHeader), 1, f); // 写入fmt头 fwrite(&fmt, sizeof(WaveFormat), 1, f); // 写入fmt内容 fwrite(&data_header, sizeof(RIFFChunkHeader), 1, f); // 写入data头 fwrite(buffer, 2, BUFFER_LENGTH, f); // 写入实际数据 free(buffer); fclose(f); }
运行程序后会在当前工作目录下生成一个"sin_1khz.wav"的文件,用播放器播放就可以听到嘟~~~的声音了。
读取wav文件
实际上读取wav文件也不难,只要按照区块的标准一个个查找就行了,一般fmt块就是第一个块,而data块则有可能夹在中间,所以我们需要循环读取区块,找出fmt和data这两个块就可以了。
当然这样只适合读取标准PCM编码或者IEEE浮点格式的wav文件,对于其他格式的文件并不支持(需要例如fact块),但是一般这样就足够了。
对于这个过程,我们只关心以下几点就可以了
- fmt块的内容
- 数据在文件中的位置
- 数据的大小
为了方便编码以及使用,读取wav文件的代码使用c++实现
也没什么很复杂的,稍微注意一点细节即可,这个前文其实提到过。
wavread.h
#pragma once #include <stdio.h> #include <string.h> #include "wavfmt.h" class WaveReader { private: FILE *f; WaveFormatExtensible fmtext; Int64 data_pos; // 实际音频数据在文件中的位置 Int64 data_size; // 文件中音频数据的大小 Int64 read_size; // 当前已经读取的音频数据大小 bool find_fmt(); // 用于查找文件中的"fmt "块 bool find_data(); // 用于查找文件中的"data"块 public: WaveReader(); ~WaveReader(); bool open_file(char *filename); // 打开文件 void close_file(); // 关闭文件 const WaveFormatExtensible &get_fmtext(); // 返回fmtext的引用 // 把size个字节的音频数据读到buffer缓冲区 // 返回实际读取的字节数,如果已经读取完了返回-1 int read_data(void *buffer, DWord size); void reset(); // 重置读取指针,即重新从data_pos的位置读取 };
wavread.cpp
#include "wavread.h" bool WaveReader::find_fmt() { RIFFChunkHeader hd; long size = 0; // 读取一次Header实际读到的大小,用来判断是否读取完毕 do { size = fread(&hd, 1, sizeof(RIFFChunkHeader), f); if (hd.size % 2 == 1) // 如果块大小为奇数则需要对齐 hd.size++; // 判断当前块的ID是不是"fmt " if (strncmp((char *)&hd.id, "fmt ", 4) != 0) fseek(f, hd.size, SEEK_CUR); // 不是直接跳过这个块 else break; } while (size >= 8); if (size < 8) // 实际读取不足8字节一般说明到了文件末尾 return false; // 假设文件的format块小于等于sizeof(WaveFormatExtensible) // 因为有极少数的格式是有自己的标准的,其尺寸大于微软的WaveFormatExtensible fread(&fmtext, 1, hd.size, f); // 判断文件格式是否是PCM或者IEEE编码,也就是FormatTag是1或者3 // 否则是编码过的格式,需要解码,我们不支持这种格式 if (fmtext.FormatTag == 0xFFFE) { if (fmtext.SubFormat.D1 != 1 && fmtext.SubFormat.D1 != 3) return false; } else if (fmtext.FormatTag != 1 && fmtext.FormatTag != 3) { return false; } return true; } bool WaveReader::find_data() { RIFFChunkHeader hd; long size = 0; // 同上 do { size = fread(&hd, 1, sizeof(RIFFChunkHeader), f); if (hd.size % 2 == 1) // 查找data块过程中这个更加重要 hd.size++; // 因为如果是8bit或者24bit单声道的文件可能不对齐2字节 if (strncmp((char *)&hd.id, "data", 4) != 0) // 同上 fseek(f, hd.size, SEEK_CUR); else break; } while (size >= 8); if (size < 8) return false; fgetpos(f, &data_pos); // 获取实际数据的位置 data_size = hd.size; // 该块的大小即为数据的大小 return true; } WaveReader::WaveReader() { memset(&fmtext, 0, sizeof(WaveFormatExtensible)); f = NULL; read_size = 0; } WaveReader::~WaveReader() { if (f) fclose(f); } bool WaveReader::open_file(char *filename) { f = fopen(filename, "rb"); if (f) { RIFFHeader riff; fread(&riff, 1, sizeof(RIFFHeader), f); // 读取文件的RIFF头 if (strncmp((char *)&riff.id, "RIFF", 4) != 0) // 判断是不是RIFF文件 return false; if (strncmp((char *)&riff.type, "WAVE", 4) != 0) // 判断是不是WAVE文件 return false; // 按照规范,fmt块是第一个块 if (!find_fmt()) // 先找fmt块 return false; if (!find_data()) // 再找data块 return false; return true; } return false; } void WaveReader::close_file() { // 清理和初始化必要内容 if (f) fclose(f); memset(&fmtext, 0, sizeof(WaveFormatExtensible)); f = NULL; data_pos = 0; data_size = 0; read_size = 0; } const WaveFormatExtensible &WaveReader::get_fmtext() { return fmtext; } int WaveReader::read_data(void *buffer, DWord size) { if (read_size >= data_size) { // 已经读完所有数据了 memset(buffer, 0, size); // 缓冲区置0 return -1; } int result; // 已经读取的加上要读取的小于实际的数据大小,说明还没到末尾 if (read_size + size < data_size) { result = fread(buffer, 1, size, f); read_size += result; // 累加实际读取的 } // 否则说明还没有读取的字节数小于需要读取的字节数,即将读完 else { memset(buffer, 0, size); // 全部清0处理 result = fread(buffer, 1, data_size - read_size, f); // 读取剩下的 read_size = data_size; } return result; } void WaveReader::reset() { read_size = 0; fseek(f, data_pos, SEEK_SET); // 回到数据开始的地方 }
这段代码可以读取大部分的PCM和IEEE格式的wav文件,只需调用open_file()打开文件,read_data()读取数据,close_file()关闭文件,其他各种见具体示例代码。
不过目前只能读取数据,还没有实现播放,播放API有好多,比较老的比如waveXxx系列,DirectSound,最新的是WASAPI。
目前已经更新了用DirectSound和WASAPI播放声音的教程。
补充内容
关于代码段
short *buffer = malloc(BUFFER_LENGTH * sizeof(short));
经过测试发现使用纯C语言编译器如gcc是完全可以的,因为C语言标准支持这种用法,但是C++标准不支持这种用法,需要使用类型转换,即
short *buffer = (short *)malloc(BUFFER_LENGTH * sizeof(short));
不过呢,对C++来说,用new更好一点,原来的代码是用C的,不过为了与后来的内容一致,现在代码库已经全部改成C++了,所以这一段代码也就改成了
short *buffer = new short[BUFFER_LENGTH];
这样应该是最合理的了。
更新记录
- 2023-01-19:新增gcc 8.5.0链接,新增振荡器原理介绍,添加更多代码注释。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】