简介:这是一份基于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。国际标准 A4=440Hz,任何音符的频率都能由 A4 推算出来:
f(n) = 440 × 2^((n−69)/12),其中 n 是MIDI音符号,69对应A4。
这个关系在算法上很干净,但在FPGA里直接用浮点计算不划算。常见做法是在PC端或脚本里把音符频率算成一张整数表,硬件只查表。下面这张表列了C4到C5共13个音,相位增量列对应本章2.3节的DDS参数,时钟50MHz,累加器位宽32位:
| 音名 | 频率/Hz | 相位增量(十进制) |
|---|---|---|
| C4 | 261.63 | 22475 |
| C#4 | 277.18 | 23808 |
| D4 | 293.66 | 25227 |
| D#4 | 311.13 | 26725 |
| E4 | 329.63 | 28309 |
| F4 | 349.23 | 30000 |
| F#4 | 369.99 | 31777 |
| G4 | 392.00 | 33673 |
| G#4 | 415.30 | 35675 |
| A4 | 440.00 | 37796 |
| A#4 | 466.16 | 40041 |
| B4 | 493.88 | 42424 |
| C5 | 523.25 | 44947 |
相位增量按公式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 <= 1'b0; end else if (cnt == HALF_COUNT - 1) begin cnt <= 'd0; note_out <= ~note_out; // 每计满半周期翻转一次 end else begin cnt <= cnt + 1'b1; end end endmodule参数 HALF_COUNT 的单位是时钟周期,输出频率等于Fclk / (2 × HALF_COUNT)。想发出多个音就得准备多组 HALF_COUNT 参数并动态替换,而动态替换意味着计数状态必须重新初始化,否则输出相位不连续。这也是为什么分频方案通常只出现在入门练习里,真正的电子琴工程更愿意用下一节那种办法。
2.3 相位累加器(NCO):用一个加法器替换分频计数器
DDS 的做法是维护一个 N 位相位寄存器,每个时钟周期加上一个相位增量 phase_inc,自然溢出回绕。输出频率由下式决定:
f_out = Fclk × phase_inc / 2^N
N=32、时钟50MHz时,可用频率分辨率接近0.0116Hz,远好于分频法。切换音符只改一个输入信号 phase_inc,相位从当前值继续累加,输出不会突然中断,这是它能改善切换爆音的根本原因。
// dds_nco.v:N位相位累加器,输出方波脉冲 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.v:4x4矩阵扫描 + 连续匹配消抖 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, // 无键时为4'hF 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 == 2'd3); 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 + 1'b1; end always @(posedge clk or negedge rst_n) begin if (!rst_n) col_idx <= 2'd0; else if (scan_tick) col_idx <= col_idx + 1'b1; end always @(*) begin case (col_idx) 2'd0: col = 4'b1110; 2'd1: col = 4'b1101; 2'd2: col = 4'b1011; default: col = 4'b0111; endcase end // 行输入先打两拍,再参与解码 reg [3:0] row_s1, row_s2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin row_s1 <= 4'hF; row_s2 <= 4'hF; end else begin row_s1 <= row; row_s2 <= row_s1; end end reg [3:0] key_raw; always @(*) begin case (row_s2) 4'b1110: key_raw = {col_idx, 2'b00}; 4'b1101: key_raw = {col_idx, 2'b01}; 4'b1011: key_raw = {col_idx, 2'b10}; 4'b0111: key_raw = {col_idx, 2'b11}; default: key_raw = 4'hF; 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 <= 4'hF; same_cnt <= 'd0; key_gate <= 1'b0; key_code <= 4'hF; end else if (scan_done) begin if (key_raw == last_code) begin if (same_cnt >= DEBOUNCE_ROUNDS) begin key_gate <= 1'b1; key_code <= last_code; end else begin same_cnt <= same_cnt + 1'b1; end end else begin last_code <= key_raw; same_cnt <= 'd0; key_gate <= 1'b0; key_code <= 4'hF; end end end endmodule消抖参数折算关系如下表:
| 参数 | 取值 | 作用 |
|---|---|---|
| SCAN_TICKS | 1000 | 每列保持20µs,四列一轮共80µs |
| scan_done | 每80µs一次 | 一轮扫描结束,比较一次键值 |
| DEBOUNCE_ROUNDS | 250 | 连续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) 4'h0: phase_inc = 32'd22475; // C4 4'h1: phase_inc = 32'd25227; // D4 4'h2: phase_inc = 32'd28309; // E4 4'h3: phase_inc = 32'd30000; // F4 4'h4: phase_inc = 32'd33673; // G4 4'h5: phase_inc = 32'd37796; // A4 4'h6: phase_inc = 32'd42424; // B4 4'h7: phase_inc = 32'd44947; // C5 default: phase_inc = 32'd0; // 空键输出0,静音 endcase end endmodulephase_inc 如果设为0,DDS相位就不再变化,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], 1'b1}; 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.v:8位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 + 1'b1; end always @(posedge clk or negedge rst_n) begin if (!rst_n) pwm <= 1'b0; else pwm <= (cnt < duty); end endmoduleduty是0到255的无符号数,duty=128时PWM输出约50%占空比,即最大音量的一半;duty=0时完全静音。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 <= 8'd0; cnt <= 'd0; end else if (gate) begin if (env < 8'hFF) begin cnt <= cnt + 1'b1; if (cnt >= ATTACK_STEP_CYCLES) begin cnt <= 'd0; env <= env + 1'b1; end end else begin cnt <= 'd0; end end else if (env > 8'd0) begin cnt <= cnt + 1'b1; if (cnt >= RELEASE_STEP_CYCLES) begin cnt <= 'd0; env <= env - 1'b1; 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 ? 2'sd1 : -2'sd1; wire signed [1:0] v1 = note_pulse2 ? 2'sd1 : -2'sd1; wire signed [2:0] mixed = v0 + v1; // 取值范围 -2~+2混音后除以通道数再送PWM做音量控制,能避免多个音叠加时幅度翻倍。通道数每加一,DDS加法器和查表逻辑就复制一份,在低成本FPGA上同时开放3~4个音符通道完全没有压力。
5.2 用 Python 生成频率表和正弦ROM初始化文件
手工计算相位增量的方式到半音全扩时非常容易出错。工程上可以先用Python生成Verilog的case或ROM初始化文件,之后改一个参数或换时钟频率都只需要重跑一次脚本:
# gen_notes.py:C4~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(f"4'h{n-60:<2}: phase_inc = 32'd{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会一直有用,后续每次加和弦通道、换音色表,都可以拿它当回归用例。
本文还有配套的精品资源,点击获取