前段时间在编程群里看到有人甩了个标题「疯狂的往左」用 C 语言播放《下山》。一看名字就知道这是典型的整活向练手题——不用图形界面不用现成的音乐库纯粹拿 C 语言的朴素能力让电脑把这首国风歌“唱”出来。我第一次见这种题也纳闷C 语言跟播放音乐能扯上什么关系扒了一圈实现之后发现这事儿真没你想的那么玄乎跟声卡驱动、音频解码器基本无关核心就是两个数字频率和时长。频率决定音高时长决定节拍。循环调用系统接口让扬声器按你给定的节奏振动一段旋律就这么出来了。《下山》本身旋律朗朗上口、音域不算宽非常适合拿来当这种实验的素材。这篇文章就把我实际操作的全过程拆开讲清楚从乐谱怎么变成数字数组到 Windows 下的 Beep 播放再到更通用的 WAV 文件生成方案以及我踩过的几个坑。适合刚学完 C 语言基础、想做点有意思练手项目的同学也适合想搞明白“程序是怎么让电脑发声”的业余爱好者。1. 先搞清楚C 语言怎么“发声”1.1 扬声器与 Beep 背后的原理电脑发声这件事说穿了就是让音频设备按照一定的频率振动。人耳听到的“音高”本质上就是振动频率频率越高听感越尖频率越低听感越沉。比如国际标准音 A4 是 440Hz中央 C也就是简谱里的中音 1是 261.6Hz高一个八度就翻倍变成 523.25Hz。Windows 系统提供了一个非常直白的 API 叫Beep声明在windows.h里BOOL Beep(DWORD dwFreq, DWORD dwDuration);第一个参数是频率单位 Hz第二个参数是持续时间单位毫秒。你调用Beep(440, 500)电脑就会发出一个 440Hz 的“嘟”声持续半秒。就这么简单。那我为什么不调用一次 Beep 直接放完整首歌因为系统只给了你“发单个音”的能力没有“解码 MP3”的能力。歌曲本质上是把一连串带有特定时长、特定音高的音按顺序排列起来。所以我们的思路就变成了把《下山》的旋律拆成一个一个音符每个音符记录下频率和时长然后用循环逐个播放。这就是整道题最核心的工程思路。1.2 简谱到频率的换算十二平均律旋律怎么变成数字在简谱体系里用 1 到 7 表示 do、re、mi、fa、sol、la、si。数字上面加一个点表示高八度下面加一个点表示低八度。要转成频率直接背频率表最省事但理解换算逻辑更有用。现代音乐通用的调音标准是十二平均律相邻两个半音之间的频率比是 2 的 1/12 次方约等于 1.0595。标准音 A4 是 440Hz从它往上数 n 个半音频率就是double to_freq(int midi_note) { return 440.0 * pow(2.0, (midi_note - 69) / 12.0); }这里的 midi_note 是 MIDI 标准里的音高编号A4 对应 69。中央 CC4简谱中音 1对应 60代入公式算出来是 261.63Hz。一个八度正好 12 个半音所以高音 1C5就是 261.63 的两倍523.25Hz。我实际写代码时不会每次都现算直接做一张表用的时候查。C 调下《下山》主旋律用到的主要音符频率大致如下简谱含义频率(Hz)低音5G3196.00低音6A3220.001C4 do261.632D4 re293.663E4 mi329.635G4 sol392.006A4 la440.001C5 高音 do523.252D5 高音 re587.333E5 高音 mi659.26看出来没《下山》这种国风歌曲大量使用五声音阶也就是 1、2、3、5、6 这几个音4 和 7 基本当装饰音甚至完全不出现。所以我上面这张表里故意没列 4 和 7 的常用项实际听写时旋律也确实集中在这些音上这算是我听写时的一个预判。2. 数据结构与播放引擎把谱子喂给电脑2.1 Note 结构体频率加节拍拿到频率表之后下一步就是设计“一行谱子”在代码里的表示。我的做法非常直接定义一个结构体typedef struct { int freq; // 频率0 表示休止符 double b; // 节拍数1 表示一个四分音符 } Note;freq存音高b存这个音持续几拍。为什么要用“拍数”而不是直接写死毫秒因为拍数跟速度是解耦的。同样的谱子BPM 是 120 还是 160播放起来节奏完全不同但你写谱子的时候不需要关心具体毫秒值。BPMBeats Per Minute是每分钟多少拍换算关系是一拍毫秒数 60000 / BPM比如 BPM 150一拍就是 400ms半拍就是 200ms两拍就是 800ms。b字段存 0.5、1、2 这种相对值程序里统一换算既方便调整速度又贴近正常人读谱的习惯。2.2 播放循环Beep 加 Sleep有了音符数组播放引擎就是个简单的 for 循环#include windows.h #include stdio.h #define BPM 150 #define BT (60000.0 / BPM) typedef struct { int freq; double b; } Note; void play(Note *song, int n) { for (int i 0; i n; i) { int dur (int)(song[i].b * BT); if (song[i].freq 0) { Sleep(dur); // 休止符安静等完这段时间 } else { int gap 24; // 音符之间的呼吸间隔 Beep(song[i].freq, dur - gap); Sleep(gap); } } }这里有个特别容易忽略的细节音符之间要留一点空隙。如果不留连续两个八分音符听起来会黏成一个长音旋律完全没有颗粒感。我习惯固定留 24 毫秒的间隔相当于每个音提前结束一小截再把这一小截补在 Beep 完成后。你可以在 10 到 40 毫秒之间试短音符多的时候把间隔调小些长音符多的时候调大些。Beep本身是阻塞调用也就是说它没响完不会返回所以整个播放过程就是串行的一个音接一个音不需要额外开线程。如果旋律较长程序播放期间看着像“卡住”了一样这是正常的它真的在忙。3. 《下山》主旋律听写与完整代码3.1 听写思路从哼歌到数字数组拿到一首歌怎么变成数组我的流程是先反复听主旋律用手机或者脑子记住“摇头晃脑”的节奏然后边哼边在纸上写简谱。这活儿没有太高门槛但有技巧先抓节奏骨架再定音高。以《下山》为例它是古风戏腔味道的歌主旋律华丽但不难抓。我先听出来一个大概走向起句在低音区游走中间逐渐扬到高音结尾回落收住。然后盯住每一句的第一个音和最后一个音这两个音通常是句子的“锚点”中间的音顺着嗓音走向往上填就行。这里必须说明不同版本、不同编曲下歌曲音高和细节出入很大我这份谱子是“听感还原版”属于简化编配不是逐音符扒原版伴奏的绝对准确谱。做这种 C 语言放歌的项目核心目标从来不是 100% 还原唱片而是把主旋律听出来、转成数组、让程序跑通。所以下面我既给一份能直接抄的数组也把完整方法讲清楚怎么标记节奏、怎么填频率、怎么调整移调。3.2 我记下来的《下山》主旋律片段按 C 调记谱我扒出来的主旋律片段是这样的用竖线分隔小节数字上标一撇表示高音下划线后的数字表示时值拉长第一句5 6 1 6 | 5 - 3 - | 2 3 5 3 | 2 - 1 - 第二句5 6 1 2 | 1 - 6 - | 5 6 1 6 | 5 - 3 - 收尾句2 3 2 1 | 低音6 - 低音5 - | 1 - - -其中每个单独数字是八分音符后面跟一个横杠的表示四分音符末尾的“1 - - -”是两拍长音。整个片段正好五个小节加一个结束长音循环起来相当顺耳。写成 Note 数组就是Note song[] { // 第1小节5 6 1 6 {392, 0.5}, {440, 0.5}, {523, 0.5}, {440, 0.5}, // 第2小节5 - 3 - {392, 1.0}, {330, 1.0}, // 第3小节2 3 5 3 {294, 0.5}, {330, 0.5}, {392, 0.5}, {330, 0.5}, // 第4小节2 - 1 - {294, 1.0}, {262, 1.0}, // 第5小节5 6 1 2 {392, 0.5}, {440, 0.5}, {523, 0.5}, {587, 0.5}, // 第6小节1 - 6 - {523, 1.0}, {440, 1.0}, // 第7小节5 6 1 6 {392, 0.5}, {440, 0.5}, {523, 0.5}, {440, 0.5}, // 第8小节5 - 3 - {392, 1.0}, {330, 1.0}, // 第9小节2 3 2 1 {294, 0.5}, {330, 0.5}, {294, 0.5}, {262, 0.5}, // 第10小节低音6 - 低音5 - {220, 1.0}, {196, 1.0}, // 结束1 - - - {262, 2.0}, {0, 2.0}, };你可能会问为什么第 10 小节突然用低音 6 和低音 5因为我听的那版在收尾时有一个明显的下坠感从 1 开始往下滑到低音区再回到主音 1 结束。这是很典型的国风收尾手法落在低音上反而更稳。你要是觉得原版不是这个走向完全可以直接改末尾两三个音的频率数组这不影响整个工程的骨架。3.3 完整可运行的 Beep 播放代码把上面所有东西拼起来就是一个完整程序。Windows 下用 gcc 或者 Visual Studio 的 cl 编译都能过#include windows.h #include stdio.h #define BPM 150 #define BT (60000.0 / BPM) typedef struct { int freq; double b; } Note; Note song[] { // 主旋律片段见 3.2 节说明 {392, 0.5}, {440, 0.5}, {523, 0.5}, {440, 0.5}, {392, 1.0}, {330, 1.0}, {294, 0.5}, {330, 0.5}, {392, 0.5}, {330, 0.5}, {294, 1.0}, {262, 1.0}, {392, 0.5}, {440, 0.5}, {523, 0.5}, {587, 0.5}, {523, 1.0}, {440, 1.0}, {392, 0.5}, {440, 0.5}, {523, 0.5}, {440, 0.5}, {392, 1.0}, {330, 1.0}, {294, 0.5}, {330, 0.5}, {294, 0.5}, {262, 0.5}, {220, 1.0}, {196, 1.0}, {262, 2.0}, {0, 2.0}, }; #define N (int)(sizeof(song) / sizeof(song[0])) int main(void) { for (int i 0; i N; i) { int dur (int)(song[i].b * BT); if (song[i].freq 0) { Sleep(dur); } else { int gap 24; Beep(song[i].freq, dur gap ? dur - gap : dur); if (dur gap) Sleep(gap); } } printf(播放结束\n); return 0; }编译运行后你会听到电脑用“电子音”把《下山》的主题旋律叮叮咚咚地奏了一遍。这段代码没有任何依赖用的全是标准 C 加上一个 Windows API非常适合当入门 demo。如果编译时报windows.h找不到说明你目前的环境不是 Windows 原生编译环境要么切到 Windows 下的编译器要么直接用第四节讲的办法跨平台生成 WAV。3.4 一个小技巧整体移调如果你觉得听感跟原曲差了半度——比如原曲是 D 调而你写的是 C 调那不需要重新改谱子。十二平均律里每升高一个半音频率乘 1.0595两个半音就是 1.1225。把数组里所有频率统一乘上 1.1225就是 D 调版本for (int i 0; i N; i) { if (song[i].freq 0) song[i].freq (int)(song[i].freq * 1.1225); }或者更通用一点写个transpose函数按 MIDI 编号整体加减半音。这比手动改一个个频率值靠谱太多了也是我后面做“换 Key”时最常用的手段。4. 升级方案纯 C 生成 WAV 文件4.1 为什么琢磨 WAVBeep 方案简单但有几个硬伤一是部分笔记本厂商会在驱动层把 Beep 静音代码跑得欢你一个字都听不到二是在 Linux、macOS 上根本没有 Beep 这个 API三是 Beep 的声音是方波电子味特别重多听两遍耳朵就累。更优雅的方案是不用系统发声接口而是自己生成一份标准 WAV 音频文件。WAV 是最基础的音频格式没有任何压缩就是一堆采样值完全可以手搓。生成之后想播就播还能发给别人听、转成其他格式。这个方案最大的好处是纯标准 C 就能搞定跨平台跑。4.2 WAV 格式与正弦波采样WAV 文件结构其实就两块44 字节的文件头 一大堆采样数据。文件头里写明了这是不是 PCM 格式、单声道还是双声道、采样率多少、每个采样几个字节。具体布局是偏移长度内容04RIFF44文件总大小减 884WAVE124fmt 164音频格式块长度16202音频格式1 表示 PCM222声道数1 表示单声道244采样率44100284字节率 采样率 × 声道数 × 位深/8322块对齐 声道数 × 位深/8342位深16364data404数据区字节数44...采样数据采样率 44100 意思是一秒钟记录 44100 个点每个点是一个 16 位整数范围 -32768 到 32767表示那一刻的空气振动幅度。对一个频率为 f 的音第 i 个采样点的理想值就是正弦函数v sin(2 * PI * f * t)这里 t i / 44100单位是秒。把 0 到 1 范围内的正弦值放大到 16 位整数能表示的幅度写进文件就得到一个纯正弦波的单音。连续按音符数组写数据就是一首歌。不过直接写出来的正弦波会在每个音的起止瞬间产生突变播放时会出现“啪啪”的爆音。解决办法是给每个音符加一段很短很短的淡入淡出业内管这叫包络处理。我一般取前 200 个采样点约 4.5 毫秒做线性渐入后 200 个采样点做线性渐出瞬间的突变被削平声音立刻干净很多。4.3 完整 WAV 生成代码下面是完整程序。song数组跟上一节完全一样只改了播放部分#define _USE_MATH_DEFINES #include stdio.h #include stdlib.h #include math.h #define SAMPLE_RATE 44100 #define AMP 0.5 #define BPM 150 #define BEAT_SEC (60.0 / BPM) typedef struct { int freq; double b; } Note; Note song[] { {392, 0.5}, {440, 0.5}, {523, 0.5}, {440, 0.5}, {392, 1.0}, {330, 1.0}, {294, 0.5}, {330, 0.5}, {392, 0.5}, {330, 0.5}, {294, 1.0}, {262, 1.0}, {392, 0.5}, {440, 0.5}, {523, 0.5}, {587, 0.5}, {523, 1.0}, {440, 1.0}, {392, 0.5}, {440, 0.5}, {523, 0.5}, {440, 0.5}, {392, 1.0}, {330, 1.0}, {294, 0.5}, {330, 0.5}, {294, 0.5}, {262, 0.5}, {220, 1.0}, {196, 1.0}, {262, 2.0}, {0, 2.0}, }; #define N (int)(sizeof(song) / sizeof(song[0])) void write_wav_header(FILE *fp, int total_samples) { int data_bytes total_samples * sizeof(short); int riff_size 36 data_bytes; fwrite(RIFF, 1, 4, fp); fwrite(riff_size, 4, 1, fp); fwrite(WAVE, 1, 4, fp); fwrite(fmt , 1, 4, fp); int subchunk1 16; short format 1, channels 1; int sample_rate SAMPLE_RATE; int byte_rate SAMPLE_RATE * 2; short block_align 2, bits 16; fwrite(subchunk1, 4, 1, fp); fwrite(format, 2, 1, fp); fwrite(channels, 2, 1, fp); fwrite(sample_rate, 4, 1, fp); fwrite(byte_rate, 4, 1, fp); fwrite(block_align, 2, 1, fp); fwrite(bits, 2, 1, fp); fwrite(data, 1, 4, fp); fwrite(data_bytes, 4, 1, fp); } void write_tone(FILE *fp, int freq, double seconds) { int n (int)(seconds * SAMPLE_RATE); int fade 200; for (int i 0; i n; i) { double t (double)i / SAMPLE_RATE; double env 1.0; if (i fade) env (double)i / fade; if (n - i fade) env (double)(n - i) / fade; double v (freq 0) ? sin(2.0 * M_PI * freq * t) : 0.0; short out (short)(v * env * AMP * 32767); fwrite(out, sizeof(short), 1, fp); } } int main(void) { int total 0; for (int i 0; i N; i) total (int)(song[i].b * BEAT_SEC * SAMPLE_RATE); FILE *fp fopen(downhill.wav, wb); if (!fp) { perror(fopen); return 1; } write_wav_header(fp, total); for (int i 0; i N; i) write_tone(fp, song[i].freq, song[i].b * BEAT_SEC); fclose(fp); printf(已生成 downhill.wav\n); return 0; }这段代码最值钱的地方在于它完全摆脱了平台 API。Windows 上编译Linux 上编译效果一模一样。Linux 下编译记得加数学库链接参数gcc -o gen_wav gen_wav.c -lm生成后播放也简单Windows 下运行完程序在命令行里敲start downhill.wav或者直接在资源管理器双击Linux 下用aplay或者ffplayaplay downhill.wav4.4 Beep 与 WAV 的效果差异我实际生成后对比过两者的听感差异可以用一句话概括Beep 是“电子音乐盒”WAV 是“安静的八音盒”。Beep 用的是方波方波含大量奇次谐波所以声音更亮、更硬、更“塑料”我生成 WAV 用的正弦波只有基频声音干净、柔和非常适合国风旋律的含蓄气质。如果你想要更丰富的声音表现可以再进一步把每个音符从单正弦改成“基频震 第二泛音”的叠加模拟一点钟琴音色甚至可以给每个音符的幅度加一个指数衰减包络让声音像拨弦一样自然消退。这些都是很小的改动几行代码的事但声音质感立刻不一样。我觉得这是整个项目里性价比最高的玩法强烈建议做完基本版后试一下。5. 常见问题与排查实录5.1 问题速查表实际操作中下面这些问题出现概率最高我按自己的踩坑经验整理成表现象原因解决方法Beep 代码编译通过但没声音部分笔记本驱动把 Beep 静音了改用 WAV 生成方案编译报 windows.h 不存在不在 Windows 环境编译切 Windows 编译或走 WAV 方案程序运行后窗口一闪而过播放结束 main 正常退出末尾加 getchar() 或 system(pause)音符连成一片没有颗粒感音符间隔没留 gapBeep 结束后补 Sleep(20~30ms)旋律听起来音高不对频率表映射错了核对简谱数字与频率表检查高低音点原曲是 D 调但代码是 C 调整体音高差一点所有频率乘 1.1225 整体移调高音特别刺耳方波谐波太丰富改正弦波 WAV或降低 AMP播放 WAV 有“啪”的爆音波形起止瞬间突变加 200 采样点的淡入淡出5.2 几个高频坑的展开说明第一Beep 没声音这个问题最容易劝退人。我最初在一台老笔记本上跑程序正常退出就是听不见任何声音一度怀疑是自己代码写错了。后来才发现是驱动层面的问题和代码没关系。所以如果你遇到“静默播放”别急着怀疑算法先换台机器或者直接生成 WAV 验证能省下大量调试时间。第二音符“糊成一团”的问题。这个我调试了挺久最后才意识到是音符间隔的问题。Beep 自带阻塞特性但连续两个同频音符如果无缝衔接耳朵根本分不出是两个音。解决办法就是每两个音之间加一个非常短的空窗短到不影响节奏感但足够让耳朵分辨。24 毫秒是我试下来比较稳的值实际可以根据 BPM 微调BPM 快就调小到 15 左右BPM 慢就调到 30 左右。第三编译环境的问题。很多人用在线编译网站跑代码那上面的环境多半是 Linux自然找不到 windows.h。我的建议是如果只是想验证逻辑用 WAV 方案如果一定要听 Beep 的声音就在 Windows 本地装一套 gcc 工具链或者直接用 Visual Studio 建空项目把代码粘进去。别在编译器环境上死磕那是浪费时间。第四调式与听感的问题。数组里每个频率都是对的但整体听起来不像《下山》多半是基调不对。我最初的版本在 C 调总觉得比原曲平了一点后来把所有频率乘 1.1225 移高到 D 调感觉立刻对了。这就是整体移调技巧的意义比手动改十几个频率值高效太多。6. 再往下还能玩出什么6.1 把键盘变成乐器播放数组是“程序控制”你还可以反过来做成“实时演奏”——把键盘按键映射成音符用GetAsyncKeyState检测按键按哪个键就 Beep 哪个频率。这样你可以自己“弹”《下山》而不是光听自动播#include windows.h int main(void) { int keys[] {A, S, D, F, G, H, J}; int freqs[] {262, 294, 330, 349, 392, 440, 494}; while (1) { for (int i 0; i 7; i) { if (GetAsyncKeyState(keys[i]) 0x8000) Beep(freqs[i], 80); } Sleep(10); } return 0; }这个代码跑起来你的键盘就是一台七个键的迷你钢琴。A 到 J 一排键刚好是 do 到 si手放上去就能跟着《下山》的旋律瞎弹。实测手感还行就是 Beep 的方波音色太“硬”弹久了耳朵累可以考虑配合 WAV 方案做实时合成不过那个复杂度就上来了。6.2 双声部与和声Beep 一次只能发一个音但 WAV 方案可以轻松做到双声部。生成时对同一个时间点叠加两个正弦波的采样值再除以 2 防止削波就能同时听到两个音。把主旋律数组和低音伴奏数组按时间对齐逐采样点相加你就能做出简单的“主旋律 低音声部”效果。《下山》这种国风歌低音声部随便填几个长音就有模有样我试过用低音 1 和低音 5 交替垫底整个听感立刻厚了一层。等你样品离线的 E人.硬要说这套折腾下来最值钱的体验我觉得有两点第一它把“声音”这个抽象概念拉回到了数字层面频率、采样率、包络这些名词在你亲手写出一个能响的 WAV 之后就再也不是课本上干巴巴的术语了第二写歌谱数据的过程意外地锻炼耳朵你得学会哼着歌写数组、靠听感调音准这种“用耳朵 debug”的经历在其他编程题里很少遇到。当初会起「疯狂的往左」这种题名我猜也是这个意思——看起来是播放一首歌实际上从乐理、数据结构到 Windows API、文件格式全都过了一遍确实够疯的。你要是也拿到了类似的整活题别急着找现成代码先把我这套“频率加时长”的骨架搭起来剩下的就是你跟旋律之间的耐心了。