ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA电子琴设计:从分频器到按键消抖与PWM音色生成的完整实践

FPGA电子琴设计:从分频器到按键消抖与PWM音色生成的完整实践

简介:这是一份基于FPGA的电子琴设计完整工程资源,以Verilog为硬件描述语言,面向数字电路设计学习者和FPGA入门开发者,示范了如何借助Altera Quartus II工具链,在FPGA上实现具备音符生成、频率合成与音效处理的音乐合成器。压缩包共收录69个文件,总大小约211KB,核心内容包括Quartus工程文件(qpf/qsf)、Verilog源码与TDF设计文件、综合布局布线报告(rpt/summary)以及可直接下载的sof/pof配置比特流;其中大量的hdb/cdb文件是Quartus在综合与适配阶段生成的数据库记录,可用于回溯各逻辑模块的映射情况。该资源已有421人学习下载。通过研究工程结构与源码层次,可掌握键盘输入转音符、振荡器波形产生、音色调节等关键模块的分工与设计手法,并完整理解从编写Verilog到完成布局布线、最终生成下载文件的FPGA开发流程,适合作为课程设计或毕业设计的参考资料。

1. 基于 FPGA 的电子琴,到底在做什么设计

把电子琴搬进 FPGA,表面上看是“用硬件描述语言写个键盘发声器”,实际做过的人都知道,它的难点根本不在发声,而在三件容易被低估的事:一是音符频率怎么用纯数字电路精准分出来,二是机械按键的抖动在硬件并行逻辑下怎么处理才不掉音、不连音,三是同一个方波 PWM 输出凭什么能调出不同音量、不同音色。这篇文章面向的是想课程设计选这个方向、或者第一次独立跑通一块 FPGA 开发板的学生和刚转行的工程师,我会按我自己的实现路径来讲:先拆模块,再给代码,最后把上板时最容易翻车的地方一个个摊开。这套方案我用模拟项目X验证过,工具链用的是常见的 Vivado 和一块入门级开发板,全部流程照着走,半天就能出声。

2. 电子琴设计的三个核心模块:分频、消抖、音色生成

2.1 分频器:如何用计数器产生准确的音符频率

电子琴的第一层需求是按下琴键发出对应音高的声音。钢琴中央 C 的频率是 261.63Hz,往上一个八度的 C 是 523.25Hz,这个频率必须来自系统时钟的分频。FPGA 开发板最常见的系统时钟是 50MHz 或 100MHz,我们要做的工作就是设计一个分频模块,把高频时钟变成音频范围内的方波。

常见的做法是计数器分频。假设系统时钟是 50MHz,要得到 261.63Hz,分频系数 N = 50_000_000 / 261.63 ≈ 191113。注意这个数字不是整数,所以输出频率会有误差。实际工程里有两种处理方式:一种是直接取整,误差在 0.1Hz 以内,人耳完全听不出来;另一种是加一个小数分频补偿机制,但初学者没必要一开始就上。我一般直接取整,然后生成一个 50% 占空比的方波。

下面是我常用的分频模块代码,采用计数比较的方式,而不是传统的先计一半再翻转,这样占空比更好控制:

module clk_div #( parameter DIV_N = 191113 // 中央C的分频系数,50MHz / 261.63Hz )( input wire clk, // 系统时钟 50MHz input wire rst_n, // 低电平复位 output reg clk_out // 分频后的音符方波 ); reg [17:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 18'd0; clk_out <= 1'b0; end else if (cnt >= DIV_N - 1) begin cnt <= 18'd0; clk_out <= 1'b1; // 每 N 个时钟周期输出一拍高电平 end else begin cnt <= cnt + 1'b1; clk_out <= 1'b0; end end endmodule

这段代码的逻辑很清楚:计数器从 0 计到 DIV_N - 1,计满时 clk_out 拉高一拍然后清零。也就是说在每个分频周期里,输出高电平只持续一个系统时钟周期,因此占空比是 1/N,非常窄。这个信号直接驱动蜂鸣器会有声音,但声音很尖很弱,听起来更像“哒哒哒”而不是琴音。

要提高音质,必须把占空比提到 50%。做法是计数器计到 N/2 时翻转一次输出,计满再翻转一次。我把完整写法放在下面的代码块里,这也是真正上板能用的版本:

module clk_div_50 #( parameter DIV_N = 191113 )( input wire clk, input wire rst_n, output reg clk_out ); reg [17:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 18'd0; clk_out <= 1'b0; end else if (cnt == DIV_N / 2 - 1) begin cnt <= cnt + 1'b1; clk_out <= 1'b1; // 前半段输出低,到半周期翻高 end else if (cnt == DIV_N - 1) begin cnt <= 18'd0; clk_out <= 1'b0; // 计满翻低,形成 50% 占空比 end else begin cnt <= cnt + 1'b1; end end endmodule

这段代码里两个比较点分别对应波形翻转的两个时刻。DIV_N 是参数化定义的,在顶层模块里例化多个 clk_div_50 就能产生不同音符。要注意的是 DIV_N 最小不能小于 2,否则计数比较会出问题。另外参数位宽要够,中央 C 的分频系数接近 20 万,需要 18 位计数器,有些初学者只写了 8 位宽度,高位被截断,出来的频率完全不对。

2.2 按键消抖与编码:机械开关接入 FPGA 的关键处理

电子琴的输入是矩阵键盘或者独立按键,这些机械开关在按下和松开的瞬间会产生 5ms 到 20ms 的抖动。如果直接把按键信号接进 FPGA 做边沿检测,一次按键会被误判成很多次,表现为声音断断续续或者一个音连续触发多次。

最稳妥的方案是消抖计数器:检测到按键电平变化后,启动一个 10ms 左右的计时,计时结束后再采样一次电平,如果电平稳定再确认有效。10ms 这个数值是怎么来的?常见机械按键的抖动时间在 5ms 到 10ms 左右,取 10ms 到 20ms 能覆盖大部分情况。太短消不掉抖动,太长会感觉按键反应迟钝,弹琴时手感很差。

下面是一个带参数化消抖时间的按键消抖模块:

module debounce #( parameter DELAY_MS = 10, // 消抖时间 10ms parameter CLK_FREQ = 50_000_000 )( input wire clk, input wire rst_n, input wire key_in, // 原始按键输入,低电平有效 output reg key_out, // 消抖后电平 output reg key_pulse // 按下瞬间产生一个单脉冲 ); localparam MAX_CNT = DELAY_MS * CLK_FREQ / 1000; reg [$clog2(MAX_CNT + 1) - 1:0] cnt; reg key_in_dly; reg key_press_d; // 上一拍的状态,用于边沿检测 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; key_out <= 1'b1; end else if (key_in != key_out) begin if (cnt < MAX_CNT) cnt <= cnt + 1'b1; else begin key_out <= key_in; // 电平稳定超过消抖时间,确认状态 cnt <= 0; end end else begin cnt <= 0; end end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_in_dly <= 1'b1; key_press_d <= 1'b1; end else begin key_in_dly <= key_out; key_press_d <= key_in_dly; end end assign key_pulse = key_in_dly & ~key_press_d; // 检测下降沿,低有效按键 endmodule

这个模块的关键思路是“电平稳定才更新”,而不是“边沿触发后延时”。前者实现的是一个低通滤波器,后者实现的是延时确认,前者在按键抖动不规则时更可靠。key_pulse 是单脉冲信号,只在高电平维持一个时钟周期,用于触发音符开关,不会因为按键一直按住而重复触发。如果要做连音效果,就需要额外设计一个按住状态持续发声的逻辑,不能直接用单脉冲。

这里有一个容易被忽略的地方:消抖模块改变了按键信号的时序,导致按键按下到声音出来的延迟约等于消抖时间。10ms 的延迟在演奏时基本无感,但如果你把消抖时间设成 100ms,弹琴时就会明显感觉“手指下去了声音才冒出来”。所以消抖参数的选取是个手感问题,不是越大越好。

2.3 PWM 音色生成:为什么电子琴声音不像真实钢琴

分频器输出的是理想的 50% 占空比方波,这种波形直接驱动蜂鸣器,声音非常单调、生硬,和真实钢琴的音色差距很大。人耳对音色的感知主要来自波形谐波成分,方波含有大量奇次谐波,所以听起来“嗡嗡的”。要改善音色,常见做法是改用 PWM 输出,通过调整占空比来改变有效输出电压,再配合无源蜂鸣器的谐振特性,听感会柔和不少。

PWM 生成的核心是产生一个高频载波,然后让占空比按某种规律变化。最简单的方案是固定占空比 PWM,比如 30% 或 50%;进阶方案是让占空比随时间变化,模拟钢琴音“开头强、后面弱”的衰减特性。

先看固定占空比 PWM 的 Verilog 实现:

module pwm_out #( parameter CLK_FREQ = 50_000_000, parameter PWM_FREQ = 44100, // PWM 载波频率,人耳听感较平滑 parameter DUTY = 30 // 占空比百分比,10~50 之间试验取值 )( input wire clk, input wire rst_n, input wire note_en, // 音符使能 output reg pwm ); localparam CNT_MAX = CLK_FREQ / PWM_FREQ; localparam CNT_DUTY = CNT_MAX * DUTY / 100; reg [15:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 16'd0; pwm <= 1'b0; end else if (!note_en) begin cnt <= 16'd0; pwm <= 1'b0; // 无音符时输出低电平,避免底噪 end else begin cnt <= (cnt >= CNT_MAX - 1) ? 16'd0 : cnt + 1'b1; pwm <= (cnt < CNT_DUTY) ? 1'b1 : 1'b0; end end endmodule

这个模块里 PWM_FREQ 是载波频率,选 44.1kHz 是参考了音频采样的常用频率,实际听感上和几十 kHz 的差别不明显。DUTY 是占空比百分比,占空比越大音量越大,但超过 80% 后蜂鸣器驱动效率下降,反而可能失真。我一般从 30% 开始试,然后根据听感上下调。

现在有一个问题摆在这里:分频器产生的是音符频率的方波,PWM 模块产生的是音频载波,这两个怎么结合起来发声?答案是“PWM 载波作为高频开关信号,音符频率作为低频调制信号”,也就是把两个信号做与操作或者用音符频率门控 PWM 输出。但这个方案做出来的音色仍然偏单薄,真正让电子琴有“琴味”的做法是用 DDS 查表法生成波形。

对于课程设计来说,PWM + 音符分频的组合已经足够交差。我自己的建议是先把 PWM 方案跑通,确认整个链路没有问题,再考虑往 DDS 方向升级。DDS 需要更大的 ROM 存储波形采样点,入门级 FPGA 资源也够,但调试复杂度会明显增加。

3. 用 Vivado 走通整个工程:顶层模块、管脚约束、上板验证

3.1 从零搭工程:顶层 Verilog 代码与模块连接

前面三个模块分别解决了分频、消抖、PWM 的问题。现在要把它们串成一个完整的电子琴。这一步最需要的是顶层模块,它负责例化所有子模块、连接信号、分配管脚。很多初学者在这里犯迷糊,总觉得“代码多了信号对不上”,其实就是没把模块端口理清楚。

我按照一个 4x4 矩阵键盘来做设计,支持 16 个音符。每个按键对应一个分频系数,当检测到某个按键按下时,选择对应的分频模块输出,再把音符频率信号和 PWM 信号合成驱动蜂鸣器。下面是我搭建的顶层模块结构:

module top_electronic_piano ( input wire clk, // 50MHz 系统时钟 input wire rst_n, // 复位按键 input wire [3:0] row, // 矩阵键盘行 output wire [3:0] col, // 矩阵键盘列 output wire buzzer // 蜂鸣器输出 ); wire key_valid; wire [3:0] key_code; wire note_en; wire pwm_signal; // 键盘扫描与消抖模块,得到 key_valid 和 key_code key_scan u_scan ( .clk(clk), .rst_n(rst_n), .row(row), .col(col), .key_valid(key_valid), .key_code(key_code) ); // 音符频率选择:根据 key_code 查表,得到对应的分频系数 reg [25:0] div_n; always @(*) begin case (key_code) 4'd0: div_n = 191113; // C4 261.63Hz 4'd1: div_n = 180460; // C#4 277.18Hz // 省略其余 14 个音符的分频系数 default: div_n = 191113; endcase end // 音符分频 wire note_clk; clk_div_50 #(.DIV_N(191113)) u_note ( .clk(clk), .rst_n(rst_n), .clk_out(note_clk) ); // PWM 载波 pwm_out u_pwm ( .clk(clk), .rst_n(rst_n), .note_en(key_valid), .pwm(pwm_signal) ); // 组合输出:音符频率门控 PWM assign note_en = key_valid; assign buzzer = note_clk & pwm_signal; endmodule

这段顶层代码里我把矩阵键盘封装成了 key_scan 模块,它的内部实现包含扫描和消抖两部分。key_code 是 4 位键值,通过 case 语句查找对应的分频系数。注意这里有个简化:我直接例化了固定参数的 clk_div_50,但真正上板时应该把 div_n 作为参数传进去,让分频系数可调。改法是把 clk_div_50 改为带输入分频系数接口的方式,避免为每个音符单独例化。

顶层逻辑里那条assign buzzer = note_clk & pwm_signal;是核心,它表示蜂鸣器的驱动信号是音符频率方波和 PWM 载波的逻辑与。note_clk 为高的时候 PWM 信号能通过,note_clk 为低的时候蜂鸣器不响,这样声音就是一个断续的、带音高的 PWM 波。如果你想省掉 PWM 模块直接用 note_clk 驱动,也能响,但音色差很多。

3.2 管脚约束:XDC 文件怎么写才能一次通过

代码写完后,最难的不是逻辑,而是管脚约束。FPGA 开发板上的时钟、按键、蜂鸣器具体连在哪个 FPGA 引脚,是由板卡硬件设计决定的,必须查原理图才能确定。Vivado 的约束文件后缀是 XDC,核心语法就三类:管脚位置、电平标准、时钟约束。

下面是一个典型的 XDC 文件内容,注释里写明了每个管脚对应的板卡外设:

set_property PACKAGE_PIN E3 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property PACKAGE_PIN C12 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n] set_property PACKAGE_PIN A10 [get_ports buzzer] set_property IOSTANDARD LVCMOS33 [get_ports buzzer] set_property PACKAGE_PIN B8 [get_ports {row[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {row[0]}] # 其余 row、col 管脚依此类推 create_clock -period 20.000 -name sys_clk [get_ports clk]

这段约束里有三个容易踩的坑。第一,IOSTANDARD 必须和板卡上 bank 的供电电压匹配,现在大多数入门板卡是 3.3V,所以写 LVCMOS33。第二,时钟约束的 -period 单位是纳秒,50MHz 对应 20ns,100MHz 对应 10ns,写错会导致时序分析结果异常。第三,管脚名字必须和顶层 Verilog 的端口名完全一致,包括总线名字的大小写和索引顺序,Vivado 对这个要求极其严格。

每次改完 XDC 重新综合和实现之后,最好打开 Elapsed Route 报告看一眼有没有 unrouted net。这类问题十有八九是管脚名写错或者端口没约束全。我见过有人把 row[3:0] 只约束了 row[0] 和 row[1],综合能过,但上板后一半按键没反应,排查半天才发现是约束漏了。

3.3 上板验证:最小系统跑起来的完整流程

上板这一步的核心原则是“先把最小链路点亮,再叠加功能”。所谓最小链路就是:时钟进 FPGA,PLL 或 BUFG 驱动逻辑,蜂鸣器响。只要这个链路通了,后面加键盘、加音符只是改逻辑的问题。

我一般按三个阶段推进上板验证。第一阶段,写一个最简单的顶层,直接把分频后的 1Hz 信号接到 LED 上,看灯是不是按照预期频率闪。这一步验证了时钟、复位、约束、烧录整个工具链。第二阶段,把分频系数改成一个音符频率,接到蜂鸣器上,这时候应该能听到“嘟”的声音,但很单调。第三阶段,把键盘扫描和消抖加进去,按不同的键听到不同的音高。

可能有人直接跳过了前两个阶段,代码一写完就烧录,结果蜂鸣器不出声,然后开始怀疑各种模块逻辑。但实测经验是,不出声的绝大多数原因不是逻辑,而是管脚没绑对、蜂鸣器类型选错、或者没有加三极管驱动。所以老老实实分阶段验证,出了错能一下定位到是硬件问题还是逻辑问题,省下的时间远多于多烧录几次的功夫。

还有一个烧录前的检查点:确认开发板上的蜂鸣器是有源还是无源。有源蜂鸣器内部自带振荡器,只要通电就响,用 FPGA 的分频方波去驱动它反而可能不出声或声音很怪。无源蜂鸣器需要外部施加音频信号才能发声,FPGA 的方波直接驱动没有问题。如果板卡上是有源蜂鸣器,需要拆掉蜂鸣器或者把信号接到板子上的音频扩展口,这个细节直接决定上板能不能出声。

4. 仿真与调试:让 FPGA 电子琴快速定位问题

4.1 编写测试平台:仿真里验证音符频率是否正确

写 Verilog 和写软件最大的不同是,硬件逻辑的运行是并行的,时序问题光靠读代码很难发现。所以仿真测试平台是必备工具。它的作用是通过一个模拟时钟驱动被测模块,用波形来验证信号跳变是否符合预期。

下面是一个简单的测试平台,专门验证分频模块输出频率是否正确:

module tb_clk_div; reg clk; reg rst_n; wire clk_out; clk_div_50 #(.DIV_N(2)) u_dut ( .clk(clk), .rst_n(rst_n), .clk_out(clk_out) ); initial begin clk = 0; rst_n = 0; #100; rst_n = 1; #5000; $finish; end always #10 clk = ~clk; // 50MHz 时钟,周期 20ns initial begin $dumpfile("clk_div.vcd"); $dumpvars(0, tb_clk_div); end endmodule

测试平台里我故意把 DIV_N 设成 2,这样输出频率是 25MHz,用仿真器看波形非常直观,不会因为分频系数太大导致仿真时间过长。如果需要验证真实的 261.63Hz,仿真时间要拉长到毫秒级别才能看到一个完整周期,仿真运行时间会明显变长。

测试平台的时钟用always #10 clk = ~clk产生,这里的 10 单位时间没有特殊含义,只是模拟 20ns 的时钟周期。如果要严格模拟 50MHz,应该用always #10加时间单位声明timescale 1ns/1ps,仿真器的精度足够就能对应上。

4.2 波形调试:从仿真波形中识别按键抖动与音符切换问题

仿真不只是看“有没有输出”,还要看输出波形是否符合预期。我总结了一套常用的调试路径。

首先看分频模块的输出,clk_out 的周期应该是输入时钟周期乘以 DIV_N。如果 clk_out 出现毛刺,大概率是组合逻辑直接输出的问题,需要加一级寄存器打拍。其次看按键消抖模块,在仿真里给 key_in 加一段连续的随机翻转,观察 key_out 是不是只在抖动结束后变化。如果 key_out 跟着抖动一起翻转,说明消抖计数器没生效。

最后看整个音符切换过程。在仿真里模拟按下按键 A 再按按键 B,观察蜂鸣器输出是不是先停 A 音再响 B 音,还是两个音叠加了。这个问题的根源在于音符使能信号 key_valid 的处理方式。如果 key_valid 保持高电平直到松开,那么新按键到来时老音符还在响,声音就会重叠。解决方法是把 key_valid 改成单脉冲触发锁存:检测到按键事件后,锁存当前音符并保持,直到检测到松开事件再清零。

这段逻辑很容易写错,我给出一个参考实现:

reg [3:0] cur_key; reg sound_on; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cur_key <= 4'd0; sound_on <= 1'b0; end else if (key_pulse) begin cur_key <= key_code; // 按下瞬间锁存键值 sound_on <= 1'b1; end else if (release_pulse) begin sound_on <= 1'b0; // 松开时停止发声 end end

这种锁存方式保证了音符切换不会重叠,同时让分频模块可以在按键按住期间持续发声。对于电子琴来说,这是最符合直觉的行为模型。

4.3 仿真通过但上板无声:硬件差异排查清单

仿真和上板结果不一致,是 FPGA 开发里最让人头疼的问题。仿真环境是理想化的,上板则涉及实际电气特性。我整理了一个排查清单,按顺序走一遍基本能定位。先查蜂鸣器类型,确认是无源;再查管脚约束是否遗漏,用 Vivado 的 I/O Planning 视图对照板卡原理图逐脚核对;然后查驱动能力,看蜂鸣器引脚是否经过三极管或功放,如果 FPGA 引脚直接驱动电流不足,声音会非常小;再查复位极性,确认开发板的复位按键是高有效还是低有效,用示波器或者逻辑分析仪看 rst_n 引脚电平是否与设计一致;最后查输出信号的频率范围,分频系数算错导致频率落入超声段,人耳听不到,这时用示波器看波形就能发现。

这个清单里的每一项我都踩过。最隐蔽的是复位极性:之前在某块开发板上,复位按键默认接高电平,我的设计是低电平复位,结果上电后所有逻辑都没复位,蜂鸣器完全没反应,查了一下午才发现是复位信号反了。所以拿到不明型号的开发板,第一件事就是查原理图确认复位怎么接。

5. 避坑:FPGA 电子琴常见的六个翻车现场

5.1 按键按下不出声,但仿真一切正常

现象:仿真波形完美,分频输出和 PWM 输出都对,上板后按按键蜂鸣器就是没反应。

原因:管脚约束里 row 和 col 的编号与原理图标注不一致。比如板子上 row0 对应 FPGA 的 B8,但你在 XDC 里把 row[0] 绑到了 B9,按键扫描逻辑永远扫不到正确行列。

解决:用 Vivado 的 schematic 视图或者板卡原理图 PDF,逐个核对 row 和 col 的 FPGA 管脚编号。最保险的办法是写一个测试代码,让 row 输出 0、1、2、3 依次拉低,再用万用表量 col 的电平变化,确认物理连接和逻辑映射一致,再跑键盘扫描功能。

5.2 同一个音符按键,按下瞬间声音断一下

现象:按键按下后,声音不是立刻出来,而是先顿一下,像是被吃了半拍。

原因:消抖模块延时和音符使能信号不同步。消抖确认后 key_valid 才拉高,但分频模块的输出相位是随机的,有可能刚好停在低电平区间,听感上就是声音晚了半拍。

解决:把分频模块的复位脚接一个延时启动信号。在 key_valid 拉高后,先让分频器复位清零,等下一个时钟沿再启动计数,这样音符起始相位是确定的,声音出来的节奏干净。一般在顶层里加一条assign note_rst_n = rst_n & ~key_valid;就能改善。

5.3 多个按键同时按下,输出疯狂乱跳

现象:弹和弦按两个键,声音变成刺耳的乱音,不是两个音叠加。

原因:代码里用 case 语句选音符,当 key_code 在多个有效值之间跳变时,分频模块的输出在瞬间被来回切换,产生了大量毛刺。

解决:加一个优先级编码,规定只有最后按下的键有效,其他按键屏蔽。或者用 OR 逻辑把多个音符的分频输出叠加,但这需要额外设计混音逻辑。课程设计阶段建议用单音方案,把“多键同时按下时按最高音响应”作为规则写进键值编码模块。

5.4 蜂鸣器声音特别小,贴到耳边才能听到

现象:声音有,但音量几乎为零,耳朵凑到蜂鸣器边上才勉强听到。

原因:板上蜂鸣器的驱动电路用了三极管放大,但 FPGA 引脚输出电流不足以驱动三极管进入完全导通状态。也可能是蜂鸣器接到了高阻态引脚,没有配置成推挽输出。

解决:在 XDC 里给蜂鸣器引脚加set_property DRIVE 8和set_property PULLUP false。如果还是小,检查板子是否通过跳线帽选择了蜂鸣器电源。很多开发板有蜂鸣器电源跳线,出厂默认断开。

5.5 分频系数算对了,但音高比真实钢琴低很多

现象:用频率计测输出,数值和理论值一致,但听起来音高不对,明显偏低。

原因:输出的是音符频率的方波,但人耳听感由基频决定,方波里的奇次谐波会干扰音高判断,尤其当谐波幅度和基波接近时,大脑会把谐波权重放大。

解决:改用 PWM 输出或者加一个简单的低通滤波器,把 10kHz 以上的高频成分滤掉。最简单的低通滤波器就是蜂鸣器本身,选合适容量的电容并联到蜂鸣器两端,能明显改变音色,让基频更突出。

5.6 烧录后蜂鸣器一直响,无论按不按按键

现象:上电后还没碰按键,蜂鸣器就一直有一种持续的低频声音。

原因:按键扫描模块在没有任何按键按下时,key_valid 处于悬空或者不定状态,被识别成了有效按键。

解决:给键盘扫描模块的输出加一个按键按下检测保护,只有当至少一个 row 和至少一个 col 交叉导通时,key_valid 才允许拉高。在 Verilog 里可以这么写:

wire any_row_low = ~(row[0] & row[1] & row[2] & row[3]); wire any_col_low = ~(col[0] & col[1] & col[2] & col[3]); wire key_detected = any_row_low & any_col_low;

像这样用两个缩减与操作保证按键信号真实有效,能避免悬浮态的干扰。

6. 进阶技巧:用 PWM 占空比调制做音量渐弱,让声音更像真实钢琴

到这里,基础电子琴已经能出声了,但你会发现所有音符的音量都一样,弹起来非常机械。真实钢琴的声音特点是一开始强,然后在几百毫秒到几秒内逐渐减弱,这就是所谓的音量包络。FPGA 里实现音量渐弱并不难,思路就是让 PWM 占空比随时间从大变小,用一个缓慢递减的占空比控制信号去调制 PWM。这个技巧能让你的设计直接拉开和普通课程设计的差距。

具体实现是加一个衰减计数器:当音符触发时,计数器重置为初始占空比,比如 70%;然后每个固定时间间隔减一点,直到降到 10% 以下。把计数器的值传给 PWM 模块的 DUTY 端口,就实现了自然的音量衰减。我一般用每 10ms 递减 5% 的速率,钢琴音大约在 1.5 秒内从最大音量衰减到几乎听不见。下面的模块实现了这个功能:

module envelope #( parameter CLK_FREQ = 50_000_000, parameter DECAY_INTERVAL = 10_000_000, // 10ms 衰减一次 parameter DUTY_INIT = 70, parameter DUTY_MIN = 10 )( input wire clk, input wire rst_n, input wire note_on, output reg [6:0] duty ); reg [31:0] timer; reg [6:0] cur_duty; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cur_duty <= DUTY_INIT; timer <= 0; end else if (note_on) begin if (timer >= DECAY_INTERVAL) begin timer <= 0; if (cur_duty > DUTY_MIN) cur_duty <= cur_duty - 1; end else begin timer <= timer + 1; end end else begin // 音符结束,占空比重置为初始值 cur_duty <= DUTY_INIT; timer <= 0; end end always @(posedge clk or negedge rst_n) begin if (!rst_n) duty <= DUTY_INIT; else if (note_on) duty <= cur_duty; else duty <= DUTY_MIN; end endmodule

把duty接到 PWM 模块的 DUTY 接口上,你会发现按键按下瞬间声音洪亮,然后逐渐变轻直到消失,这个动态过程让 FPGA 电子琴的听感提升非常明显。DECAY_INTERVAL 和 DUTY_MIN 两个参数可以根据自己的喜好调整,衰减快了声音短促像拨弦,衰减慢了像管风琴,非常好玩。

如果想让音色更进一步,可以试试 DDS 查表法生成正弦波,然后把正弦波作为 PWM 调制的输入。FPGA 里内置的 Block ROM 存一个周期的正弦波采样点,查表地址用音符频率做增量累加,输出的就是频率可调的正弦波,再经过 PWM 或者直接经过简单 RC 滤波,基本能模拟出比较干净的琴音。DDS 查表法比 PWM 直接产生音符频率方波复杂一些,但资源开销很小,入门级 FPGA 也能轻松跑起来。

我建议做这个方向的从业者,走完上面的基础链路后,再花时间自己实现一次 DDS 方案。我就是从那个阶段开始真正理解硬件并行逻辑的:两个模块各自维护自己的计数器,又通过数据总线和使能信号协同工作,这种“并行各自跑、同步靠握手”的思维方式,是写软件的人很难单靠看书体会到的。希望这些在开发板和仿真工具里试出来的经验能帮你少走一些弯路,祝你的 FPGA 电子琴一次出声。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进