1 任务要求
1. 基于提供的套件和工具,自己组装电子琴
2. 自己编程基于FPGA实现:
(1) 存储一段音乐,并可以进行音乐播放
(2) 可以通过板上的按键进行弹奏,支持两个按键同时按下(和弦)且声音不能失真,板上的按键只有13个,可以通过"上","下"两个按键对音程进行扩展
(3) 使用扬声器进行播放时,输出的音调信号除了对应于该音调的单频正弦波外,还必须包含至少一个谐波分量
(4) 音乐的播放支持两种方式,这两种方式可以通过开关进行切换:
i. 当开关切换到蜂鸣器端,可以通过蜂鸣器来进行音乐播放
ii. 当开关切换到扬声器端,可以通过模拟扬声器来进行音乐播放,每个音符都必须包含基频 + 至少一个谐波分量
2 蜂鸣器和模拟喇叭的区别分析
1. 驱动方式区别
无源蜂鸣器是由变化的电平驱动的,即是由数字信号驱动的,我们可以用不同频率的方波驱动无源蜂鸣器产生不同频率的声音信号。FPGA或单片机的GPIO口驱动能力弱,不能直接驱动无源蜂鸣器,一般将一个三极管或MOSFET与GPIO相连,使其作为开关管来控制蜂鸣器两引脚间的电平,从而驱动蜂鸣器发声。
而喇叭是由模拟信号驱动的,不同频率、不同波形、不同幅度的模拟信号可以驱动喇叭发出不同音调、不同音色和不同音量的声音。FPGA或单片机的GPIO产生的电流不足以驱动喇叭发声,需要使用功率放大器将产生的音频信号进行功率放大,以驱动喇叭发声。
2. 音效差别
无源蜂鸣器发出声音的音色是固定的,而喇叭在谐波成分不同的音频信号的驱动下可以发出不同音色的声音。
3 电子琴的工作原理和结构图
电子琴的工作主要可以概括为两个方面:更新当前时刻应该播放的音符和根据目标音符驱动播放器发声。
在手动弹奏模式下,音符更新通过按键检测实现:检测升八度键和降八度键以确定音符在第几个八度,扫描13个电子琴按键以确定弹奏的是哪些音符。在自动演奏模式下,音符更新通过以固定频率从乐谱ROM中取出新的音符实现。
当选择用蜂鸣器播放音乐时,FPGA需要输出频率为目标声音频率的方波,以驱动蜂鸣器发声。当选择用扬声器播放音乐时,FPGA需要通过DDS的方式生成频率为目标声音频率的正弦波或带有高次谐波的信号,然后再通过PWM+低通滤波器的方式构成DAC,输出模拟信号来推动扬声器发出声音。
电子琴工作原理图
4 主要代码片段及说明
1. 乐谱查询
该部分代码首先调用分频器模块,将12MHz的系统时钟分频为8Hz的时钟,用于在自动演奏模式下每隔0.125s更新一次待演奏音符的地址。然后再实例化music_ROM模块,根据音符在乐谱中的地址,由模块内部的查找表得到待播放音符的序号。
wire clk_music; // 音符更新时钟,8Hz
divider_even #(.WIDTH(21),.N(1500000)) divider(clk,rst,clk_music);
reg [8:0] address; // 音符在乐谱中的地址
always @(posedge clk_music or negedge rst) begin
if(!rst)
address <= 9'b0;
else if(!mode) // 弹奏模式下地址不更新
address <= 9'b0;
else if(address >= 9'd423) // 播放结束后地址不再变化
address <= address;
else
address <= address + 9'b1; // 每0.125s从乐谱中读出一个音符
end
wire [5:0] note_music; // 从乐谱中读出的音符
music_ROM music(clk_music,address,note_music);
2. 按键扫描模块
(1) 八度选择
系统复位后,我们弹奏的音符将默认位于第五个八度。该部分代码实例化了两个按键消抖模块,当升音度键/降音度键按下时,keyup_pulse/keydown_pulse将出现一个clk周期的高电平,FPGA检测到后弹奏的音符会升/降一个八度。
reg [1:0] stage; // 当前所在八度 0为第4个八度 1为第5个八度 2为第6个八度
wire keyup_pulse,keydown_pulse; // 检测到调整音度键按下产生的高电平脉冲
debounce up(clk,rst,key_up,keyup_pulse); // 按键消抖
debounce down(clk,rst,key_down,keydown_pulse);
always @(posedge clk or negedge rst) begin
if(!rst)
stage <= 2'b01;
else begin
if(keyup_pulse && stage < 2'b10)
stage <= stage + 2'b1; // 升八度
if(keydown_pulse && stage > 2'b00)
stage <= stage - 2'b1; // 降八度
end
end
(2) 电子琴键扫描
我采用了状态机来扫描13个电子琴键的状态。在状态机的状态0,程序判断当前按下按键的数量,当同时按下的按键数量超过两个或没有按键按下时,不采取操作;当只有一个按键按下时,状态机依次经过状态1到状态13,扫描出按下的按键,得到弹奏音符的序号;当有两个按键同时按下时,状态机依次经过状态14到26,扫描出按下的两个按键,将其对应的音符序号保存在note[0]和note[1]中。
reg [3:0] key_sum; // 同时按下的按键个数
always @(*) key_sum = key[0]+key[1]+key[2]+key[3]+key[4]+key[5]+key[6]+key[7]+key[8]+key[9]+key[10]+key[11]+key[12];
reg [4:0] state; // 按键扫描状态机
reg i; // note序号
reg [5:0] note [1:0]; // 当前音节
always @(posedge clk or negedge rst) begin
if(!rst) begin
state <= 5'b0;
note[0] <= 6'b0;
note[1] <= 6'b0;
end
else begin
case(state)
5'd0: begin
i <= 1'b0;
if(key_sum < 4'd11 || key_sum == 4'd13) begin // 同时按下的按键个数超过两个或没有键按下
note[0] <= 6'b0;
note[1] <= 6'b0;
state <= 5'd0; // 直接进行下一轮扫描
end
else if(key_sum == 4'd12) begin // 有一个按键按下
state <= 5'd1;
note[1] <= 6'b0;
end
else // 有两个按键按下
state <= 5'd14;
end
// 状态1-13,依次扫描13个按键,找出按下的一个按键
5'd1,5'd2,5'd3,5'd4,5'd5,5'd6,5'd7,5'd8,5'd9,5'd10,5'd11,5'd12,5'd13: begin
if(!key[state-5'd1]) begin // 找到按下的按键
note[0] <= (stage == 2'b00) ? state : ((stage == 2'b01) ? (state + 5'd12) : (state + 5'd24));
state <= 5'd0; // 进入下一轮扫描
end
else if(state != 5'd13)
state <= state + 1'b1; // 继续扫描
else
state <= 5'd0; // 进入下一轮扫描
end
// 状态14-25,依次扫描前12个按键,找出按下的两个按键
5'd14,5'd15,5'd16,5'd17,5'd18,5'd19,5'd20,5'd21,5'd22,5'd23,5'd24,5'd25: begin
if(!key[state-5'd14]) begin // 有按键按下
note[i] <= (stage == 2'b00) ? (state - 5'd13) : ((stage == 2'b01) ? (state - 5'd1) : (state + 5'd11));
if(i == 1'b1) // 已找到按下的两个按键
state <= 5'd0; // 进入下一轮扫描
else // 已找到按下的一个按键
i <= 1'b1;
end
state <= state + 1'b1; // 继续扫描
end
5'd26: begin // 扫描最后一个按键
if(!key[state-5'd14]) // 有按键按下
note[i] <= (stage == 2'b00) ? (state - 5'd13) : ((stage == 2'b01) ? (state - 5'd1) : (state + 5'd11));
state <= 5'd0; // 已完成本次扫描,进行下一轮扫描
end
default: state <= 5'd0;
endcase
end
end
3. 蜂鸣器驱动模块
为了产生不同的频率的方波来驱动蜂鸣器发出不同音调的声音,我们可以通过对12MHz的系统时钟进行分频来实现,不同的音符的分频系数不同,即分频计数器的计数终值不同。(可以参考蜂鸣器模块 [电子森林] (eetree.cn))
always@(*) begin
case(note)
6'd1: time_end = 15'd22933; //L1
6'd2: time_end = 15'd21646;
6'd3: time_end = 15'd20430; //L2
6'd4: time_end = 15'd19284;
6'd5: time_end = 15'd18201; //L3
6'd6: time_end = 15'd17180; //L4
6'd7: time_end = 15'd16216;
6'd8: time_end = 15'd15306; //L5
6'd9: time_end = 15'd14446;
6'd10: time_end = 15'd13635; //L6
6'd11: time_end = 15'd12870;
6'd12: time_end = 15'd12148; //L7
6'd13: time_end = 15'd11464; //M1
6'd14: time_end = 15'd10822;
6'd15: time_end = 15'd10215; //M2
6'd16: time_end = 15'd9641;
6'd17: time_end = 15'd9100; //M3
6'd18: time_end = 15'd8589; //M4
6'd19: time_end = 15'd8107;
6'd20: time_end = 15'd7652; //M5
6'd21: time_end = 15'd7223;
6'd22: time_end = 15'd6817; //M6
6'd23: time_end = 15'd6435;
6'd24: time_end = 15'd6073; //M7
6'd25: time_end = 15'd5732; //H1
6'd26: time_end = 15'd5411;
6'd27: time_end = 15'd5107; //H2
6'd28: time_end = 15'd4820;
6'd29: time_end = 15'd4550; //H3
6'd30: time_end = 15'd4294; //H4
6'd31: time_end = 15'd4053;
6'd32: time_end = 15'd3826; //H5
6'd33: time_end = 15'd3611;
6'd34: time_end = 15'd3408; //H6
6'd35: time_end = 15'd3217;
6'd36: time_end = 15'd3036; //H7
default:time_end = 15'd22933;
endcase
end
reg [14:0] time_cnt;
//当蜂鸣器使能时,计数器按照计数终值(分频系数)计数
always@(posedge clk or negedge rst_n) begin
if(!rst_n)
time_cnt <= 1'b0;
else if(!enable)
time_cnt <= 1'b0;
else if(time_cnt >= time_end)
time_cnt <= 1'b0;
else
time_cnt <= time_cnt + 1'b1;
end
//根据计数器的周期,翻转蜂鸣器控制信号
always@(posedge clk or negedge rst_n) begin
if(!rst_n)
buzzer_out <= 1'b0;
else if(!note)
buzzer_out <= buzzer_out; // 休止符不发声
else if(time_cnt == time_end)
buzzer_out <= ~buzzer_out;
else
buzzer_out <= buzzer_out;
end
4. DDS模块
为了使喇叭能够模拟小号的音色,我对小号发出的声音信号进行了FFT,得到了其各次谐波的比例。然后,我根据该比例,合成了一个可以模拟小号音色的波形,并将其一个周期的波形用Diamond提供的ROM IP核来存储。波表中数据的位数为10位,波表的地址线位数为10位。
波表中储存的波形
该部分代码定义了一个28位的相位累加器,它以固定频率(240MHz)累加频率控制字f_inc。将相位累加器的高10位作为ROM中波表的地址,则波表地址循环的频率等于相位累加器最高位的频率,等于240000000/(2^28)*f_inc Hz,故输出信号的频率为240000000/(2^28)*f_inc Hz。
reg [27:0] phase; // 相位累加器,增加位数使得分辨率提高(能够输出的最低频率降低)
always @(posedge clk) phase <= phase + f_inc; // 每计数2^28/f_inc次,phase[27]经过一个周期,
// 则输出信号的频率为 时钟频率*f_inc/2^28
wire [9:0] wave_dat; // 波形数据
ROM_wave u1(.Address(phase[27:18]), .OutClock(clk), .OutClockEn(1'b1), .Reset(~rst), .Q(wave_dat));
always @(posedge clk or negedge rst) begin
if(!rst)
waveout <= 10'b0;
else if(enable)
waveout <= wave_dat;
else
waveout <= 10'b0;
end
从上述分析可以看出,输出信号的频率由频率控制字决定,故要使喇叭可以发出不同音调的声音,仅需改变频率控制字即可。
reg [27:0] f_inc; // 频率控制字
always @(*) begin
case(note)
6'd1: f_inc <= 28'd293; // c4
6'd2: f_inc <= 28'd310;
6'd3: f_inc <= 28'd328;
6'd4: f_inc <= 28'd348;
6'd5: f_inc <= 28'd369;
6'd6: f_inc <= 28'd391;
6'd7: f_inc <= 28'd414;
6'd8: f_inc <= 28'd438;
6'd9: f_inc <= 28'd465;
6'd10: f_inc <= 28'd492;
6'd11: f_inc <= 28'd521;
6'd12: f_inc <= 28'd552;
6'd13: f_inc <= 28'd585; // c5
6'd14: f_inc <= 28'd620;
6'd15: f_inc <= 28'd657;
6'd16: f_inc <= 28'd696;
6'd17: f_inc <= 28'd737;
6'd18: f_inc <= 28'd781;
6'd19: f_inc <= 28'd828;
6'd20: f_inc <= 28'd877;
6'd21: f_inc <= 28'd929;
6'd22: f_inc <= 28'd984;
6'd23: f_inc <= 28'd1043;
6'd24: f_inc <= 28'd1105;
6'd25: f_inc <= 28'd1170; // c6
6'd26: f_inc <= 28'd1240;
6'd27: f_inc <= 28'd1314;
6'd28: f_inc <= 28'd1392;
6'd29: f_inc <= 28'd1475;
6'd30: f_inc <= 28'd1562;
6'd31: f_inc <= 28'd1655;
6'd32: f_inc <= 28'd1754;
6'd33: f_inc <= 28'd1858;
6'd34: f_inc <= 28'd1969;
6'd35: f_inc <= 28'd2086;
6'd36: f_inc <= 28'd2210;
default: f_inc <= 28'b0;
endcase
end
5. 和弦
为了实现两个电子琴键同时按下时喇叭可以发出和弦的声音,我在顶层模块实例化了两个speaker模块,分别取出两个音符对应的波形数据,当两个音符都不是休止符时,将它们对应的波形数据相加再除以2,得到最终的输出波形数据。
wire [9:0] wave_dat1;
wire [9:0] wave_dat2;
speaker speaker1(clk_240M,rst,note1,player,wave_dat1);
speaker speaker2(clk_240M,rst,note2,player,wave_dat2);
reg [10:0] wave_dat; // 输出波形数据
always @(*) begin
if(note2) // 两个音符和弦
wave_dat = (wave_dat1+wave_dat2)>>1;
else // 单个音符
wave_dat = wave_dat1;
end
6. PWM模块
我使用了一阶sigma-delta调制的方法实现PWM,PWM信号经过电子琴拓展板上的低通滤波器滤波后即可生成目标的音频信号波形。创建一个一阶sigma-delta调制器的最简单的方法就是使用一个硬件累加器,每次累加器溢出,输出为“1”, 否则输出'0',用FPGA非常容易实现。 相对于通常固定频率、改变占空比的PWM方式,sigma-delta是在给定时钟频率的条件下,在保证占空比的前提下尽可能采用更高的脉冲频率,相当于大大提高了DAC的转换频率,从而减少了对合成频率的频段内的混叠。
reg [10:0] PWM_DDS_accumulator;
always @(posedge clk or negedge rst) begin
if(!rst)
PWM_DDS_accumulator <= 11'b0;
else
PWM_DDS_accumulator <= PWM_DDS_accumulator[9:0] + dac_dat; // 变化的数据产生变化的PWM占空比,从而产生变化的输出波形
end
assign pwm_out = PWM_DDS_accumulator[10];
5 遇到的主要难题及解决方法
1. ROM资源不足
最开始,我为了简化按键检测部分的程序,为每一个音符都实例化一个DDS模块,导致DDS模块内部的ROM核被实例化了36次,程序所使用的ROM空间远超FPGA所能提供的容量。为了解决这个问题,我用状态机实现了两个同时处于按下状态的按键的检测。这样,我就可以只实例化两个DDS模块,然后实时根据按下的按键改变两个DDS模块的频率控制字。
2. 按键扫描过于繁琐
要检测出13个按键中同时按下的两个按键对我来说并不是一件十分容易的事。我最开始的想法是使用case语句,根据当前13个按键的状态,给note1和note2赋予对应的音符序号。但是13个按键可能组合成将近100种状态,使用这种方法编写程序十分繁琐。后来,我意识到了使用状态机来描述时序逻辑的优势,并用状态机轻松实现了按键的扫描。
6 FPGA资源使用情况