|
文礼:专栏文章目录
本篇开始我们探讨一下音频,以及音频的播放。
音频物理原理简单回顾
如同我们在中学物理当中所学,声音其实是机械振动,属于纵波。
当声音抵达我们的耳朵的时候,空气密度的变化会带动我们鼓膜进行振动,而这种振动经过听小骨,传达到神经。神经将其转变为生物电信号,传递给我们的大脑。
仿造这个原理,科学家发明了麦克风。麦克风当中填充有碳粉。当声音到达麦克风时,空气压力的变化导致麦克风当中的碳粉密度发生变化,从而改变麦克风器件的电阻。而这种电阻的变化在固定电压下导致电流的变化。
早期的留声机将这种电流的变化通过一些设备将其放大之后又转变回机械振动(横波),并将这种振动记录在唱片的轨道(称为音轨)上。当播放的时候把唱针放在唱片上,使其沿着音轨滑动还原其中记录的机械振动,并通过一系列电磁机构将其重新转换为电流的变化。将电流流过扬声器的磁芯,导致磁场变化,带动扬声器的膜振动,从而还原成声波。
但是唱针与盘面由于存在着物理接触,所以在播放过程当中相互会进行摩擦从而造成损毁。后来随着光记录技术的发展,出现了CD唱盘。CD唱盘对于声音的记录原理与唱片相同,但是由于采用激光进行读取,读取头与盘面没有物理(机械)接触,所以大大延长了寿命。此外,光记录的密度高,所以同等曲目数量的CD的尺寸远小于唱片。
但是由于整个记录和再生(回放)环节需要多次多种物理量的相互转换,这个过程当中就无可避免地会混入噪声,且存在着精度问题。转换设备越灵敏,则转换的精度越高,但是也越容易受到干扰引入噪声。
而数字电路技术的出现,使得我们可以大大缩短这个过程,在接近信号源的地方通过模数转换,直接对信号进行采样量化和编码,然后以数据的方式进行传输和存储。这样整个记录-还原过程的环节就大大减少。
而且数据更加容易被进行计算加工,就为我们给声音加上各种后期处理带来了方便。
PCM和WAV文件格式
上个世纪80年代,由飞利浦和索尼公司共同提出PCM方法,并将其运用到CD上。PCM方法就是按照一定的频率对信号进行测量,并将每次测量的值编码到一个8bit的2进制的字节上。而WAV文件是基于RIFF文件格式,保存PCM调制模式的声音信号的一种比较简单且通用的媒体文件格式。
RIFF文件格式包含一个RIFF文件头CHUNK和多个Payload CHUNK。每个CHUNK首先由4个ASCII码的字符表示其类型,接下来是一个32bit的整数表示其Payload的长度。WAV文件是一种RIFF文件,其RIFF Payload CHUNK的第一个CHUNK为WAV文件头。具体格式定义如下:
namespace My {
#pragma pack(push, 1)
struct WAVE_FILEHEADER {
uint8_t Magic[4];
uint32_t FileSize;
uint8_t FileTypeHeader[4];
};
struct WAVE_CHUNKHEADER {
uint8_t ChunkMarker[4];
uint32_t ChunkSize;
};
struct WAVE_FORMAT_CHUNKHEADER : WAVE_CHUNKHEADER {
uint16_t FormatType;
uint16_t ChannelNum;
uint32_t SampleRate;
uint32_t ByteRate;
uint16_t BlockSize;
uint16_t BitsPerSample;
};
struct WAVE_DATA_CHUNKHEADER : WAVE_CHUNKHEADER {};
#pragma pack(pop)
其中,RIFF CHUNK的Magic固定为‘RIFF’,当文件为WAV时,其FileTypeHeader为‘WAVE'。而WAV CHUNK头的Magic固定为’fmt ‘(末尾含一个空格),其中包含6个字段:
- FormatType:1表示PCM,其它数字则代表其他量化编码方式
- ChannelNum:音频的声道数。比如单声道为1,立体声为2
- SampleRate:采样频率。常见的有11kHz,44kHz等
- ByteRate:字节码率。可以通过(采样频率x声道数x采样位精度/8位每字节)计算得到
- BlockSize:一个样本的数据宽度。可以通过(声道数x采样位精度/8位每字节)计算得到
- BitsPerSample:采样位精度。也就是每次采样的值用几个比特表示
数据CHUNK头的Magic固定为‘data',其中的内容就是裸的PCM采样值。
生成音频
有了以上的准备知识,我们就可以用很简单的程序来生成实际可播放的WAV文件。方便起见,我们这里选择8bit 11kHz单声道因为作为生成对象。首先我们准备一个可以存放1s PCM数据的缓冲区,并填充上面定义的几个CHUNK头结构:
const unsigned int SAMPLE_RATE = 11025U; // 11KHz
uint8_t buf[SAMPLE_RATE];
My::WAVE_FILEHEADER file_header = {
{'R', 'I', 'F', 'F'},
0,
{'W', 'A', 'V', 'E'}
};
My::WAVE_FORMAT_CHUNKHEADER format_chunk_header = {
{{'f', 'm', 't', ' '},
sizeof(My::WAVE_FORMAT_CHUNKHEADER) - sizeof(My::WAVE_CHUNKHEADER)},
1, // PCM
1, // only 1 channel
SAMPLE_RATE, // 11kHz
1 * SAMPLE_RATE * 8 / 8, // byte rate
1 * 8 / 8, // block align
8 // bits per sample
};
My::WAVE_DATA_CHUNKHEADER data_chunk_header = {
{{'d', 'a', 't', 'a'},
0}
};
接下来我们用一个【0,255】区间(对应8bit量化范围)的随机数去填充这个1s的缓冲区,然后将其按照WAV文件的格式保存到一个名为noise.wav的文件当中去:
auto fp = std::fopen("noise.wav", "wb");
if (fp) {
// skip file header
std::fseek(fp, sizeof(file_header), SEEK_SET);
// write format chunk
std::fwrite(&format_chunk_header, sizeof(format_chunk_header), 1, fp);
// now generate the wave
std::default_random_engine generator;
std::uniform_int_distribution<int> distribution(0, 255);
for (int i = 0; i < SAMPLE_RATE; i++) {
buf = distribution(generator); // generates number in the range 0..255
}
data_chunk_header.ChunkSize = SAMPLE_RATE;
std::fwrite(&data_chunk_header, sizeof(data_chunk_header), 1, fp);
std::fwrite(buf, sizeof(uint8_t), SAMPLE_RATE, fp);
// Now fill the RIFF header with correct chunk size
file_header.FileSize = std::ftell(fp) - My::RIFF_HEADER_SIZE;
std::fseek(fp, 0, SEEK_SET);
// write file header
std::fwrite(&file_header, sizeof(file_header), 1, fp);
// Now close the wave file
std::fclose(fp);
}
这里面有一个小细节是,由于RIFF CHUNK头当中需要记录Payload的大小,所以在最开始我们用fseek跳过了这一个部分,直接记录Payload。等所有Payload记录完毕之后,我们再次用fseek跳回文件头部,将Payload的长度代入并写出RIFF头。

随机噪声
https://www.zhihu.com/video/1577788947068846080
接下来,我们使用数学库当中的正弦函数,来生成一个正弦波。
// Create the wave file and open with binary write mode
fp = std::fopen(&#34;sine.wav&#34;, &#34;wb&#34;);
if (fp) {
// skip file header
std::fseek(fp, sizeof(file_header), SEEK_SET);
// write format chunk
std::fwrite(&format_chunk_header, sizeof(format_chunk_header), 1, fp);
// now generate the wave
for (int i = 0; i < SAMPLE_RATE; i++) {
buf = 0xFF * std::sin(261.6f * 2.0f * M_PI * i / SAMPLE_RATE);
}
data_chunk_header.ChunkSize = SAMPLE_RATE;
std::fwrite(&data_chunk_header, sizeof(data_chunk_header), 1, fp);
std::fwrite(buf, sizeof(uint8_t), SAMPLE_RATE, fp);
// Now fill the RIFF header with correct chunk size
file_header.FileSize = std::ftell(fp) - My::RIFF_HEADER_SIZE;
std::fseek(fp, 0, SEEK_SET);
// write file header
std::fwrite(&file_header, sizeof(file_header), 1, fp);
// Now close the wave file
std::fclose(fp);
}
其中的261.6f代表了我们生成的正弦波波形的频率。这里我选择的是中央C的频率。

固定频率的正弦波
https://www.zhihu.com/video/1577790159222710272
最后,让我们生成一个c3的八度音阶:
// Create the wave file and open with binary write mode
fp = std::fopen(&#34;octave.wav&#34;, &#34;wb&#34;);
if (fp) {
// skip file header
std::fseek(fp, sizeof(file_header), SEEK_SET);
// write format chunk
std::fwrite(&format_chunk_header, sizeof(format_chunk_header), 1, fp);
data_chunk_header.ChunkSize = SAMPLE_RATE;
// now generate the wave
const float oct_frequency[] = {261.6f, 293.6f, 329.6f, 349.2f, 392.0f, 440.0f, 493.8f, 523.2f};
for (int oct_index = 0; oct_index < 8; oct_index++) {
for (int i = 0; i < SAMPLE_RATE; i++) {
buf = 0xFF * std::sin(oct_frequency[oct_index] * 2.0f * M_PI * i / SAMPLE_RATE);
}
std::fwrite(&data_chunk_header, sizeof(data_chunk_header), 1, fp);
std::fwrite(buf, sizeof(uint8_t), SAMPLE_RATE, fp);
}
// Now fill the RIFF header with correct chunk size
file_header.FileSize = std::ftell(fp) - My::RIFF_HEADER_SIZE;
std::fseek(fp, 0, SEEK_SET);
// write file header
std::fwrite(&file_header, sizeof(file_header), 1, fp);
// Now close the wave file
std::fclose(fp);
}
主要就是在生成正弦波的部分的外侧添加了一个外循环,每次读取8个音阶当中的对应频率作为正弦波的频率。

生成八度音阶
https://www.zhihu.com/video/1577791754333478912
本篇代码
Release article_97 · netwarm007/GameEngineFromScratch |
|