ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

FPGA电子琴设计:DDS相位累加器实现精确音符与蜂鸣器发声

2026/9/19 9:26:41 拓冰建站 浏览量
FPGA电子琴设计:DDS相位累加器实现精确音符与蜂鸣器发声 简介这是一份基于FPGA的电子琴系统设计课程报告面向电子工程、EDA课程设计及相关竞赛学习者完整展示从系统方案到单元电路实现的全过程。资源为单个doc文档压缩包大小706KB内容涵盖VHDL程序源码、功能模块原理、时序仿真图及设计思路。文档以Xilinx SpartanⅡ系列芯片为平台详细介绍了控制输入电路、FPGA核心处理、显示电路和扬声器电路四大模块并对比了三种整体设计方案帮助读者理解数控分频、音调发生与自动演奏等关键技术的实现方法。文档结构完整包含摘要、目录、系统设计、FPGA简介、单元电路设计、PS2键盘控制程序和总结心得方便按章节查阅。已有543人学习下载适合需要完成类似课设或入门FPGA音乐系统设计的同学参考可从中获得清晰的模块划分、代码框架和设计报告写作范例。1. FPGA电子琴的第一道坎音符不是“响起来”而是“切换准”一个FPGA电子琴如果把“响”当成目标半天就能做完把“准”当成目标才会真正碰到FPGA的技术点。按键按下时输出要立刻切换到十二平均律规定的下一个频率音高误差、相位跳变、触发电平变化都会在这个瞬间体现。很多入门工程把音符做成了固定分频音准不可调切换时还有咔哒声根本原因是频率生成路径选错了。按一个能在开发板上跑通的最小工程展开先用相位累加器DDS生成音符频率再做矩阵键盘扫描与消抖最后合成输出到无源蜂鸣器。以50MHz系统时钟为统一假设代码不依赖具体FPGA型号可直接作为课程设计或FPGA项目的基础骨架。2. 音符频率生成从十二平均律表到相位累加器FPGA电子琴的音准来源2.1 十二平均律确定音高每差半音频率乘2的12次方根十二平均律把八度切成12等份每等份频率比是 2^(1/12)≈1.05946。国际标准 A4440Hz任何音符的频率都能由 A4 推算出来f(n) 440 × 2^((n−69)/12)其中 n 是MIDI音符号69对应A4。这个关系在算法上很干净但在FPGA里直接用浮点计算不划算。常见做法是在PC端或脚本里把音符频率算成一张整数表硬件只查表。下面这张表列了C4到C5共13个音相位增量列对应本章2.3节的DDS参数时钟50MHz累加器位宽32位音名频率/Hz相位增量十进制C4261.6322475C#4277.1823808D4293.6625227D#4311.1326725E4329.6328309F4349.2330000F#4369.9931777G4392.0033673G#4415.3035675A4440.0037796A#4466.1640041B4493.8842424C5523.2544947相位增量按公式round(freq × 2^32 / 50_000_000)算出整数化之后的最大误差在零点几个赫兹以内对蜂鸣器演奏完全够用。这张表本身就是键盘扫描和发声模块之间的唯一接口键盘输出键号发声模块查相位增量双方都不用关心对方内部的实现。2.2 整数分频计数器FPGA入门最常见的做法为什么音准会偏差最直接的想法是把50MHz除以目标频率用计数器翻转输出。以440Hz为例半周期计数 50_000_000 / (2 × 440) 56818取整后实际频率 ≈ 439.99Hz误差很小。但问题出在取整频率不是总能被整除D#4要求311.13Hz半周期计数80365.5取80365时实际≈311.08Hz取80366时又偏高0.02%。单独听不出问题可一旦换成音高更低的B4或者要同时发声做和弦整数分频的误差就会体现得比较明显。另一个问题是切换分频系数。按键改变时寄存器里的计数值要换掉一个周期之内输出就是节拍不齐的波形。下面是典型的可综合分频器写法// freq_div.v用计数器产生方波HALF_COUNT 决定输出频率 module freq_div #( parameter HALF_COUNT 56_818 // 50MHz/(2*440) 取整 )( input wire clk, input wire rst_n, output reg note_out ); localparam W 16; // 56818 需要16bit reg [W-1:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt d0; note_out 1b0; end else if (cnt HALF_COUNT - 1) begin cnt d0; note_out ~note_out; // 每计满半周期翻转一次 end else begin cnt cnt 1b1; end end endmodule参数 HALF_COUNT 的单位是时钟周期输出频率等于Fclk / (2 × HALF_COUNT)。想发出多个音就得准备多组 HALF_COUNT 参数并动态替换而动态替换意味着计数状态必须重新初始化否则输出相位不连续。这也是为什么分频方案通常只出现在入门练习里真正的电子琴工程更愿意用下一节那种办法。2.3 相位累加器NCO用一个加法器替换分频计数器DDS 的做法是维护一个 N 位相位寄存器每个时钟周期加上一个相位增量 phase_inc自然溢出回绕。输出频率由下式决定f_out Fclk × phase_inc / 2^NN32、时钟50MHz时可用频率分辨率接近0.0116Hz远好于分频法。切换音符只改一个输入信号 phase_inc相位从当前值继续累加输出不会突然中断这是它能改善切换爆音的根本原因。// dds_nco.vN位相位累加器输出方波脉冲 module dds_nco #( parameter N 32 )( input wire clk, input wire rst_n, input wire [N-1:0] phase_inc, // 每个时钟周期增加的量 output reg [N-1:0] phase, output wire pulse_out ); always (posedge clk or negedge rst_n) begin if (!rst_n) phase d0; else phase phase phase_inc; // 32位加法自然溢出 end assign pulse_out phase[N-1]; // 最高位做50%占空比方波 endmodulephase_inc 是32位无符号整数对应频率越高数值越大。取 phase 的最高位输出刚好是一半时间高一半时间低。如果想获得正弦波音色把 phase[N-1:N-8] 当成ROM地址查一张256点正弦表即可DDS结构完全不变。要注意DDS输出的是方波不是PWM。FPGA引脚直接送给蜂鸣器时靠的是方波本身驱动蜂鸣器换能而不是经过低通滤波恢复正弦波。到第4章我们会用它和PWM合成幅度这里先保持纯逻辑。另外DDS的相位寄存器是连续累加的改 phase_inc 不需要重新加载这就为滑音留了余地每个周期给 phase_inc 做一次插值就能做出频率渐变的弯音效果分频计数器做不到这一点。3. FPGA电子琴的矩阵键盘扫描与解码从按键行列到 phase_inc 查表地址3.1 4×4 矩阵键盘扫描与 20ms 消抖驱动列、读回行再数稳定次数开发板上的矩阵键盘一般按4×4排布列线接FPGA输出、行线接FPGA输入。扫描时逐列拉低其余列拉高某一列拉低后如果有一个键被按下该键所在行会被拉到低电平通过读回行值就能定位键的位置。直接读不靠谱机械按键按下和释放时会有几毫秒到二十毫秒的抖动电平会在有效和无效之间跳好几下必须加消抖。下面的模块每20µs切换扫描列扫描完四列产生一个scan_done脉冲消抖逻辑在每个scan_done时比较本次键值和上一次键值连续250轮相同才认定按键有效正好对应 250 × 80µs ≈ 20ms// key_scan.v4x4矩阵扫描 连续匹配消抖 module key_scan #( parameter CLK_FREQ 50_000_000 )( input wire clk, input wire rst_n, input wire [3:0] row, // 行输入低有效 output reg [3:0] col, // 列驱动低有效 output reg [3:0] key_code, // 无键时为4hF output reg key_gate // 稳定后的按键有效信号 ); localparam SCAN_TICKS 1_000; // 20us 切一列 localparam DEBOUNCE_ROUNDS 250; // 连续250轮相同即确认 reg [9:0] scan_cnt; reg [1:0] col_idx; wire scan_tick (scan_cnt SCAN_TICKS - 1); wire scan_done scan_tick (col_idx 2d3); always (posedge clk or negedge rst_n) begin if (!rst_n) scan_cnt d0; else if (scan_tick) scan_cnt d0; else scan_cnt scan_cnt 1b1; end always (posedge clk or negedge rst_n) begin if (!rst_n) col_idx 2d0; else if (scan_tick) col_idx col_idx 1b1; end always (*) begin case (col_idx) 2d0: col 4b1110; 2d1: col 4b1101; 2d2: col 4b1011; default: col 4b0111; endcase end // 行输入先打两拍再参与解码 reg [3:0] row_s1, row_s2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin row_s1 4hF; row_s2 4hF; end else begin row_s1 row; row_s2 row_s1; end end reg [3:0] key_raw; always (*) begin case (row_s2) 4b1110: key_raw {col_idx, 2b00}; 4b1101: key_raw {col_idx, 2b01}; 4b1011: key_raw {col_idx, 2b10}; 4b0111: key_raw {col_idx, 2b11}; default: key_raw 4hF; endcase end reg [3:0] last_code; reg [8:0] same_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin last_code 4hF; same_cnt d0; key_gate 1b0; key_code 4hF; end else if (scan_done) begin if (key_raw last_code) begin if (same_cnt DEBOUNCE_ROUNDS) begin key_gate 1b1; key_code last_code; end else begin same_cnt same_cnt 1b1; end end else begin last_code key_raw; same_cnt d0; key_gate 1b0; key_code 4hF; end end end endmodule消抖参数折算关系如下表参数取值作用SCAN_TICKS1000每列保持20µs四列一轮共80µsscan_done每80µs一次一轮扫描结束比较一次键值DEBOUNCE_ROUNDS250连续250轮相同键约20ms后确认实际开发板上矩阵键盘的行列定义未必和原理图一致接线之前先用万用表量出哪个引脚对应行、哪个对应列否则扫描结果会整体镜像错位按键映射全部错掉。3.2 键号到音符频率的映射case 查表比现场计算更适合 FPGA键号有了下一步是把它映射成频率参数。工程上不会在FPGA里做浮点运算更不会现场算440×2^(n/12)那样需要指数运算IP核几十个周期才出一个结果组合逻辑面积也难看。常见做法是预先把12个音对应的相位增量写进case按键变化时组合逻辑直接选中一个32位数。// note_map.v把4bit键码映射成DDS相位增量 module note_map( input wire [3:0] key_code, output reg [31:0] phase_inc ); always (*) begin case (key_code) 4h0: phase_inc 32d22475; // C4 4h1: phase_inc 32d25227; // D4 4h2: phase_inc 32d28309; // E4 4h3: phase_inc 32d30000; // F4 4h4: phase_inc 32d33673; // G4 4h5: phase_inc 32d37796; // A4 4h6: phase_inc 32d42424; // B4 4h7: phase_inc 32d44947; // C5 default: phase_inc 32d0; // 空键输出0静音 endcase end endmodulephase_inc 如果设为0DDS相位就不再变化pulse_out会停在当前电平上输出自然静音这比“输出0”更干净。case写满8个音后剩余键位可以继续扩成半音也可以安排成八度切换或音色切换功能。这个模块综合后就是一个32位多路选择器面积很小放到任何FPGA工程里都不占资源。提示如果键盘数量继续增加把 case 换成 ROM 数组reg [31:0] rom [0:15]更容易维护综合结果基本没差别。3.3 按键和复位都要同步亚稳态不只发生在按键上行输入打两拍能解决异步输入但不少FPGA设计还忽略了一个更隐蔽的入口复位信号。按下开发板复位键时rst_n同样可能产生毛刺直接进复位端会让内部状态在毛刺期间乱跳。常见现象是仿真完全正常上板后偶尔死机重启一次又好了。处理办法是“异步复位、同步释放”在顶层模块里加一个复位同步器// rst_sync.v异步复位、同步释放 module rst_sync #( parameter STAGES 2 )( input wire clk, input wire rst_n, output reg rst_n_sync ); reg [STAGES-1:0] sync_ff; always (posedge clk or negedge rst_n) begin if (!rst_n) sync_ff d0; else sync_ff {sync_ff[STAGES-2:0], 1b1}; end assign rst_n_sync sync_ff[STAGES-1]; endmodulerst_n_sync 后面所有模块都用它做复位而不再直接引用外部引脚。这个模块在按键类项目里意义很大因为矩阵键盘扫描本身是毫秒级循环只要有一次状态错乱之后所有按键都会映射错位。把复位信号和按键输入都同步掉整个设计的可调试性会明显提升。4. 发声与音色处理无源蜂鸣器、PWM 幅度和包络去爆音4.1 先分清蜂鸣器类型无源蜂鸣器才能被 FPGA 控制频率开发板上常见的蜂鸣器分两种。有源蜂鸣器内部带振荡电路通电就响频率固定不能改FPGA只能控制“响或不响”做不了电子琴。无源蜂鸣器内部没有振荡源需要外部给方波或正弦信号才发声频率完全由输入决定这才是电子琴需要的器件。选型时先看丝印再用万用表量阻值无源蜂鸣器直流电阻一般在16Ω到几十欧有源的在数百欧以上。两者在驱动方式上的差异如下表类型是否需要外部驱动信号是否能被FPGA控制音高典型驱动方式有源蜂鸣器不需要通电自带振荡不能输出高电平即可无源蜂鸣器需要能FPGA引脚给方波/正弦有源蜂鸣器内部振荡频率一般在2.7kHz左右供电就响跟电子琴完全无关。无源蜂鸣器的驱动电流通常几十毫安FPGA的IO口一般只能输出几毫安到十几毫安直接接引脚音量不够还可能损伤IO口。常见做法是加一级三极管放大基极串1kΩ电阻接FPGA输出集电极接蜂鸣器正极发射极接地FPGA只负责电平信号不直接驱动感性负载。4.2 PWM 载波乘音符信号用8位占空比做数字音量DDS输出的是50%占空比的方波把音量做进去有两种路线。一种是直接把方波信号经过电阻分压衰减音量不可动态控制另一种是用PWM载波控制平均电平把蜂鸣器当成低通负载。通常用后一种因为改成数字音量只需要改一个8位寄存器。// pwm_out.v8位PWM载波频率50MHz/256≈195kHz module pwm_out #( parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire [WIDTH-1:0] duty, output reg pwm ); reg [WIDTH-1:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt d0; else cnt cnt 1b1; end always (posedge clk or negedge rst_n) begin if (!rst_n) pwm 1b0; else pwm (cnt duty); end endmoduleduty是0到255的无符号数duty128时PWM输出约50%占空比即最大音量的一半duty0时完全静音。PWM的载波频率是195kHz远在人耳听阈之外蜂鸣器膜片响应不了这么高的振动频率它只会对PWM的平均值做出反应。把PWM信号和音符方波做按位与得到的输出既有音符频率的翻转、又有PWM决定的平均幅度相当于给蜂鸣器配音量控制。如果外接的是喇叭而不是蜂鸣器输出端最好并一个1µF电容到地把195kHz载波滤掉避免高频噪声辐射。4.3 包络发生器按键按下5ms渐入、弹起20ms渐出减少方波硬切爆音DDS相位连续只能保证频率切换不乱不能消除触发瞬间的相位跳变。比如上一拍按下A4相位停在某个位置下一拍换到C4两个音在时间轴上焊接在一起连接处必然出现一个跟波形幅度无关的跳变音量越大越明显。用包络把每次发声的起点和终点处理成坡度人耳几乎感觉不到连接点。// envelope.v线性attack/release包络输出8位音量 module envelope #( parameter ATTACK_STEP_CYCLES 1_000, // 每20us升一级总约5ms到顶 parameter RELEASE_STEP_CYCLES 4_000 // 每80us降一级总约20ms归零 )( input wire clk, input wire rst_n, input wire gate, // 按键稳定有效信号 output reg [7:0] env ); reg [19:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin env 8d0; cnt d0; end else if (gate) begin if (env 8hFF) begin cnt cnt 1b1; if (cnt ATTACK_STEP_CYCLES) begin cnt d0; env env 1b1; end end else begin cnt d0; end end else if (env 8d0) begin cnt cnt 1b1; if (cnt RELEASE_STEP_CYCLES) begin cnt d0; env env - 1b1; end end else begin cnt d0; end end endmodule参数不是随便填的。ATTACK_STEP_CYCLES 取1000即每1000个50MHz时钟升一级8位音量从0到255约需 255×1000/50M ≈ 5ms既能避免按键按下瞬间的冲击声又不会让声音显得迟钝RELEASE_STEP_CYCLES 取4000约20ms渐出这是键盘乐器比较自然的释放长度。高位宽包络会让音量变化更细腻但8位在蜂鸣器这类换能器上已经够用。4.4 顶层连接示例DDS、包络、PWM 三个模块怎么接三个模块的接口正好构成一条链键盘模块输出key_gate和key_code键码查表得到phase_inc门控信号直接作为包络的gate包络输出驱动PWM的占空比最后把PWM载波和DDS方波做按位与送蜂鸣器// top_music.v键盘→查表→DDS→包络→PWM→蜂鸣器 wire [31:0] phase_inc; wire gate; wire [7:0] env; wire note_pulse; wire pwm_carrier; note_map u_map ( .key_code (key_code), .phase_inc(phase_inc) ); dds_nco #(.N(32)) u_nco ( .clk(clk), .rst_n(rst_n), .phase_inc (phase_inc), .pulse_out (note_pulse) ); envelope #( .ATTACK_STEP_CYCLES (1_000), .RELEASE_STEP_CYCLES(4_000) ) u_env ( .clk(clk), .rst_n(rst_n), .gate(gate), .env(env) ); pwm_out #(.WIDTH(8)) u_pwm ( .clk(clk), .rst_n(rst_n), .duty(env), .pwm(pwm_carrier) ); assign buzzer_out note_pulse pwm_carrier;这里最容易犯的错误是把音符方波接到PWM的duty输入上。那样做得到的还是195kHz的固定载波没有音乐频率蜂鸣器只发出一个持续的高频音。正确思路是音符由DDS方波决定音量由PWM占空比决定两者按位与才是完整的输出。如果做更细腻的波形乘法可以把note_pulse和env都换成有符号数做数字乘法但那需要额外的时钟周期延迟入门工程里按位与更直观也够用。5. FPGA电子琴的三种实用扩展复音、脚本查表和仿真验证5.1 多通道 DDS 累加求和一个FPGA项目最强的扩展方向电子琴最基本的升级是支持两个以上键同时按下发声。做法是在顶层例化多个dds_nco实例每个实例独立维护自己的相位把各路输出在有符号域相加。方波需要先映射成有符号数比如note0 ? 1 : -1才能避免两个高电平相加溢出成错误电平wire signed [1:0] v0 note_pulse ? 2sd1 : -2sd1; wire signed [1:0] v1 note_pulse2 ? 2sd1 : -2sd1; wire signed [2:0] mixed v0 v1; // 取值范围 -2~2混音后除以通道数再送PWM做音量控制能避免多个音叠加时幅度翻倍。通道数每加一DDS加法器和查表逻辑就复制一份在低成本FPGA上同时开放34个音符通道完全没有压力。5.2 用 Python 生成频率表和正弦ROM初始化文件手工计算相位增量的方式到半音全扩时非常容易出错。工程上可以先用Python生成Verilog的case或ROM初始化文件之后改一个参数或换时钟频率都只需要重跑一次脚本# gen_notes.pyC4~B4的相位增量输出 CLK 50_000_000 for n in range(60, 72): # C4的MIDI编号是60 freq 440.0 * 2 ** ((n - 69) / 12) inc int(round(freq * 2**32 / CLK)) print(f4h{n-60:2}: phase_inc 32d{inc}; // {freq:7.2f} Hz)运行后把输出粘进note_map.v的case分支即可。如果要做正弦音色可以改成输出256行的.mif文件存8位量化正弦值供ROM IP核读取。这样DDS的相位寄存器、查表地址和ROM初始化数据三者都用同一个Python脚本生成更换频率标准或系统时钟只需要改一行参数。5.3 用 testbench 数脉冲验证音准比肉眼听得更快包络和PWM滤波很难在仿真里感受但音准可以验证得很精确。写一个初始相位增量37796的testbench给1秒仿真时间统计note_pulse的上升沿数量应该正好落在440个上下。用仿真器计数比听蜂鸣器可靠也能在综合前发现查表写错的键位。// tb_verify.v数一秒钟方波脉冲数 reg [31:0] cnt_pulse; always (posedge note_pulse) cnt_pulse cnt_pulse 1; initial begin // 复位、给phase_inc赋值运行1秒仿真时间 #1_000_000_000; $display(pulse count %0d, cnt_pulse); // 期望 440 ± 1 $finish; end验证时把系统时钟周期设成20ns#1_000_000_000在仿真器里表示模拟1秒。如果计数结果不是440先查相位增量公式的2^32项是不是写成了2^16再查时钟频率是否真的为50MHz。这个testbench会一直有用后续每次加和弦通道、换音色表都可以拿它当回归用例。本文还有配套的精品资源点击获取