实战:音频波形生成(WAV格式+正弦波合成)

音频处理,听起来挺高大上对吧?其实说白了,就是跟一堆数字打交道。你想想看,计算机里哪有什么声音,只有0和1。我们今天要做的,就是把这些数字变成耳朵能听懂的波形。

我个人习惯把这类问题拆成三步:搞懂格式 → 生成数据 → 写入文件。WAV格式虽然老,但胜在简单,特别适合用来理解音频的底层原理。

WAV文件结构速览

WAV文件本质上就是一个带头的二进制块。我刚开始学的时候,总觉得这玩意儿很神秘,后来发现其实就是几个固定字段拼在一起。

偏移量 长度(字节) 内容 说明
0 4 "RIFF" 文件标识
4 4 文件大小-8 整个文件减去前8字节
8 4 "WAVE" 格式标识
12 4 "fmt " 子块标识(注意有空格)
16 4 16 子块1大小(PCM格式固定16)
20 2 1 音频格式(1=PCM)
22 2 声道数 1=单声道,2=立体声
24 4 采样率 如44100
28 4 字节率 采样率×声道数×位深/8
32 2 块对齐 声道数×位深/8
34 2 位深 如16
36 4 "data" 数据子块标识
40 4 数据大小 采样数×声道数×位深/8
44 ... 音频数据 原始采样值

嗯,这里要注意:"fmt "后面那个空格不是笔误,WAV规范就是这么写的。我曾经因为这个空格没写对,折腾了整整一个下午,最后发现是字符串长度问题。

核心要点:WAV头总共44字节,之后紧跟着原始PCM数据。只要把这44个字节填对,剩下的就是纯粹的数学计算。

正弦波合成的数学原理

正弦波公式很简单:y(t) = A * sin(2π * f * t)。其中A是振幅,f是频率,t是时间。

但在数字世界里,时间t不是连续的,而是离散的采样点。假设采样率是44100Hz,那么每秒钟就有44100个点。第n个采样点对应的时间就是 t = n / 采样率

所以代码里实际用的是:sample[n] = A * sin(2π * f * n / 采样率)

小技巧:如果你想要生成多个频率叠加的波形,直接把多个正弦波的值加起来就行。这就是所谓的「傅里叶合成」——任何复杂波形都可以拆成多个正弦波的叠加。

代码实现:从零写出WAV文件

下面这段代码,我建议你一行一行看。它完整地演示了如何生成一个440Hz的正弦波,并保存为16位单声道WAV文件。

#include <iostream>
#include <fstream>
#include <vector>
#include <cmath>
#include <cstdint>

// WAV文件头结构
struct WAVHeader {
    char     riff[4] = {'R', 'I', 'F', 'F'};
    uint32_t fileSize;
    char     wave[4] = {'W', 'A', 'V', 'E'};
    char     fmt[4]  = {'f', 'm', 't', ' '};
    uint32_t fmtSize = 16;
    uint16_t audioFormat = 1;   // PCM
    uint16_t numChannels = 1;   // 单声道
    uint32_t sampleRate = 44100;
    uint32_t byteRate;
    uint16_t blockAlign;
    uint16_t bitsPerSample = 16;
    char     data[4] = {'d', 'a', 't', 'a'};
    uint32_t dataSize;
};

void generateSineWave(const std::string& filename,
                      double frequency,
                      double duration,
                      uint32_t sampleRate = 44100,
                      uint16_t bitsPerSample = 16) {
    // 计算总采样数
    uint32_t numSamples = static_cast<uint32_t>(sampleRate * duration);
    uint32_t dataSize = numSamples * (bitsPerSample / 8);

    // 准备WAV头
    WAVHeader header;
    header.byteRate = sampleRate * header.numChannels * (bitsPerSample / 8);
    header.blockAlign = header.numChannels * (bitsPerSample / 8);
    header.dataSize = dataSize;
    header.fileSize = 36 + dataSize;  // 整个文件减去前8字节

    // 生成采样数据
    std::vector<int16_t> samples(numSamples);
    double amplitude = 30000;  // 避免削波,留点余量
    double twoPiF = 2.0 * M_PI * frequency;

    for (uint32_t i = 0; i < numSamples; ++i) {
        double t = static_cast<double>(i) / sampleRate;
        samples[i] = static_cast<int16_t>(amplitude * sin(twoPiF * t));
    }

    // 写入文件
    std::ofstream outFile(filename, std::ios::binary);
    if (!outFile) {
        std::cerr << "无法打开文件: " << filename << std::endl;
        return;
    }

    outFile.write(reinterpret_cast<const char*>(&header), sizeof(header));
    outFile.write(reinterpret_cast<const char*>(samples.data()), dataSize);
    outFile.close();

    std::cout << "生成成功: " << filename << std::endl;
    std::cout << "采样数: " << numSamples << std::endl;
    std::cout << "时长: " << duration << " 秒" << std::endl;
}

int main() {
    // 生成440Hz标准音,持续2秒
    generateSineWave("output.wav", 440.0, 2.0);
    return 0;
}
注意:振幅不要设成32767(16位有符号整数的最大值)。我曾经图省事直接用了最大值,结果播放时出现了明显的「削波失真」——高频部分全被削平了,听起来像破锣。留10%左右的余量比较安全。

多频率叠加:生成更丰富的音色

单一正弦波听起来很单调,就像老式电话的拨号音。实际乐器发出的声音,都是基频加上一系列泛音。我们可以把多个频率的正弦波叠加起来,模拟出更复杂的音色。

// 生成带泛音的正弦波
std::vector<int16_t> generateHarmonic(double frequency,
                                       uint32_t numSamples,
                                       uint32_t sampleRate) {
    std::vector<int16_t> samples(numSamples, 0);
    double baseAmplitude = 20000;

    // 基频 + 前4个泛音
    double harmonics[] = {1.0, 2.0, 3.0, 4.0, 5.0};
    double amplitudes[] = {1.0, 0.5, 0.3, 0.2, 0.1};

    for (int h = 0; h < 5; ++h) {
        double freq = frequency * harmonics[h];
        double amp = baseAmplitude * amplitudes[h];
        double twoPiF = 2.0 * M_PI * freq;

        for (uint32_t i = 0; i < numSamples; ++i) {
            double t = static_cast<double>(i) / sampleRate;
            samples[i] += static_cast<int16_t>(amp * sin(twoPiF * t));
        }
    }

    return samples;
}

你看,代码其实不复杂。核心就是循环里做加法。但要注意:多个波形叠加后,振幅可能会超过32767。我建议你先算一下理论最大值,或者生成后做一次归一化处理。

知识体系与核心逻辑

下面这张图,帮你理清整个音频波形生成的核心脉络:

音频波形生成核心逻辑 输入参数 频率 / 振幅 / 时长 采样参数 采样率 / 位深 / 声道 输出格式 WAV / PCM / 其他 核心处理:正弦波合成 y[n] = A · sin(2π · f · n / sampleRate) 输出:WAV文件 44字节头 + PCM采样数据 图:从输入参数到WAV文件的完整流程

避坑指南与经验之谈

做这个项目,有几个坑我踩过,你注意一下:

  • 字节序问题:WAV格式使用小端字节序。如果你在ARM或PowerPC上跑,记得检查字节序。我曾在嵌入式设备上吃过这个亏,生成的WAV文件在PC上播放全是噪音。
  • 文件大小计算:WAV头里的fileSize字段是整个文件大小减去8,不是整个文件大小。这个细节很容易搞错。
  • 采样率选择:44100Hz是CD音质,22050Hz是电话音质。如果你只是测试,用8000Hz就够了,文件小很多。
  • 内存管理:生成长时间音频时,采样数据可能很大。比如44.1kHz、16位立体声,1分钟就是10MB左右。建议分段写入,不要一次性全放内存里。
调试小技巧:生成一个1秒的440Hz正弦波,用Audacity打开看看。如果波形是完美的正弦曲线,说明你的代码没问题。如果波形有毛刺或不对称,检查一下数据类型转换和振幅计算。

说实话,音频编程入门并不难。你只要搞懂了WAV头的结构,再加上正弦波公式,就能生成任何你想要的声音。从简单的单音,到复杂的和弦,再到完整的音乐片段——这条路是通的。

我建议你从440Hz开始,这是标准音A4。然后试试523Hz(C5)、659Hz(E5),把它们叠加起来就是一个大三和弦。听着自己代码生成的声音,那种成就感,嗯,挺特别的。


公众号:蓝海资料掘金营,微信deep3321