基于FPGA的电子琴设计
基于小脚丫FPGA核心板(Lattice MXO2-C),通过Lattice Diamond软件进行编程,利用一块Piano Kit扩展板实现电子琴功能。
标签
FPGA
数字逻辑
2022暑假在家练
杨老基
更新2022-09-06
北京理工大学
1655

1. 项目介绍

基于小脚丫FPGA核心板(Lattice MXO2-C),通过Lattice Diamond软件进行编程,利用一块Piano Kit扩展板实现电子琴功能。

2. 硬件介绍

  • 1x Piano Kit扩展板,包含了带电路的底板和一块琴键盖板。

 

  • 1x 小脚丫FPGA核心板(Lattice MXO2-C),能够通过Web IDE编程或Lattice官方提供的Diamond软件进行编程。

 

  • 1x Micro USB数据线。

3. 电子琴的工作原理和框图

Fq_i-wX8hWfw6ybWLY4bBkFHuQrr

图 1 电子琴功能示意图

Ftj8PPYETWJg2daziLxyxCK8yAjf

图 2 模块调用关系图

 

FkXlak_Pjc5aOGmy0m2Vpfh1UpQC

图 3 具体电路图

 

Fr-5mNUJOzOaBTio47MyWNZICd9s

图 4 国际音高与频率对照表

 

3.1. 弹奏功能简述

Piano Kit扩展板共有15个按键,其中13个是琴键,2个是音程键。琴键和音程键按下按键后分别作为信号输入向量key送入。依次对13个琴键和2个音程键进行扫描,如果判断琴键值发生变化,就把琴键编号按公式以一定规律转为音符编号送入,设置60为中央C,不同音程只需在公式中加减13即可。经查表将不同音名对应上相应频率(见图4),然后,通过dds模块变换为波形编码,通过note模块使用累加计数器产生分频脉冲,根据查表所得频率调整脉冲周期以实现不同音高。再将波形数据送入dac模块,通过简单的计数器实现单通道PWM波形输出,最后通过player模块实现音符的打开和关闭,在top模块中例化即可实现弹奏功能。

3.2. 自动播放功能简述

首先,将音符储存在mem文件中,高八位为音值,第八位为音高,十六进制。设置读秒器,当读秒器超过音值和节拍的积,输出八位音符至player模块,通过状态机分频。因为先前为了实现弹奏功能的实现设计keyboard模块时,将音符最高位设置为开关位,player模块会判断最高位来决定音符的播放和停止。所以需要在mem中的每一个音符后手动再添加一个终止音符。

 

3.3. 和弦功能简述

只需设置24位二维数组,将三个琴键的音符分别以3个8位存储,每个时钟周期扫描对应琴键,没按下则为零,下一个时钟周期则扫描下一个,以此循环。随后,将三者进行累加求和,最高位置1,使用除法器求得均值输出。

 

3.4. 消抖

只需要通过一个低频时钟定时扫描按键即可。

 

3.5. DDS的实现

笔者尝试使用过Sin-Cos Table IP核,但不知道为什么很容易就报错,效果不是很理想。DDS的原理在电子森林百科中已述,故不再赘述。

使用波表法要先生成波表数据初始化文件,笔者曾尝试过matlab toolbox和python脚本等方法,但效果不甚理想。这一步并不是很难,只要生成波表存储器数据文件即可。后来经查找资料得知,可以使用一种mif生成器,通过绘制波形图即可自动采样生成波表数据。设置8位精度,8位宽度,256个相位波形量化数据,参考小提琴的波形图,绘制波形(见图5)。

 

Ft8T0QbdsmW4tOgAeRhWoNbFoxvh

                                       图 5

 

随后软件即可自动输出.mif格式的波表文件,经过手动转换储存在ROM中。随后,通过查找表的方法生成周期性脉冲信号(波形样点),即可实现类似小提琴的波形。

 

FlspcljwJvr4hfhT2VtbnRi9UG_G

图 6 频谱图

 

FuRnFn2mx_NaEuyAQt9AzBveblhz

图 7 按下一个按键

FmzIGuMrZ4oaBDueAp6fQZ7ccvww

图 8 按下两个按键

Fk7tJM4M0clEcDMt71btqVw9cevV

图 9 按下三个按键

 

由以上四图可见,实际生成波形和小提琴波形大致吻合,同时按下按键时声音没有失真,且至少包括一个谐波分量,满足所需要求。

 

4. 功能分析 4.1. 分析蜂鸣器和模拟喇叭的差别

驱动电路不同。蜂鸣器一般是高阻,直流电阻无限大,交流阻抗也很大,窄带发声器件,通常由压电陶瓷片发声。需要较大的电压来驱动,但电流很小,几mA就可以了。功率也很小。喇叭则是低阻,直流电阻几乎是0,交流阻抗一般几欧到十几欧。宽频发声器件,通常由利用线圈的电磁力推动膜片发声。

发声原理不同。喇叭主要由永久磁铁、线圈和纸盆构成。当电信号通过引出线流进线圈时,线圈产生磁场。由于流进线圈的电流是变化的,线圈产生的磁场也是变化的,线圈变化的磁场与磁铁的磁场相互作用,线圈和磁铁不断排斥和吸引,使重量轻的线圈产生运动,线圈的运动带动与它相连的纸盆振动,纸盆就发出声音,就使扬声器产生随音频变化的声音,从而实现了电-声转换。

压电式蜂鸣器主要由多谐振荡器、压电蜂鸣片、阻抗匹配器、共鸣箱及外壳等组成。多谐振荡器由晶体管和集成电路构成,通过交流信号驱动,多谐振荡器起振,产生1.5-2.5kHz的音频信号,FPGA可以通过输出不同频率的PWM脉冲信号控制蜂鸣器产生不同的音节输出,经阻抗匹配器推动压电蜂鸣片发声。压电蜂鸣片由锆钛酸铅或铌镁酸铅压电陶瓷材料制成,在陶瓷片的两面镀上银电极,经极化和老化处理后,再与黄铜片或不锈钢片粘在一起。电磁式蜂鸣器由振荡器、电磁线圈、磁铁、振动膜片及外壳等组成。接通直流电源后,振荡器产生的音频信号电流通过电磁线圈,使电磁线圈产生磁场。振动膜片在电磁线圈和磁铁的相互作用下,周期性振动发声。无源激型蜂鸣器的工作发声原理是:方波信号输入谐振装置转换为声音信号输出。

 

4.2. 蜂鸣器和模拟喇叭的实现方法差别以及音效差别分析

从两者的结构和原理上比较与分析,喇叭输入的信号为音频信号,是不断变化的交流电,所以在结构上无振荡器;蜂鸣器输入的为直流电,为使其发出声音,在结构上必须配有振荡器,将直流电转换为交流电,从而发出声音。

喇叭可以发出各种声音,蜂鸣器只能发出几种单调的声音。

 

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

通过Lattice diamond自带的modelsim软件进行仿真,设置testbench文件,进行模块例化和端口配置,得到输出的波形图。可以看到输出的单通道PWM波为不同占空比的波形,满足要求。

Ftfsp2wNqiYgwwiRcYmMygPl8b3l

图 10 wave图

 

5. 主要代码片段及说明 5.1. top模块

 

module top #(parameter pclk_freq = 12_000_000,

              parameter PLLclk_freq = 120_000_000)

             (input wire pclk,

              input wire rst_n,// 复位信号,低电平有效

              input wire [12:0] key,// 钢琴键,低电平有效

              input wire [1:0] pitch,// 升音/降音键,低电平有效

              input wire switch,// 蜂鸣器/扬声器选择键

              input wire autoplay_n,// 开始自动播放按键,低电平有效

              output wire buzzer,// 蜂鸣器输出

              output wire speaker);

 

Piano Kit扩展板共有15个按键,其中13个是琴键,2个是音程键。琴键和音程键按下按键后分别作为信号输入向量key送入。pitch、switch、 autoplay_n三个向量则作为开关,只需要注意对应管脚即可。top模块中配置输入输出管脚,使用简单的判断语句进行模式切换,因为keyboard和autoplay模块输出的是音符,所以只需要再例化player模块就可以实现电子琴的功能。

 

5.2. keyboard模块

    always @(posedge clk_scan_13x or posedge rst) begin

        if (rst) begin

            clk_msg      = 1'b0;

            msg          = 1'b0;

            sta          = 0;

            scan_i       = 1'b0;

            key_last_sta = 13'b1_111_111_111_111;

        end

        else begin

            case (sta)

                2'b00: begin

                    if (key_last_sta[scan_i] != key[scan_i]) begin

                        msg                  = shift*13+scan_i+8;

                        msg[7]               = ~key[scan_i];

                        key_last_sta[scan_i] = key[scan_i];

                        sta                  = 2'b01;

                    end

                    if (scan_i == 12)

                        scan_i = 1'b0;

                    else

                        scan_i = scan_i + 1'b1;

                end

                2'b01: begin

                    clk_msg = 1'b1;

                    sta     = 2'b10;

                end

                2'b10: begin

                    clk_msg = 1'b0;

                    sta     = 2'b00;

                end

            endcase

        end

    end

 

在这一段代码中,按键信息输入后,先对时钟做一个分频,在每一个扫描时钟周期对key向量进行一次扫描,按下按键后,如果判断琴键值发生变化,就把琴键编号按公式以一定规律转为音符编号送入,设置60为中央C,不同音程只需在公式中加减13即可。使用状态机进行输出信号的时钟,然后循环扫描十三个按键,音程键原理相同。

 

5.3. note_freq模块

always@(clk) begin

    case(note_id[6:0])

47: freq = 116.54;

48: freq = 123.47;

49: freq = 130.81;

50: freq = 146.83;

51: freq = 155.56;

52: freq = 164.81;

53: freq = 174.61;

54: freq = 185.00;

55: freq = 196.00;

56: freq = 207.65;

57: freq = 220.00;

58: freq = 233.08;

59: freq = 246.94;

60: freq = 261.23;

61: freq = 277.18;

62: freq = 293.67;

63: freq = 311.13;

64: freq = 329.63;

65: freq = 349.23;

66: freq = 392.00;

67: freq = 415.30;

68: freq = 440.00;

69: freq = 466.16;

70: freq = 493.88;

71: freq = 523.25;

72: freq = 587.33;

73: freq = 622.25;

74: freq = 659.26;

75: freq = 698.46;

76: freq = 739.99;

77: freq = 783.99;

78: freq = 830.61;

79: freq = 880.00;

80: freq = 932.33;

81: freq = 987.77;

82: freq = 1046.5;

83: freq = 1108.7;

84: freq = 1174.7;

85: freq = 1244.5;

    endcase

end

 

在freq模块中,输入的音符数值根据音频对照表输出频率数据。

 

5.4. DDS模块

module dds #(parameter keyboard_num = 3,

             parameter phase_wide = 8,//8位地址

             parameter sin_acc = 8)//8位精度

            (input wire [phase_wide*keyboard_num-1:0] phase,

             output wire [sin_acc*keyboard_num-1:0] am);

    

    reg [sin_acc-1:0] dds [0:(1<<phase_wide)-1];//256个

    initial $readmemh("LUT.mem", dds);

    

        genvar i;

        generate

            for (i=0; i<keyboard_num; i=i+1)begin:BLOCK1

                assign am[sin_acc*i+:sin_acc] = dds[phase[phase_wide*i+:phase_wide]];

end

        endgenerate




endmodule

 

在DDS模块中,地址和波形都为3×8位数组,分别存储最大同时按下的三个琴键的音符信息。将输入音符编号作为为地址编号,以地址位宽为间隔依次查找LUT表,使用for循环依次将三个琴键对应音符转化为8位精度的相应波形数据输出。

 

5.5. note模块

    always @(posedge clk_theta or posedge rst) begin

        if (rst) begin

            am = 1'b0;

        end

        else if (note_id != last_note_id) begin                           //储存上个音符,用于判断

            last_note_id = note_id;

        end

        else if (note_id != 0 && freq != 0 && clk_theta_divx != 0) begin //分频脉冲

            am = dds_am;

end

        else

            am = 1'b0;

    end

在note模块中,我们先使用always语句块依次输出地址和相应波形数据,但我们要实现dds的功能,接着例化note_freq模块,作用是根据查找音名频率表获得对应频率。然后根据频率转化为分频系数,通过例化除法器模块由输入时钟除以这个分频系数得到每个输出脉冲的时钟的分频系数,然后再通过例化时钟分频模块得到输出脉冲的时钟,根据这个时钟和先前例化的波形数据产生周期性的脉冲,就可以得到dds信号输出。

 

5.6. player模块

 case (sta)

                2'b00: begin

                    if (~clk_msg_last & clk_msg) begin

                        handle_note = msg[6:0];

                        handle_i    = 1'b0;

                        if (msg[7:7])

                            sta = 2'b01;

                        else

                            sta = 2'b10;

                    end

                    clk_msg_last = clk_msg;

                end

                2'b01: begin // 打开一个音符

                    if (note_ids[handle_i] == 8'b0) begin//如果之前未打开

                        note_ids[handle_i] = handle_note;

                        sta               = 2'b00;

                    end

                    else if (handle_i == keyboard_num-1)

                    sta = 2'b00;

                    else

                    handle_i = handle_i + 1'b1;

                end

                2'b10: begin // 关闭一个音符

                    if (note_ids[handle_i] == handle_note) begin

                        note_ids[handle_i] = 8'b0;

                        sta               = 2'b00;

                    end

                    else if (handle_i == keyboard_num-1)

                    sta = 2'b00;

                    else

                    handle_i = handle_i + 1'b1;

                end

            endcase

 

在player模块中,判断音符最高位,有效则输出音符,否则输出0。然后例化note模块,作用是使用累加器根据不同音名的频率改变依次产生的脉冲间隔,实现不同音高。然后,再将最大可以同时按下的琴键对应的波形相累加,再除以最大按下琴键数,以实现和弦功能。再将上一步的波形分别例化给dds和dac模块,就能得到单通道PWM波。

 

 

    always @(*) begin

        am_sum = ams[0];

        for(i = 1; i<keyboard_num; i = i+1) begin

            am_sum = am_sum + ams[i];

        end

        am_sum = am_sum + (1<<(sin_acc-1)) * keyboard_num;

    End

这段代码通过将三个琴键音符累加求和再取均值,并把最高位置1输出,即可实现和弦功能。

 

5.7. DAC模块

    always @(posedge clk or posedge rst) begin

        if (rst) begin

            cnt <= 1'b0;

            ccr <= 1'b0;

        end

        else begin

            if (cnt >= ARR) begin

                cnt <= 1'b0;

                ccr <= (am * ARR) >> sin_acc;

            end

            else

                cnt <= cnt + 1'b1;

        end

    end

    

    always @(posedge clk or posedge rst) begin  //控制输出

        if (rst)

            pwm <= 1'b0;

        else

            pwm <= cnt > ccr;

End

 

在dac模块中,我们的目的是将8位精度的波形数据转化为1位PWM输出。先把时钟频率除以PWM频率得到分频系数,然后做一次分频,分频后的每一个周期根据每一个音的值依照对应关系的到一个常量,然后在每个时钟周期作0-1输出,这样就能得到不同占空比的PWM单通道输出。

5.8. autoplay模块

            cnt = cnt + 1'b1;

            case (sta)

                2'b00: begin

                    if (cnt > music[i][15:8] * per_beat_len) begin

                        msg = music[i][7:0];

                        i   = (i == music_len-1) ? 1'b0 : i + 1'b1;

                        cnt = 1'b0;

                        sta = 1'b01;

    end




else begin

note_last = music[i][7:0];

msg       = 1'b0;

sta       = 2'b01;

end

                end

                

                2'b01: begin

                    clk_msg = 1'b1;

                    sta     = 2'b11;

                end

                

                2'b11: begin

                    clk_msg = 1'b0;

                    sta     = 2'b00;

                end

                

            Endcase

 

这段代码的功能,主要是使用状态机实现音符的播放。乐曲存储在3×16位的数组中,高8位为音值,低7位为音符,第8位为开关位,数组长度就是乐曲长度。随后将music中的音符数据依次输出,设置一个计数器在状态机作循环判断,当计数器大于音值×节拍,就把音符输出供其他模块例化即可实现不同音值。当变量i大于音符长度时从头播放,以实现乐曲循环。要让音符关闭需要手动在音乐文件中在每一个音符后手动添加一个停止音符。

 

6. 改进建议

  1. 笔者曾尝试使用数码管输出音符编号,但数字显示总有一点问题无法解决,限于时间只能放弃。后续将继续尝试实现此功能。
  2. 最初尝试实现自动播放功能时,播放完单个音符后总是无法将其停止。笔者尝试修改autoplay模块以改正,但无论怎么改就是不行。经不断尝试,只需要在音乐文件中的每个音符后手动添加一个关闭音符,即可正常播放。后续将改正这个缺点。
  3. 实现可以同时按下大于三个按键的功能。
  4. 计划改用Sin-CosTable IP核实现DDS功能。

 

 

7. 心得体会

在选择这个项目之前,我很心虚。因为我的专业原因,对FPGA并没有太大了解,学校也只是开设相关选修课,而且我也没选修过,只是在几个学期前学过几天的verilog语言,在这几天的学习中也只是依葫芦画瓢照搬老师的做法尝试用了一下quartus。几个学期后的现在我早已把先前的知识忘得一干二净,只是知道有verilog这个东西,连这个东西是什么,用来干什么都忘了。可以说在项目开始前差不多是零基础。

考虑到设计的是电子琴,不可避免的要运用乐理知识,于是我先大致学了一遍乐理。随后花数天时间囫囵吞枣学完了verilog语言。

最痛苦的是刚刚开始编写程序,verilog是关于时序的语言,所以逻辑特别抽象,不仅学习难,而且编写难,调试更难。在综合上遇到了很多让人抓耳挠腮求爷爷告奶奶的问题,特别是模块例化和端口配置经常出问题,浪费了大量时间。

网络上关于FPGA的资料很少,特别是开源资源。所以,除了一些底层模块,核心模块都需要自己编写。而且,关于电子琴的资料,网上的资料,大都繁冗且晦涩难懂,读完一知半解,也不知道哪些是对自己有用的部分。电子琴要实现的功能虽然听起来简单,对于我这种小白来说,还是很复杂。

遇到困难,遇到问题,大都还是自己掉头发在网上查找资料解决。还是解决不了只能求助他人,群里的几位大哥们、老师们都很热心。我觉得,干这一行的,共享、共建、互助是最重要的精神。耐心,细心,专心是最重要的品质。

这也是我第二次参加电子森林项目了。本项目历时半个多月,在这半个多月学到的知识怕是几门课都抵不过的,期间走了不少弯路,付出了很多沉没成本,有半夜掉头发泡咖啡的经历,有找bug改bug但是越改errors数量越多的让人心态崩溃恨不得给编译器大爷跪下的经历,有从浩如烟海的网络资料中找到自己所需而雀跃的经历,有经过群里大佬指点想给对方跪下的经历,有成功实现某功能而欣喜若狂的经历,而笔者还要兼顾考研和实习,最后赶着ddl完成了项目。其中各种,不失为人生的宝贵回忆。

最后,感谢硬禾学堂为我们提供了良好的学习平台。也希望能有越来越多的同学了解并参与硬禾学堂的项目,并借此提升自己的技能。

 

补充

· 再补充一下FPGA的资源占用数据吧

答:已补:

FrC0z92kUHB-nPLIbItDaXUQmc-H

 

· 好像播放的效果不咋地啊

答:电子琴本身并无问题,只是我忘了算上黑键了

附件下载
piano1.zip
团队介绍
个人姓名:杨丰珲 学校:北京理工大学
团队成员
杨老基
评论
0 / 100
查看更多
目录
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号