基于小脚丫FPGA实现的电子琴
本项目用STEP-MXO2-C核心板和部分外设,通过FPGA使用PWM和DDS等算法实现了对电子琴的模拟。
标签
FPGA
DDS
PWM
2022暑假在家练
月影沫晨
更新2022-09-08
北京邮电大学
715

工作原理

电子琴的核心是一块FPGA板,外部输入主要是13个琴键对应的按键,核心板接收13个按键传来的信号之后,根据按下的不同按键而向外发出不同频率的波形,这里向蜂鸣器的输出口输出PWM波,向模拟喇叭的输出口输出DDS波形经过1位DAC处理后的波形,通过这种形式向外输出不同音调的声音。

除此之外还有调整音程、选择蜂鸣器或模拟喇叭播放和自动播放音乐的按键。由于相邻音程之间对应音调的频率是两倍频关系,故调高音时只需要将输出频率翻倍,反之调低音时只需要将输出频率减半就可以达到调整音程的功能。选择蜂鸣器或模拟喇叭播放的功能相对比较简单,在已经产生输出信号的情况下只需要根据输入的选择信号做一个选择判断即可。自动播放音乐功能需要在产生输出信号前对输入信号做一个接管动作,根据提前存储好的音乐在程序内设定好每一个音调对应的琴键信号和音程信号,用其替代外部硬件输入,就可以实现播放音乐的功能。

电子琴框图FpiPOM6s9hnWgvKQhmdtd-ay82k-

蜂鸣器和模拟喇叭的差别

这次活动拓展板上的蜂鸣器是无源蜂鸣器,发声原理是靠输入的信号驱动内部振荡器件,振荡频率等同于输入信号的频率,所以发出的声音的频率也等于输入信号的频率,这里的输入信号主要是数字类型的。而模拟喇叭则是会将输入的模拟类型的电信号转化为声音信号,输出声音的质量主要取决于输入信号的质量。

在项目中虽然两种输出信号本质上都是PWM波,但是实现过程相差非常大。输出给蜂鸣器的PWM信号通过将时钟进行分频得到,倍频只需要改变分频系数即可实现;而模拟喇叭信号则需要通过DDS技术得到相应频率波形的数字形式,再通过DAC将其转化为模拟信号,由于活动板上的输出口只有一位,所以这里采用的是1位DAC,倍频则需要通过在DDS产生波形的过程中使得递增系数翻倍来实现。

由于蜂鸣器的发声只受到输入信号频率的影响,所以能够发出的声音比较单一。而模拟喇叭接收的是模拟信号,信号波形很大程度上影响了喇叭发声的质量,不同的波形可以发出不同的声音,因此模拟喇叭可以发出的声音的种类很广,只要你能给出合适的信号,他就能发出不同的音色,不过可惜的是由于我的技术有限,在这次项目中我通过模拟喇叭发出的声音和蜂鸣器发出的声音相差不大,这点之后也可以改进一下。

模拟放大电路的仿真及分析

在模拟喇叭信号的输出口和模拟喇叭之间还有一段电路,其中分为低通滤波部分和功率放大部分,具体的电路可以在电路图中找到。功率放大部分用到了一颗功放芯片,而低通滤波部分就是简单的两个电阻和一个电容,这一部分将输出的方波信号的高频部分削弱,使得信号在0和1之间变化时变缓,再将该信号送入功率放大电路输出给模拟喇叭。

由于我用的multisim中找不到相应的功放芯片,所以这里就只对低通滤波部分进行仿真,如下图,方波边缘被削弱,更接近模拟信号。

Fhrr9Thr-Aa9szYlpsAaIEmVH0Zl

代码片段

①音程调节

这个功能的实现比较简单,只需要根据上下按键改变内部的音程变量,然后在蜂鸣器模块和模拟喇叭模块内根据这个变量进行相应调整即可。(分别是改变分频系数和改变递增系数)

这里的音程变量只有两位,所以这个电子琴的音程只分成了4段。下图中的sw_pulse是调节音程两个按键消抖后的信号。

// 音程调节
always @ (posedge clk or negedge rst_n) begin
	if(!rst_n) 
		band <= 2'd2;
	else if(sw_pulse[0]) begin  // 调高音
		if(band == 2'd0)
			band <= band;
		else
			band <= band - 2'd1;
	end
	else if(sw_pulse[1]) begin  // 调低音
		if(band == 2'd3)
			band <= band;
		else
			band <= band + 2'd1;
	end
	else
		band <= band;
end
// 改变分频系数
always @ (posedge clk_1Mhz) begin
	dev_coe[0]  = 13'd478 << band;
	dev_coe[1]  = 13'd450 << band;
	dev_coe[2]  = 13'd426 << band;
	dev_coe[3]  = 13'd402 << band;
	dev_coe[4]  = 13'd380 << band;
	dev_coe[5]  = 13'd358 << band;
	dev_coe[6]  = 13'd338 << band;
	dev_coe[7]  = 13'd320 << band;
	dev_coe[8]  = 13'd300 << band;
	dev_coe[9]  = 13'd284 << band;
	dev_coe[10] = 13'd268 << band;
	dev_coe[11] = 13'd254 << band;
	dev_coe[12] = 13'd240 << band;
end
// 改变递增系数
always @ (posedge clk_12Mhz) begin
	case(band)
		2'b00:	fword <= 4'd8;
		2'b01:	fword <= 4'd4;
		2'b10:	fword <= 4'd2;
		2'b11:	fword <= 4'd1;
	endcase
end

 

②多个琴键同时按下

这里我的解决方法比较简单,我设定了一个13位的信号,每位代表一种声音,该信号与按下的琴键相与后再相或,即将按下的信号简单地加在一起,这样在按下的琴键在两个以内时还是基本上能够保持声音的辨识度的。

蜂鸣器信号

assign	voice = base_hz & (~key);
assign	buzzer_sig = (voice[0] | voice[1] | voice[2] | voice[3] 
					| voice[4] | voice[5] | voice[6] | voice[7]
					| voice[8] | voice[9] | voice[10] | voice[11]
					| voice[12]);

模拟喇叭信号

assign	base_hz = {pwm12[8], pwm11[8], pwm10[8], pwm9[8], 
				   pwm8[8], pwm7[8], pwm6[8], pwm5[8], 
				   pwm4[8], pwm3[8], pwm2[8], pwm1[8], pwm0[8]};
assign	voice = base_hz & (~key);
assign	speaker_sig = (voice[0] | voice[1] | voice[2] | voice[3] 
					| voice[4] | voice[5] | voice[6] | voice[7]
					| voice[8] | voice[9] | voice[10] | voice[11]
					| voice[12]);

 

③模拟喇叭输出信号的生成

这里用的是DDS技术生成数字信号形式的正弦波,正弦信号的相位-幅值表格我是用芯片IP内核来实现的,然后通过一位DAC将其转化为模拟信号,虽然本质上仍然是PWM信号,即0和1形式的电信号,但是由于外部电路在模拟喇叭信号的输出口处接了一个低通滤波器,将高频信号滤除后使得输出的PWM波形变缓,更接近模拟信号。

这里由于篇幅限制,只展示其中一个琴键对应的模拟喇叭信号的生成代码,其他的我也只是简单地重复了一遍。

改变相位(这里的clk_d[0]是时钟分频信号,与正弦波频率有关)

// 改变相位
always @ (posedge clk_d[0]) begin
	theta[0] <= theta[0] + fword;
	theta2[0] <= theta2[0] + (fword << 1); // 谐波相位
end

获取正弦波和谐波幅值

sin_table	st0(
	.Clock(clk_12Mhz), 
	.ClkEn(1'b1), 
	.Reset(1'b0), 
	.Theta(theta[0]), 
	.Sine(sin[0])
);
sin_table	stx0(
	.Clock(clk_12Mhz), 
	.ClkEn(1'b1), 
	.Reset(1'b0), 
	.Theta(theta2[0]), 
	.Sine(sin2[0])
);

1位DAC(此时pwm0[8]就是DAC之后的信号)

pwm0  <= pwm0[7:0]  + sin[0]  + sin2[0];

 

④自动播放音乐

这里我设定了一个开始信号(start),当开始信号为1时自动播放音乐,为0时停止自动播放。而外部的播放按键同时也兼顾了停止键的功能,因此当按键按下后开始信号则会翻转一下,为1时用由播放模块产生的琴键信号和音程信号来替代外部输入的这两个信号再输入蜂鸣器模块和模拟喇叭模块,由此来自动播放音乐。播放音乐完毕后,播放模块传出停止信号,交由外部输入来演奏。

always @ (posedge clk or negedge rst_n) begin
	if(!rst_n) 
		start <= 1'b0;
	else if(stop)
		start <= 1'b0;
	else if(pl_music_pulse)
		start <= ~start;
	else
		start <= start;
end
auto_play at_play1(
	.clk(clk),
	.start(start),
	.key(at_key),
	.band(at_band),
	.stop(stop)
);

assign	play_key = start?at_key:key;
assign	play_band = start?at_band:band;

而播放模块内部需要读取提前存储好的一段音乐(这里我用的是IP内核中的ROM模块),然后对相关信息进行解码。此处我提前存储的文件中还包含了每个音持续的时间,其中偶数地址存储音调,奇数地址存储持续时间,计数器到达持续时间上限后增加读取地址,改变音调的同时改变计数器计数上限,在读取到全1信号的时候产生一个停止脉冲,表示音乐播放完毕。

读取音调和持续时间

music tone1(
	.Address(addr), 
	.OutClock(clk), 
	.OutClockEn(1'b1), 
	.Reset(1'b0), 
	.Q(tone)
);

music delay1(
	.Address(addr+1'b1), 
	.OutClock(clk), 
	.OutClockEn(1'b1), 
	.Reset(1'b0), 
	.Q(q2)
);

assign	cont = {q2[2], q2[1], q2[0]};

改变读取地址(随着地址的改变,音符持续时间delay也会不断变化)

always @ (posedge clk) begin
	if(!start) begin
		addr <= 8'd0;
		cnt <= 28'd0;
	end
	else begin
		if(cnt >= delay) begin
			addr = addr + 2'b10;
			cnt <= 28'd0;
		end
		else
			cnt <= cnt + 1'b1;
	end
end

 

FPGA资源占用报告截图

FreX3RmInSR5nuGjJwvIAx8hKnbW

 

遇到的问题

在这次项目的实现过程中我遇到的大多都是很细微的问题。在编写蜂鸣器模块的代码时,由于调整音程功能的存在,各个琴键对应的PWM波的频率都应该是可调整的,因此分频系数也应该是一个变量,但是若使用原本的12Mhz时钟来进行分频的话,每一个分频系数的位数就要很多,例如分出500hz的PWM波就需要16位来表示分频系数,13个分频模块需要13个16位变量,资源需求太大,然而最后的解决方法也很简单,只需要将时钟提前分频一次即可,最后我是在1Mhz时钟的基础上来分频形成各频率对应的PWM波的,这样花费的资源就少了很多,虽然很简单,但是一开始我的脑袋没有转过弯来,倒是卡了我很长的时间。

其它的问题诸如消抖、代码逻辑之类的都很简单,但是verilog有一点很令人难受的就是它在你拼写错误时编译后有时并不会给你报错,然后在把代码写入板子之后又无法实现预期的功能,在我以为是代码逻辑出错时检查了半天之后才发现是拼写错误,我不太清楚是编译器的问题还是语言本身就是这么设计的,这一点确实在我编写代码时造成了一定的影响。

 

改进建议

虽然这个项目基本上都实现了需求的功能,但是实现效果还有很多需要改进的地方。例如模拟喇叭的音色目前仍然和蜂鸣器相差不大,难以体现出模拟喇叭在音色方面的优点。其次在实现按键同时按下的功能时应该有比直接将信号相加更好的做法,当前做法在同时按下两个按键时还勉强能够保持不失真,但是在同时按下三个及以上的按键时就会出现非常明显的噪声。若想使项目更完善的话这些方面都可以进行一下改进。

附件下载
piano.rar
完整项目
piano_impl1.jed
项目jed文件
团队介绍
北京邮电大学20级通信的学生一名
团队成员
月影沫晨
评论
0 / 100
查看更多
目录
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2023 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号