尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

FPGA音乐演奏电路设计:从分频计数器到状态机的完整实现

FPGA音乐演奏电路设计:从分频计数器到状态机的完整实现 简介本资源是一套完整的基于FPGA的音乐演奏电路设计工程面向数字电路初学者、Verilog硬件开发学习者及嵌入式系统实践者解决从理论音符生成到可运行硬件实现的全流程落地问题。压缩包共166个文件涵盖21个HDL综合数据库hdb/cdb、21个编译日志qmsg/logdb、7个核心Verilog源码v、5个波形仿真文件vwf、5个文本说明txt及1个可直接烧录的sof配置文件等完整覆盖设计、仿真、综合、布局布线与下载验证各环节资源包大小为13.85MB结构清晰模块化程度高含音符解码器、分频时钟模块、频率发生器及顶层控制逻辑等关键组件。已有750人学习下载提供可直接上板运行的music_top.v顶层框架、带备份的decoder.v与clk_divide.v等关键模块源码以及配套readme说明与引脚约束pin、工程配置qsf/qpf等实用文件助读者快速理解FPGA音乐合成原理并完成实操验证。 很多人第一眼看到基于FPGA的音乐演奏电路第一反应往往是“就这”——开发板上一个蜂鸣器滴滴答答放个小星星听起来确实谈不上震撼。但等你真正动手做一遍就会发现这个看起来不起眼的项目几乎把数字逻辑设计的核心知识点全部串起来了时钟分频、计数器、查找表、状态机、模块化设计甚至后续做DDS信号发生器、音频处理时会用到的思想在这个项目里都有雏形。如果你正在学FPGA又不想一上来就啃DDR3、PCIE这种大工程那么音乐演奏电路是一个非常合适的中等难度项目既能快速建立“硬件并行思维”也能在一两周内跑通硬件、听到实际效果。用FPGA播放音乐本质上要解决两个问题一是怎么让硬件“知道”某个音符对应多大的频率并准确产生这个频率二是怎么按乐谱的节奏把一串音符依次送到输出端。前者是一个标准的数字分频/波形生成问题后者是一个典型的控制状态机设计问题。这篇博文我从原理到Verilog代码、从蜂鸣器到DAC输出把完整链路梳理一遍也把我在调试中踩过的坑一并记录下来。1. 先看全局这个项目到底在做什么1.1 核心功能闭环先说需求。最简单的音乐演奏电路功能可以拆成这么几条能播放一段预设旋律比如《小星星》《欢乐颂》这类经典入门曲目每个音符的频率要准确误差不能被耳朵明显察觉每个音符的时长节拍要可控不能一股脑全弹完最好有循环播放、暂停/继续之类的控制口方便演示看上去简单但每一条落到FPGA里都需要专门的硬件逻辑。频率准确对应分频计数器的精度节拍可控对应独立的时间基准计数器循环播放对应状态机的状态迁移。把这些点串起来项目的整体轮廓就出来了。1.2 顶层模块怎么划分我做这个项目时顶层逻辑划分成了四个模块这个划分方式在后面做复杂项目时也一直在用非常值得养成习惯模块职责核心资源clk_manager时钟管理、复位同步PLL/MMCM、全局复位tone_gen根据音符编号产生对应频率的方波分频计数器note_table将音符编号映射到分频阈值组合逻辑/Case语句player_ctrl乐谱存储与播放流程控制状态机、ROM/RAM为什么要把音符映射单独拆成一个模块因为“音符编号到频率系数”是一张固定的查表逻辑它不关心外部时钟也不关心状态机怎么跑单独拆出来之后后续如果要把《小星星》换成《欢乐颂》只需要改player_ctrl里的乐谱内容tone_gen一行都不用动。这种“数据通路与控制器分离”的思想越早建立越好。2. 频率生成的底层逻辑分频计数器2.1 从频率到分频阈值的数学推导FPGA里产生一个特定频率的方波最直接的办法就是拿系统时钟去做分频。假设系统时钟是50MHz要产生标准音A4440Hz的方波输出方波的半周期时间就是 1/(440×2) ≈ 1.136ms。在50MHz时钟下这段时间对应的时钟周期数是N_half f_clk / (2 × f_note) 50_000_000 / (2 × 440) ≈ 56818.18所以我们需要一个计数器从0计数到56817然后翻转输出电平再重新计数。这样输出方波的频率就是f_out 50_000_000 / (2 × 56818) ≈ 439.9Hz误差大约是0.02%人耳完全听不出来。这里有一个细节分频阈值不一定能整除所以要选择向上取整还是向下取整。在音高上每差一个半音比如C到升C频率差约5.95%而取整带来的误差通常远小于0.1%所以取整方向并不重要只要不是差的离谱就行。计数器位宽也要提前算好。50MHz下最低音如果做到C2约65.4Hz半周期计数值为 50M/(2×65.4)≈382,263需要一个19位计数器。如果只做中音区C4~C517位就够。建议直接开20位留足余量后续换曲子不用再改位宽。2.2 分频计数器的Verilog实现下面这段代码是tone_gen模块的核心我实测在很多开发板上都能直接跑module tone_gen #( parameter CLK_FREQ 50_000_000 )( input wire clk, input wire rst_n, input wire [5:0] note_idx, // 音符索引0表示休止 output reg tone_out // 输出方波 ); // 根据音符索引查分频阈值通过 note_table 组合逻辑产生 reg [19:0] threshold; always (*) begin case (note_idx) 6d0: threshold 20d0; // 休止 6d1: threshold CLK_FREQ / (2 * 262); // C4 6d2: threshold CLK_FREQ / (2 * 294); // D4 6d3: threshold CLK_FREQ / (2 * 330); // E4 6d4: threshold CLK_FREQ / (2 * 349); // F4 6d5: threshold CLK_FREQ / (2 * 392); // G4 6d6: threshold CLK_FREQ / (2 * 440); // A4 6d7: threshold CLK_FREQ / (2 * 494); // B4 6d8: threshold CLK_FREQ / (2 * 523); // C5 default: threshold 20d0; endcase end reg [19:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 20d0; tone_out 1b0; end else if (note_idx 6d0) begin // 休止符输出拉低不给蜂鸣器驱动 cnt 20d0; tone_out 1b0; end else if (cnt threshold - 1) begin cnt 20d0; tone_out ~tone_out; end else begin cnt cnt 1b1; end end endmodule有两点要特别提醒。第一threshold等于0时threshold - 1会发生下溢变成很大的数所以休止符必须单独处理这也是很多新手刚开始不发声的隐藏原因。第二这个写法用的是“半周期翻转”而非“周期内高低电平可调”输出的占空比固定为50%对于蜂鸣器来说音色最干净也不会因为占空比不稳定产生直流分量。3. 乐谱存储与播放状态机的设计3.1 音高和时值怎么编码一个音符在乐谱里包含两个维度音高和时值。在FPGA里最直观的编码方式就是每个音符占用两个数组一个数组存音符索引另一个存时值。时值我用“节拍数”来表示比如四分音符 1拍二分音符 2拍八分音符 0.5拍由于FPGA里不擅长处理小数我统一把最小时间单位设为“半拍”这样八分音符就是1个单位四分音符就是2个单位二分音符就是4个单位。所有时值先用半拍为单位整数化再用一个独立的节拍计数器去计时。这里的关键疑问是“一拍到底多长”我一般设一拍为0.5秒也就是半拍0.25秒这个速度播放《小星星》听起来比较舒服。在50MHz时钟下节拍单位时间就是 50M×0.2512,500,000 个时钟周期一个24位计数器完全够用。3.2 以《小星星》为例的乐谱编码《小星星》的简谱是1 1 5 5 6 6 5 - 4 4 3 3 2 2 1 -对应到C大调就是 C C G G A A G - F F E E D D C -。我把音高索引和时值数组这样定义// 音符索引: 0休止, 1C4, 2D4, 3E4, 4F4, 5G4, 6A4, 7B4, 8C5 localparam [5:0] C4 6d1; localparam [5:0] D4 6d2; localparam [5:0] E4 6d3; localparam [5:0] F4 6d4; localparam [5:0] G4 6d5; localparam [5:0] A4 6d6; localparam [5:0] B4 6d7; localparam [5:0] C5 6d8; localparam [5:0] REST 6d0; // 半拍为单位 localparam [3:0] H 4d1; // 八分音符 localparam [3:0] Q 4d2; // 四分音符 localparam [3:0] H2 4d4; // 二分音符 reg [5:0] note_mem [0:15]; reg [3:0] dur_mem [0:15]; initial begin // 第1小节: 1 1 5 5 6 6 5 - note_mem[0] C4; dur_mem[0] Q; note_mem[1] C4; dur_mem[1] Q; note_mem[2] G4; dur_mem[2] Q; note_mem[3] G4; dur_mem[3] Q; note_mem[4] A4; dur_mem[4] Q; note_mem[5] A4; dur_mem[5] Q; note_mem[6] G4; dur_mem[6] H2; note_mem[7] REST; dur_mem[7] H; // 第2小节: 4 4 3 3 2 2 1 - note_mem[8] F4; dur_mem[8] Q; note_mem[9] F4; dur_mem[9] Q; note_mem[10] E4; dur_mem[10] Q; note_mem[11] E4; dur_mem[11] Q; note_mem[12] D4; dur_mem[12] Q; note_mem[13] D4; dur_mem[13] Q; note_mem[14] C4; dur_mem[14] H2; note_mem[15] REST; dur_mem[15] H; end用initial块初始化只适合仿真和下载到BRAM的场景如果是在某些芯片上做固化建议改用$readmemh加载外部文件这样换曲子不用重新综合更灵活。不过初学者用initial就够了先跑通再说。3.3 播放控制状态机播放控制是整个项目的“大脑”我用四个状态实现IDLE空闲状态等待启动指令PLAY当前音符正在播放WAIT拍间隔音符切换前的短暂停顿LOOP_END播完最后音符回到IDLE或从头循环核心代码如下注意我在每个音符进入时都会把分频计数器和节拍计数器清零这是防止“音不准”和“节拍乱”的关键细节module player_ctrl #( parameter TOTAL_NOTES 16, parameter BEAT_CNT_MAX 12_500_000 // 半拍对应的时钟周期数 )( input wire clk, input wire rst_n, input wire play_en, output reg [5:0] note_idx, output reg [3:0] note_dur ); localparam IDLE 2d0; localparam PLAY 2d1; localparam WAIT 2d2; reg [1:0] state; reg [4:0] addr; reg [23:0] beat_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; addr 5d0; beat_cnt 24d0; note_idx 6d0; note_dur 4d0; end else begin case (state) IDLE: begin if (play_en) begin addr 5d0; state PLAY; end end PLAY: begin // 取当前音符信息 note_idx note_mem[addr]; note_dur dur_mem[addr]; // 用计数器控制节拍 if (beat_cnt BEAT_CNT_MAX * note_dur - 1) begin beat_cnt 24d0; state WAIT; end else begin beat_cnt beat_cnt 1b1; end end WAIT: begin // 短停顿避免音符间粘连 if (beat_cnt BEAT_CNT_MAX / 8) begin beat_cnt 24d0; if (addr TOTAL_NOTES - 1) begin addr 5d0; state PLAY; // 循环播放 end else begin addr addr 1b1; state PLAY; end end else begin beat_cnt beat_cnt 1b1; end end endcase end end endmodule注意这里note_dur已经是半拍为单位所以BEAT_CNT_MAX * note_dur表示当前音符总共持续多少个时钟周期。位宽要算好二分音符对应 12_500_000×450_000_000需要26位我这里是24位就溢出了所以参数里实际建议用26位或直接做reg [31:0]省心。4. 输出方案蜂鸣器、PWM还是DAC4.1 先分清蜂鸣器类型开发板上最常见的音频输出元件是蜂鸣器但蜂鸣器分为有源和无源两类这是新手踩坑的重灾区。有源蜂鸣器内部自带振荡电路只要通直流就会响频率固定。买来通电就响适合做报警但完全没法用来播放不同音调的音乐无源蜂鸣器内部没有振荡源需要外部提供交流方波才能发声频率由外部信号决定这才是本项目要用的器件判断方法很简单用一节电池碰两个引脚如果直接响的是有源不响或用万用表测电阻几百欧的是无源。买开发板时我也见过有些板子上的蜂鸣器是有源的这种情况下代码写得再好也只会发出固定音调的“滴滴”声根本弹不出旋律这一点务必在硬件选型时确认。4.2 三种输出方案对比方案实现方式音质硬件复杂度适用场景无源蜂鸣器IO直接输出方波中方波自带谐波偏“电子音”极低板载即可入门学习、课程设计PWM输出用PWM控制等效电压可调音量/音色中上配合滤波可接近正弦低只需1个IO 功放电路进阶学习、小型播放器外置DACDDS生成正弦波经DAC输出高接近真实乐器音色中高需要外部DAC芯片或音频编解码器音频处理、音色合成方向我在实际项目里建议如果只是验证逻辑正确性无源蜂鸣器就够了省事。如果想往“音频系统”方向延伸直接跳到DACDDS方案那才是FPGA做音频的核心玩法。4.3 无源蜂鸣器的直接驱动细节无源蜂鸣器内部是压电陶瓷片等效于一个电容性负载IO口直接驱动一般能响但声音偏小而且不同板子IO驱动能力不一样。如果你在面包板上自己搭电路建议用一个简单三极管放大FPGA IO → 1kΩ电阻 → NPN三极管基极 VCC → 蜂鸣器 → 蜂鸣器- → 三极管集电极 三极管发射极 → GND这样方波的幅度可以从3.3V提升到蜂鸣器额定电压常见5V或12V响度明显增加。IO口低电平使能还是高电平使能取决于三极管接法写代码前先确认极性否则可能发现“按逻辑该响的时候不响不该响的时候反而响”。4.4 想提升音质DDS正弦波方案分频产生的方波含有大量奇次谐波所以听起来“又尖又硬”。如果你想听到接近乐器的柔和音色可以考虑DDS直接数字合成生成正弦波。核心思路是提前把正弦波一个周期的采样点存到ROM里比如256个点用相位累加器累加“频率控制字”频率控制字 f_note × 2^N / f_clk相位累加器的高8位作为ROM地址读出一个周期内的正弦波采样值采样值经过PWM或DAC输出再经低通滤波还原成模拟信号DDS的好处是频率切换非常平滑相位连续而且改频率只需要改频率控制字不改硬件逻辑。这不只是“音乐播放”它其实就是任意波形发生器的基础和信号发生器项目是同一套东西。5. 调试实录从“不出声”到“音不准”的排查链路5.1 先查蜂鸣器类型和硬件极性我刚开始做这个项目时代码仿真完全正常下载到板上却完全没声音。第一反应是代码问题结果排查了一圈发现板载蜂鸣器是有源的只支持直流驱动。换了一块无源蜂鸣器的板子声音立刻出来了。所以遇到“完全无声”的第一件事不是翻代码而是先确认硬件链路蜂鸣器是有源还是无源IO口有没有经过三极管使能极性是高有效还是低有效。5.2 分频系数和实际时钟不一致导致音准偏差有次我把工程从A板迁移到B板代码里写死50MHz时钟但B板的外部晶振是12MHz内部PLL配置我忘了改成50MHz导致实际系统时钟跑在96MHz。所有音符频率几乎翻倍听起来完全不是那首曲子。排查方法很简单用一个计数器输出一段已知时间的方波用示波器或者逻辑分析仪测实际频率确认系统时钟到底是多少。另外还要检查PLL配置。如果工程里用了MMCM/PLL产生100MHz时钟那么所有分频阈值都要按100MHz重新算不再按50MHz。这块很多人容易漏建议把所有时钟相关的参数集中成一个宏定义或localparam统一管理别散落在各个模块里。5.3 计数器位宽不足音符时值错乱前面提到如果节拍计数器位宽不够拍数长的音符比如二分音符计数到最大值后会溢出回0导致二分音符被截断成四分音符整个节奏一塌糊涂。这类问题仿真时往往发现不了因为仿真时间短而实际硬件连续跑几秒就暴露了。所以写计数器时先把最大值算清楚有余量就多分配几位资源不差这一点。5.4 音符切换时“滋啦”的杂音音乐播放时音符之间如果直接切换tone_gen的输出波形会从任意相位突然跳到另一个频率人耳听到的就是“滋啦”一声。我在WAIT状态里加了小停顿相当于给音符之间留了一个极短的静音间隔听起来就自然很多。如果你想更极致一点可以在tone_gen内部加一个“输出使能”信号音符切换时先把输出拉低几个微秒再开始新频率的计数这样能完全避免相位突变。5.5 用仿真和在线逻辑分析仪双重验证写代码过程中我习惯先用ModelSim/Vivado Simulator跑一遍功能仿真重点看三个信号note_idx有没有按顺序变化、每个音符的持续时长是否满足时值要求、tone_out在休止符时是否拉低。仿真过了之后再用ILAIntegrated Logic Analyzer抓板上实际信号对比实际状态机和预期是否一致。很多时序问题在仿真里看不出来比如异步复位、跨时钟域只有在线抓信号才能定位。调试时还有一个实用技巧如果手头没有示波器可以临时写一个“频率测量”模块用另一个计数器在固定时间内统计tone_out的翻转次数然后用串口把计数值发到上位机。这样不用示波器也能验证输出频率对不对误差精确到Hz级别。6. 做完之后可以往哪些方向扩展6.1 增加交互控制在现有状态机上加按键输入实现播放/暂停、上一曲/下一曲、切换不同歌曲。也可以加一个调速开关把节拍计数器的时间基准调快或调慢实现“快进慢放”的效果。这部分关键是处理好按键消抖和单次触发逻辑。6.2 增加显示模块用开发板上的数码管或LCD显示当前播放的音符序号、曲名、速度等。音符索引和显示段码之间是一张简单的查表正好练手七段数码管驱动。也可以加个LED灯随音符跳动演示效果会好很多。6.3 从播放器走向合成器只播放预设旋律是比较静态的玩法。如果FPGA能接收外部输入比如UART或MIDI信号实时解析音符并控制tone_gen发声那就是一个简单的MIDI合成器了。再往后可以做双音轨、多音色叠加、包络控制这就进入了数字音频信号处理的领域配合DDS和DAC能玩的深度远超“蜂鸣器放歌”这个层面。我个人对这个项目的评价是它是理解“硬件并行思维”最好的入门实验之一。做这个项目之前我总觉得“播放音乐”是一个软件任务要写循环、延时、中断。做完之后才意识到在硬件里根本没有什么“循环延时”所有事情都是靠计数器、状态机和信号翻转在每一个时钟周期里并行推进的。这种认知转变比代码本身值钱得多。最后分享一个我的习惯项目早期就把顶层模块的端口和参数定义清楚写上详细注释再开始写子模块。这个小习惯能让我在后期调试时省掉大量翻代码的时间。基于FPGA的音乐演奏电路说到底是一个“麻雀虽小五脏俱全”的数字逻辑综合训练当你把它跑通、听到自己写的硬件“弹”出旋律那一刻你会对FPGA这三个字有一个全新的理解。本文还有配套的精品资源点击获取
返回列表