一、电子琴的工作原理和框图
物体震动产生声音,给无源蜂鸣器通入一定频率的方波信号,或者给扬声器通入一个波动的电信号,都可以发出声音。通入多少频率的电信号,发声装置就能发出多少频率的声音。信号的产生由FPGA来完成。FPGA还接有若干按键,其中13枚控制音调,2个进行音域拓展,1个用于切换扬声器/蜂鸣器播放,一个用于开启自动播放。
本项目基于硬禾的小脚丫FPGA和电子琴拓展板来完成,系统的硬件结构框图表示为:
系统的软件结构框图为:
二、蜂鸣器和模拟喇叭的差别
1. 蜂鸣器是一种一体化的电子讯响器,采用直流电压供电,有压电式蜂鸣器和电磁式蜂鸣器两种类型,它们又各有两种结构:有源型和无源型。这里,源指的是振荡源,有源蜂鸣器只需通入直流信号即可发声,而无源蜂鸣器则需要通入一定频率的方波信号才能发声。
2. 扬声器也是一种换能元件,俗称喇叭,有电动式,压电式,舌簧式几类。扬声器需要交流信号来进行驱动,交流信号的频谱对应发声的频谱。
三、用模拟蜂鸣器和模拟喇叭的实现方法以及音效差别分析
针对蜂鸣器,我们只需使用一个三极管即可通过IO口进行控制,希望得到多少频率的声音,就要给蜂鸣器通入多少频率的方波信号。蜂鸣器发出的声音是方波信号的声音。
针对扬声器,需要用FPGA生成PWM波,然后滤波后得到交流音频信号。这样的到的音频信号理论上可以得到扬声器频率范围内的任意频谱,
四、模拟放大电路的仿真及分析
FPGA的IO口输出的PWM波,首先经过R16,R17,C3构成的低通滤波器,滤除高频的噪声,然后进入音频放大环节,音频放大环节有一个带通的有源滤波器,可以放大一定频率的信号。8002B是一颗专用的音频放大IC,可以将信号减去二分之一电源电压后输出(相当于去除直流分量)。经过电脑仿真,在20-20kHz频段(即人耳可以听到的频率范围内)的增益和相位如图所示。可以看出在200Hz以下的声音增益要远小于中频增益。
五、主要代码片段及说明
pwm生成
PWM波生成模块借鉴了硬禾的程序,使用一个累加器,将一个脉冲分散在一个周期内,从而充分利用了时钟频率,提高了方波的频率,为之后滤波提供便利。这里我们采用的PWM精度为7位。精度的选择主要考虑了FPGA的时钟频率(12MHz),预期的发声频率范围(130-1050Hz),平衡PWM精度和
module pwm (
clk,rst_n,pwm_in,pwm_out
);
input clk;
input rst_n;
input [6:0] pwm_in;
output pwm_out;
reg [7:0] cnt;
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin
cnt <= 0;
end
else cnt <= cnt[6:0] + pwm_in;
end
assign pwm_out = cnt[7];
endmodule
自动播放模块-autoplay
自动播放模块使用了软件提供的ROM ip核来实现。
分频器模块
参考了硬禾的例程,主要为自动播放模块提供一个频率更低的时钟。相比简单的累加器分频,可以实现精准的奇数分频。
module divide(clk, rst_n, clk_out);
input clk;
input rst_n;
output clk_out;
parameter WIDTH = 16;
parameter N = 40000;
reg [WIDTH:0] cnt_n,cnt_p;
reg clk_p,clk_n;
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin
cnt_p <=0;
end
else if(cnt_p==(N-1))begin
cnt_p <= 0;
end
else begin
cnt_p <= cnt_p + 1;
end
end
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
clk_p <= 0;
end
else if (cnt_p>(N>>1)) begin
clk_p <= 1;
end
else begin
clk_p <= 0;
end
end
always @(negedge clk or negedge rst_n) begin
if(!rst_n)begin
cnt_n <= 0;
end
else if(cnt_n==(N-1))begin
cnt_n <= 0;
end
else begin
cnt_n <= cnt_n + 1;
end
end
always @(negedge clk or negedge rst_n) begin
if (!rst_n) begin
clk_n <= 0;
end
else if(cnt_n>(N>>1))begin
clk_n <= 1;
end
else begin
clk_n <= 0;
end
end
assign clk_out = (N==1)?clk:(N[0]?(clk_p|clk_n):clk_p);
endmodule
主要功能模块
波表的生成使用c语言生成带有谐波的波形信号。查找资料得到了小提琴的各谐波,然后累加各个正弦波信号得到类似小提琴的声音。在实际测试过程中听感与小提琴具有一定类似程度,但未设计包络,因此声音幅值缺乏变化,同时实际的乐器各个音调的各个谐波占比并不相同,即使是同一音调,在发声初和后期的谐波占比也有所不同,这些变化仍有待实现。
代码中可以看出,我在这刻意加强了二次、三次和五次谐波的强度,这将在频谱图中有所体现。
#include<cmath>
#include<stdio.h>
#include<iostream>
#include<stdlib.h>
using namespace std;
#define STEP 3.1415926*2/128
double am[10]={1.0, 0.286699025, 0.150079537, 0.042909002,
0.203797365};
double temp[200];
int main(){
freopen("out4.mem","w",stdout);
char string[33];
char dest[33];
double max=-1;
for(int i=0;i<128;i++){
for(int j=0;j<=4;j++){
temp[i]+=sin((STEP*i*(j+1)))*am[j];
}
max = max<temp[i]?temp[i]:max;
}
for(int i=0;i<128;i++){
itoa((int)(temp[i]/max*63)+64, string, 2);
sprintf(dest,"%7s", string);
for(int i=0;i<=6;i++){
if(dest[i]==' ')dest[i]='0';
}
printf("7'b%s,\n",dest);
}
fclose(stdout);
}
代码中设计了四次谐波,使用软件phyphox观察发声的频谱如下,在频谱中可以清晰地看见设置的基波和共计4个谐波,其中二三五次谐波较强,同程序中的设置相符。基波的强度反而不如谐波的强度,可能与上文分析到的低频信号增益较低有关。
主要基于system verilog的parameter数组特性来构建。在之前的尝试过程中,曾经尝试使用ROM ip来存储波表,考虑到和弦的需求,需要为每一个按键实例化一个module,共计37个,但是ebr只有十个,不足以实现如此多的ROM,因此尝试使用parameter数组。
parameter [6:0] ARRAY [127:0] = {
7'b1000000,
7'b1001001,
7'b1010010,
7'b1011010,
7'b1100010,
7'b1101001,
//这里省略中间部分的波表,具体内容见附件工程文档中的代码
7'b0110111
};
parameter [9:0] FREQ [36:0] = {
10'd716,
10'd676,
10'd638,
10'd602,
10'd568,
10'd536,
10'd506,
10'd478,
10'd451,
10'd426,
10'd402,
10'd379,
10'd358,
10'd338,
10'd319,
10'd301,
10'd284,
10'd268,
10'd253,
10'd239,
10'd225,
10'd213,
10'd201,
10'd189,
10'd179,
10'd169,
10'd159,
10'd150,
10'd142,
10'd134,
10'd126,
10'd119,
10'd112,
10'd106,
10'd100,
10'd94,
10'd89
};//各个音调的定时器参数
具体播放时,使用37组如下代码完成音调的生成:
wire clk_out [36:0];
reg [6:0] b [36:0];
reg [9:0] pwm_in_buf;
reg [9:0] cnt [36:0];
reg [7:0] idn [36:0];
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin
cnt[0] <= 0;
idn[0] <= 0;
end
else if(cnt[0] == FREQ[0]) begin
cnt[0] <= 0;
idn[0] <= idn[0] + 1;
b[0] <= ARRAY[idn[0]];
end
else cnt[0] <= cnt[0] + 1;
end
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin
cnt[1] <= 0;
idn[1] <= 0;
end
else if(cnt[1] == FREQ[1]) begin
cnt[1] <= 0;
idn[1] <= idn[1] + 1;
b[1] <= ARRAY[idn[1]];
end
else cnt[1] <= cnt[1] + 1;
end
//此后省略其他音调的生成代码,具体查看附件工程文件中的代码
自动播放
相关的代码如下,在乐曲的最后加入一个零音调,初始化和播放完成后都停止在这个最后的音调,触发播放之后,乐曲从头播放,最终也会在最后一个音调停下来。pnt是乐谱索引,key_auto是输出的音调。这里自动播放和手动弹奏输出的音调进行与运算,因此可以同时进行。这里我加载了三首音乐,可以通过按键进行切换。
wire pulse;
reg [12:0] pnt;
wire [36:0] key_auto;
wire [36:0] key_syn;
divide #(.N(1500000)) w1(.clk(clk),.rst_n(rst_n),.clk_out(pulse));
always @(posedge pulse or negedge rst_n or negedge auto[0] or negedge auto[1] or negedge auto[2]) begin
if (!rst_n) begin
pnt <= 13'd0;
end
else if(!auto[0])begin
pnt <= 1;
end
else if(!auto[1])begin
pnt <= 13'd160;
end
else if(!auto[2])begin
pnt <= 13'd993;
end
else if(pnt == 13'd0)begin
pnt <= 13'd0;
end
else if(pnt == 13'd159)begin
pnt <= 13'd159;
end
else if(pnt == 13'd992)begin
pnt <= 13'd992;
end
else if(pnt == 13'd1736)begin
pnt <= 13'd1736;
end
else pnt <= pnt + 1;
end
autoplay r1(.Address(pnt),.OutClock(clk),.OutClockEn(1'b1),.Reset(1'b0),.Q(key_auto));
按键滤波模块
参考了硬禾的例程,在检测到按键按下之后,触发一个计时器,定时获取按键状态,若按键和之前一段时间的按键状态均为按下,则认为按键确实按下,从而实现了按键的滤波。
module debounce (
clk,rst_n,key,key_out
);
parameter N = 2;
input clk;
input rst_n;
input [N-1:0] key;
output [N-1:0] key_out;
wire [N-1:0] key_edge;
reg [N-1:0] key_rst_pre;
reg [N-1:0] key_rst;
reg [N-1:0] key_hes_pre;
reg [N-1:0] key_hes;
//update key_rst_pre and key_rst
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin
key_rst <= {N{1'b1}};
key_rst_pre <= {N{1'b1}};
end
else begin
key_rst <= key;
key_rst_pre <= key_rst;
end
end
//get key_edge, key_edge give a pulse when key state changes
assign key_edge = key_rst_pre&(~key_rst);
//start to count when a key_edge comes
reg [17:0] cnt;
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin
cnt <= 0;
end
else if(key_edge)begin
cnt <= 0;
end
else if(cnt == 18'd240001)begin
cnt <= 0;
end
else begin
cnt <= cnt + 1;
end
end
//update key_hes and key_hes_pre
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin
key_hes <= {N{1'b1}};
key_hes_pre <= {N{1'b1}};
end
else if (cnt == 18'd240000) begin
key_hes <= key;
key_hes_pre <= key_hes;
end
end
//get key_out from key_hes and key_hes_pre
assign key_out = key_hes&key_hes_pre;
endmodule
顶层模块
完成按键的滤波功能、音域的拓展功能(其实就是把13个按键映射到37个音调上去),以及扬声器和蜂鸣器的切换功能:
module transform(
clk, rst_n, sw, tr, key, auto,speaker, buzzer
);
input clk;
input rst_n;
input sw;
input [1:0] tr;
input [12:0] key;
input [2:0] auto;
wire speaker_buf;
wire buzzer_buf;
output speaker;
output buzzer;
wire [12:0] key_out;
wire [2:0] auto_out;
debounce #(.N(13)) de(.clk(clk),.rst_n(rst_n),.key(key),.key_out(key_out));
debounce #(.N(3)) de1(.clk(clk),.rst_n(rst_n),.key(auto),.key_out(auto_out));
wire [36:0] key_trans;
assign key_trans[11:0] = key_out[11:0]|{12{~tr[0]}}|{12{tr[1]}};
assign key_trans[12] = {key_out[12]|{~tr[0]}|tr[1]}&{key_out[0]|{~tr[0]}|{~tr[1]}};
assign key_trans[23:13] = key_out[11:1]|{11{~tr[0]}}|{11{~tr[1]}};
assign key_trans[24] = {key_out[12]|{~tr[0]}|{~tr[1]}}&{key_out[0]|tr[0]|{~tr[1]}};
assign key_trans[36:25] = key_out[12:1]|{12{tr[0]}}|{12{~tr[1]}};
svtest u1(
.clk(clk),
.rst_n(rst_n),
.key(key_trans),
.auto(auto_out),
.speaker(speaker_buf),
.buzzer(buzzer_buf)
);
assign buzzer = buzzer_buf&sw;
assign speaker = speaker_buf&(~sw);
endmodule
六、改进建议
1. 更换按键声音更小的按键。当前在弹奏时按键会发出较大声音,影响听感。
2. 之后我会尝试加入包络功能。现在每个音调按下之后发声的幅度是恒定的,而诸如钢琴、吉他等乐器发出的声音是减弱的,在东方红卫星上的播放器上也同样有一个5Hz的低频载波,这些可以令声音听起来更加动听。
七、主要困难和解决方法
在开发过程中,基础模块因为功能简单,加之硬禾提供了大量的例程以供参考,所以开发起来非常顺利。但是将各个模块综合起来较为考验优化能力。在2022年春的FPGA项目中,ICE40UP5K的资源相对更丰富一些,开发的功能也更简单,所以没有体会到优化的重要性。但是在暑假在家一起练中,多次遇到了ebr不足和LUT不足的情况。
1.为每一个音调甚至是每一个按键声明一个ROM module的尝试是不现实的,STEP-MXO2-C只拥有10个ebr,所以改用LUT来保存使用的波表。具体是使用了system verilog的parameter参数数组功能。他可以帮助开发者更方便地使用LUT实现类似数组的功能。代价是要用掉相当多的LUT资源。
2.我保存乐谱的方法是使用一个37位的01串来表示某个音符是否发声,然后依次保存下每四分之一个节拍的发声的音符,我选择的乐曲这样保存下来将占用67,743 字节的空间。如果同样使用LUT资源来实现,将大大超出可使用的LUT资源数量。后来我该用了fpga的ROM来保存乐谱。解决了这个问题。
3.如何将midi文件转化成上述的乐谱文件:我曾尝试寻找解析midi文件的c,c++或者Python库,但是解析得到的数据往往需要经过繁复的处理才能转化成我需要的乐谱(主要工作包括将相对的时间转化成绝对的时间,然后吸附到节拍上),后来参考了群内大佬的解决方案,在网站onlinesequencer中完成了上述工作,同时借用了大佬的Python翻译工具,对其进行修改(主要是修改了音域,对两个相同音符之间添加了换气的停顿,修改了大小端序),实现了将midi文件翻译为可播放的乐谱的功能。
八、参考资料
在开发过程中,除了硬禾的资料外,许多博客、百科也给了我很多帮助,在这里一并列出,希望对其他进行类似开发的同学也能有所帮助。
九、资源使用
Design Summary
Number of registers: 1218 out of 4635 (26%)
PFU registers: 1202 out of 4320 (28%)
PIO registers: 16 out of 315 (5%)
Number of SLICEs: 2081 out of 2160 (96%)
SLICEs as Logic/ROM: 2081 out of 2160 (96%)
SLICEs as RAM: 0 out of 1620 (0%)
SLICEs as Carry: 551 out of 2160 (26%)
Number of LUT4s: 4080 out of 4320 (94%)
Number used as logic LUTs: 2978
Number used as distributed RAM: 0
Number used as ripple logic: 1102
Number used as shift registers: 0
Number of PIO sites used: 23 + 4(JTAG) out of 105 (26%)
Number of block RAMs: 10 out of 10 (100%)
Number of GSRs: 1 out of 1 (100%)
EFB used : No
JTAG used : No
Readback used : No
Oscillator used : No
Startup used : No
POR : On
Bandgap : On
Number of Power Controller: 0 out of 1 (0%)
Number of Dynamic Bank Controller (BCINRD): 0 out of 6 (0%)
Number of Dynamic Bank Controller (BCLVDSO): 0 out of 1 (0%)
Number of DCCA: 0 out of 8 (0%)
Number of DCMA: 0 out of 2 (0%)
Number of PLLs: 0 out of 2 (0%)
Number of DQSDLLs: 0 out of 2 (0%)
Number of CLKDIVC: 0 out of 4 (0%)
Number of ECLKSYNCA: 0 out of 4 (0%)
Number of ECLKBRIDGECS: 0 out of 2 (0%)