基于FPGA的电子琴设计
完成者:东南边的纸蓝
日期:2022年8月29日
一、项目简介
该项目为硬禾学堂2022暑期训练之一。项目要求使用小脚丫FPGA + 电子琴扩展板,编程实现电子琴的功能。具体要求请参考项目发布详情页面。
二、资源使用情况及完成的功能
项目资源使用情况
完成的功能总结
- 通过piano kit拓展板子(下称拓展板)上的琴键驱动扬声器和蜂鸣器正确发声,并通过右侧按键对音阶进行扩展
- 通过小脚丫FPGA核心板(Lattice MXO2-C)板上的数码管显示此时的音阶
- 通过小脚丫FPGA核心板(Lattice MXO2-C)(下称核心板)上的按键驱动扬声器或蜂鸣器播放一段音乐
具体情况请参考演示视频。
三、电子琴的工作原理
通过核心板读取按键信息,并针对信息按照对应的方式驱动蜂鸣器或扬声器产生对应的音频。
其中需要注意的是,驱动扬声器的电路仅由一阶RC低通滤波电路和音频功放组成,故需要针对一阶RC低通滤波电路设计对应的驱动方式,具体驱动方式在下文会进行详细地描述。
四、蜂鸣器和扬声器的差别&蜂鸣器和扬声器的驱动方法差别以及音效差别分析
piano kit拓展版的蜂鸣器为无源蜂鸣器,故以下内容讨论的蜂鸣器皆为无源蜂鸣器。
差别讨论
简单来说,蜂鸣器音色单一,只能播放简单音频。而扬声器音色丰富,能够播放各种波形。从器件特性上说,个人认为无源蜂鸣器和扬声器最大的差别在于阻抗大小和频率响应不同。
蜂鸣器的直流电阻接近无限大,交流阻抗也很大,为窄带发声器件。我找到了板上蜂鸣器的数据手册,可以明显看到,该蜂鸣器线圈阻值为15±3Ω,带宽较小。
扬声器则是低阻,直流电阻几乎是零,交流阻抗一般为几欧姆。很不幸的是我翻遍了各大数据手册网站均未找到板上使用的扬声器的数据手册,淘宝倒是有卖这个扬声器但是也没有数据手册,无法与蜂鸣器做横向数据对比。但一般来说,扬声器的阻值仅为4Ω或8Ω,且带宽较大。
驱动方法
由拓展板原理图可知,仅由三极管驱动蜂鸣器,故施加给蜂鸣器的波形可近似为方波,且仅为方波。仅需改变方波频率即可驱动蜂鸣器发出不同的音调。
驱动扬声器的方法可参考此处,接下来我将通过使用iVerilog、Multisim和Simulink对该方法进行可行性的验证。以下是链接页面中的参考代码。
module PWM(clk, PWM_in, PWM_out);
input clk;
input [7:0] PWM_in;//输入的数值
output PWM_out;//输出端口
reg [8:0] PWM_accumulator;
always @(posedge clk) PWM_accumulator <= PWM_accumulator[7:0] + PWM_in;
assign PWM_out = PWM_accumulator[8];
endmodule
对该模块进行仿真可得以下结果,由图易得,输入的值越高,累加器溢出越快。
我们可以近似地将输出的pwm波形(即累加器的溢出位)看成占空比不同的方波。那么方波的频率该如何选择呢?我们暂且忽略音频功放部分,只考虑滤波电路,对该电路进行频率响应分析可得下图,找到对应的-3dB点[注1],为(20.9kHz, -15dB),得该低通滤波器的带宽约为20kHz。
我又使用simulink设计了一个实验,即通过产生输入波形,将波形幅值映射为PWM的占空比,使PWM信号通过一阶RC低通滤波后得到输出的波形,并将输入波形和输出波形进行对比,能够很好的证明该方法的可行性。
实验结果如下图所示,我对比了PWM频率为20kHz和12Mhz时的输出波形,结果20kHz时的输出波形与输入波形差异极大,若直接采用核心板上12Mhz的晶振做PWM频率得到的输出波形效果更佳,故最终我的代码中驱动该部分模块的时钟信号为12Mhz。那仿真出来的20kHz有什么用啊喂
该工程文件我会打包放在附件中,我simulink用的不是很熟,请熟悉这方面的大佬请多多指点我一下,如有问题也欢迎讨论。
音效差别分析
说实话我不知道在这里写啥,因为驱动蜂鸣器和扬声器的电路都不一样,那施加的波形肯定也不一样,不一样的波形音频听起来肯定不一样,这比个啥啊。对比不同的波形的音频可以去这里听差别。
硬要说的话,我只能说蜂鸣器的声音听起来很刺耳,且没接分压器所以音量不可调。扬声器的听起来更加柔和。
可能有人说蜂鸣器只能发出方波的声音,扬声器可以发出任意波形,说实话我不赞同这个观点。因为我自己私下里试过把一块功放上的喇叭换成无源蜂鸣器,虽然音频炸耳朵,但是是能听出来人声的,这就说明蜂鸣器在是有发出任意波形的能力的,只不过能力非常有限,且效果很烂。
五、模拟放大电路的仿真及分析
我找不到板上使用的芯片的仿真模型所以没做,但音频功放理论上就是个放大器,外围电路直接参考厂家给的范本就行,我查了一下这枚音频功放在20Hz到20KHz范围内的THD只有0.25%,那驱动几个100Hz到1kHz的单音肯定是够的,不仿这部分应该问题也不大吧……
六、主要代码片段及说明
先上顶层文件的RTL网表图,图如下所示。其中pll_24M为PLL,输出24Mhz的时钟;music为音频播放模块;buzzer为蜂鸣器驱动模块;speaker为扬声器驱动模块;segment为数码管驱动模块。
speaker模块
speaker模块中包含四类模块和一个时钟选择逻辑块,四类模块的功能分别是时钟分频模块,波形发生模块,和弦模块,按键消抖模块。
波形发生模块代码和和弦模块代码如下,简单来说就是计数,然后查找costable表,然后递给和弦模块来累加,和弦模块根据输入的按键数量来决定累加类型。更改发生波形时仅需要修改costable表即可。
module sin2output(
input clk,
input en,
input[8:0] para,
output[7:0] out
);
reg[8:0] cnt_T;
reg[6:0] cnt_theta;
wire[7:0] w_cosines;
costable m_cos(
.clk(clk),
.en(~en),
.theta(cnt_theta),
.cos_out(w_cosines)
);
always@(posedge clk)begin
if(cnt_T == para)begin
cnt_theta <= cnt_theta+1'b1;
cnt_T <= 9'd0;
end
else begin
cnt_theta <= cnt_theta;
cnt_T <= cnt_T + 1'b1;
end
end
//always@(posedge clk) outs <= outs[7:0] + w_cosines;
assign out = w_cosines;
endmodule
module Chords(
input clk,
input[12:0] en_in,
input en_sw,
input[7:0] data0,
input[7:0] data1,
input[7:0] data2,
input[7:0] data3,
input[7:0] data4,
input[7:0] data5,
input[7:0] data6,
input[7:0] data7,
input[7:0] data8,
input[7:0] data9,
input[7:0] data10,
input[7:0] data11,
input[7:0] data12,
output reg out
);
wire[3:0] num_in;
reg[9:0] data_all;
assign num_in = en_in[0]+en_in[1]+en_in[2]+en_in[3]+en_in[4]+en_in[5]+en_in[6]+en_in[7]+en_in[8]+en_in[9]+en_in[10]+en_in[11]+en_in[12];
always@(posedge clk) begin
if (num_in==4'd0)begin
data_all <= 10'd0;
end
if (num_in == 4'd1)begin
data_all <= data_all[7:0]+data0+data1+data2+data3+data4+data5+data6+data7+data8+data9+data10+data11+data12;
end
else begin
data_all <= data_all[8:0]+data0+data1+data2+data3+data4+data5+data6+data7+data8+data9+data10+data11+data12;
end
end
always@(posedge clk)begin
if(~en_sw)begin
out <= 1'b0;
end
else if (num_in==4'd0)begin
out <= 1'b0;
end
else if (num_in==4'd1)begin
out<=data_all[8];
end
else if (num_in==4'd2)begin
out<=data_all[9];
end
else begin
out <= 1'b0;
end
end
//assign out = data_all[9];
endmodule
扩充音域只需要修改时钟信号的频率即可,实现代码如下:
assign clk_use = clk_use1|clk_use2|clk_use3;
always@(posedge key_out[0] or posedge key_out[1])begin
if (key_out[1])begin
s_tone<=s_tone+1'b1;
end
if(key_out[0])begin
s_tone<=s_tone>>1;
end
case(s_tone)
2'b00:begin num_out<=4'd5; clk_ens<=3'b011;end
2'b01:begin num_out<=4'd4; clk_ens<=3'b101;end
2'b10:begin num_out<=4'd3; clk_ens<=3'b110;end
2'b11:begin num_out<=4'd5; clk_ens<=3'b011;end
endcase
end
debounce m_key(clk,1'b1,~tone,key_out);
gen_divd #(.divdWIDTH(24),.divdFACTOR(4))
m_clk_1 (.reset(clk_ens[0]), .clkin(clk), .clkout(clk_use3));
gen_divd #(.divdWIDTH(24),.divdFACTOR(2))
m_clk_2 (.reset(clk_ens[1]), .clkin(clk), .clkout(clk_use2));
gen_divd #(.divdWIDTH(24),.divdFACTOR(1))
m_clk_3 (.reset(clk_ens[2]), .clkin(clk), .clkout(clk_use1));
Buzzer模块
驱动蜂鸣器就简单了,拿几个分频器一顿分就完事了。分频器代码如下:
//偶数分频器,外部带入两个参数决定分频系数。
//divdFACTOR--分频系数,实际分频数为divdFACTOR*2
//divdWIDTH--分频计数器的位宽,实际位宽为divdWIDTH+1,该位宽所能表达的最大值>divdFACTOR
//12MHz的时钟
//out = in / divdFACTOR / 2
module gen_divd
#(
parameter divdWIDTH = 24,
parameter divdFACTOR = 6000000
)
(
input reset,
input clkin,
output reg clkout
);
reg [divdWIDTH:0] cnt;
always @ (posedge reset or posedge clkin)
if(reset) begin
cnt<=0;
clkout<=0;
end
else begin
cnt<=cnt+1'b1;
if(cnt==(divdFACTOR-1)) begin
cnt<=0;
clkout<=~clkout;
end
end
endmodule
music模块
这个模块就纯纯打表,我觉得没啥好讲的,具体请看FPGA工程文件里的music.v。
segment模块
用于显示此时的音域,上电默认第一个白键为C5,数码管显示5。我也觉得没啥好讲的,具体请看FPGA工程文件里的segment.v。
七、遇到的主要难题及解决方法
遇到的难题主要还是关于一位DAC,说实话在做这个项目前我完全没接触过这个方法,之前都是用DAC芯片做的DDS啥的。在了解这个方法后我对这个方法的可行性表示怀疑,故我花了一定的时间进行验证,验证结果也放在上面了。光是验证这个其实花了我至少两周去研究,其过程可以简要划分成以下几个问题
- 链接里面的参考代码输出的波形长啥样?
- 我要怎么把输出的波形塞给低通滤波器?
- 如何转换成Verilog代码?
第一个问题其实很简单,用modlesim仿真一下就出来了,然后diamond软件自动生成的仿真文件有问题无法出波形,我也不知道为什么,报的错是显示某个文件读取错误,我打开一看感觉确实像是少了个中括号,补上中括号这个文件就能正常读取,结果再跑一遍中括号就又没了。就很神秘,我也没找到为什么那个文件写入会出问题,就当作是diamond的bug吧,然后就换去用iVerilog仿真了。个人感觉iVerilog用起来不仅比modlesim友好,仿真速度还快,有兴趣的可以去试一试。我在上传的文件里面有放一个bat文件,可一键执行编译仿真和波形展示,当然别忘了先装好iVerilog
第二个问题就很要命,我熟悉的仿真软件Multisim似乎没法自定义输入波形,LTspice也不太会用。这学期正好学了Matlab,就想着用这个来仿,练练手,啊结果在这个上面花了非常多的时间。虽然感觉很花时间但整体来说感觉挺有意思的,最终验证出来的结果也和我实际去调出来的结果基本一致,验证了一位DAC的可行性,找到了合适的PWM频率。
第三个问题其实也挺简单的,毕竟有个范例,照着改谁不会啊。然后还好这个暑假我都待在学校,有示波器辅助调起来非常快。顺带一提,比起仿真,我更喜欢直接看RTL网表图,我感觉这个能很直观地告诉我我写了什么东西,这个东西是什么样子的,哪有问题一眼就能看得出来。
最后其实还有个问题,那就是如何快速的生成部分Verilog语句,比如我costable里面的查找表和例化多个模块,一个个复制挺麻烦的,我借用了另一门语言,python,用来简化工作量非常方便。举个例子,请看以下代码:
def costable():
import math
import matplotlib.pyplot as plt
times = 10000
f = 128
beginstr = """
module costable(
input clk,
input en,
input[6:0] theta,
output reg[7:0] cos_out
);
always@(posedge clk)begin
if (en)begin
case(theta)
""" # 头
endstr = """
default:cos_out <= 8'd80;
endcase
end
else begin
cos_out <= 8'd0;
end
end
endmodule
""" # 尾
end = 2 * int(math.pi * times)
step = int(end / f) + 1
x = [i for i in range(0, end, step)]
# print(len(x))
x_ = [i for i in range(len(x))] # 确定时基
# y = [math.sin(i / times) + 0.34 * math.sin(i / times * 2) + 0.10 * math.sin(i / times * 3) + 1.44 for i in x]
y = [math.sin(i / times) + 0.34 * math.sin(i / times * 2) + 1.34 for i in x] # 生成波形
y_ = [int(127 * i / 2) for i in y] # 8位精度
plt.step(x_, y_)
plt.plot(x_, y_)
# 生成case部分语句
print(beginstr)
for i in range(len(x_)):
print(f"\t\t\t7'd{x_[i]} : cos_out <= 8'd{y_[i]};")
# print("\t\t\tdefault:cos_out <= 8'd0;")
print(endstr)
plt.show() # 展示生成的波形
costable()
其实还可以写的更方便,比如直接将生成的内容写入文件,但我懒得改了,总之用起来很方便就对了。当然,其他编程语言也能实现,只是我个人习惯用python罢了。
八、本项目改进建议
我个人觉得我写的还是有非常大的改进空间的,比如:
- 和弦模块和波形发生器那边,完全可以优化一下减少波形发生器的数量。但我懒得改了
- 增加更多能播放的方法。但我懒得加了
- 改进播放音乐的方法,比如支持wav和mid文件的播放。这个其实我有去研究,但时间不太够了就没继续看了
- 增加RGB炫彩亮灯。
总之,这个项目对我来说就算搞一个段落了,之后的事情有缘再说吧。
注1:我猜有人可能会好奇为什么明明是-3dB点却取的是-15dB,这是因为在低频或直流情况下,可近似忽略电容对电路的影响,此时该滤波器仅为分压器,增益为0.25,换算成声贝就是-12dB。那对于此电路的-3dB点就应该取-12-3=-15dB点。下图为原理图。