Skip to content

用单片机spwm实现八音盒效果

摘自八音盒

在调试装置时需要一个稳定的声音源,想到之前买的有不少stc的MCU芯片,可以用它来产生几个声音信号。于是写了这个程序。

原理介绍

八音盒能产生七个不同的乐音,而音乐就是由这些基本音阶组合构成。所以本例程就是个基于MCU的乐音产生程序。 用MCU产生一个乐音本身很简单,比如要产生一个110HZ的乐音,只要用MCU生成一个频率为110HZ的PWM方波,然后输出电路上加个一阶低通滤波器,驱动喇叭就听到声音了。

但当我们需要产生多个乐音时,就遇到一个问题,不同乐音的频率不同,需要的滤波器的参数就不一样,只用一个滤波器时,频率高的乐音会受到较大的衰减。结果低音很强,高音听不见了。

当然可以用七个不同的低通滤波器,应对七个频率,用一个cd4051进行选择,mcu输出不同频度时,控制cd4051接通不同的滤波器。不过这样硬件就多了几样。而且无法适应更多的频率。

常用解决办法是采用一个高频率的载波,然后把需要产生的声音频率信号调制到载波上。这样就节省了滤波器。这就是spwm波。它是一个脉冲宽度按正弦规律变化的PWM波。下面给出一个实现方法。

👉实现方法

stc32g12k128具有硬件pwm功能。只要做好设置,它能自动产生符合设置频率和脉宽的PWM波,不需要占用MCU时间。我们只要合理控制它的脉宽变化,就能得到需要的SPWM波了。所以程序简单稳定,精度也很高。

  1. 频率选择 我选择系统主频用24MHZ,载波频率为55*512=28160HZ。55为参考频率,512为对应该频率时正弦波表的点数。因为人耳可闻声频率上限20KHZ,所以这个频率不会对乐音产生干扰,同时它又能保证每个周期对应一个波点数据。最大限度的保证产生的声音的精度。 对其它频率的乐音,载波频率不变,只改变正弦波表的点数。始终保证系统在最大精度上运行。该载波频率对应的周期设置值为:24000000/28161=852=0x0354

  2. SPWM生成 STC官方技术手册上有现成的产生spwm波的例程。就直接套用了,包括pwm设置和pwm中断服务。本例程主要是解决正统波表的生成,管理;使用,乐谱表的建立和使用;乐曲的播放管理。 对spwm波来讲,每个频率的正弦波都需要一组正统波表数据,建立正统波表时,我使用了软件Spwm_calc.exe。它的界面如图所示。 中值采用420,幅值415,调制度0.98,在55HZ时的点数512.其它频率的点数值通过计算获得。软件产生了七组数据存放在头文件sin_table1.h里,每组数据的第一个数放的是本组数据的个数(也就是使用的点数)。第二个开始才是正弦波表的数据。也就是说。读波表数据要从第二个开始读。这是为了编程序简单方便。

  3. 音符的实现方法 系统设置了三个数组,一个是正弦波表 ,放在头文件里。第二个是波表索引数组u16 *sin_table_index[],存放正弦波表的数据的地址,它是乐谱与正弦波表的过渡。第三个是乐谱数组u8 music_score[33][3]

    乐谱表中一组数据有三个,第一个是音符名,程序根据它通过索引数组读取对应正弦波表。第二个是音组名。1是最低音,2是低音,3是中音4是高音,5是最高音。这个数据决定从波表中读取数据的数量,数据越少音频越高。比如读512个是最低音的6(la),那么读256个就是低音的6(la),高了八度,频率高一倍,以此类推。这样七组波表数据可用以播放几十个乐音。第三个是音长(乐音的持续时间)。本例程只设置了三个数值,1是全音,2是半音,4 是4分音。仅用来展示一下音长管理的方法。想多加几个音长设置很容易。

  4. 软件的编写和资源的分配 例程中使用了四个中断服务:

  5. PWMA中断,基本照抄官方例程,用来产生spwm波。不同的是这里输出的正弦波频率不是固定的。所以读波表时不能用固定首地址。而是用了一个变量*p存放正弦波表首地址值,方便切换频率。
  6. 外中断int0控制程序运行,主要是把等待切换为运行。如果不用这个中断。可以让程序开机后就自动循环播放。在中断服务程序中。控制变量CC的值,从而影响主程序里语句While(cc);的运行。

    在调试时发现中断服务里改变cc值后,主程序里的while语句没有响应。接上stc-link1d仿真器后观察到,中断发生后cc值确实发生了变化,但while语句不理会,没有如预期的那样跳出死循环。把这个现象放到群里咨询时,有高人指出。这是由于keil编译不合理所致,解决的办法是声名变量CC时加一个限制符volatile。试了一下,果然解决了问题。

  7. 中断T1是避免按键振动产生干扰的延时。这两个都不重要,可以不用。

  8. 中断T0是核心,它控制一个乐音的播放时间,同时设定播放所需要的所有参数。各语句的作用在程序里做了注明。
    void t0_sever() interrupt 1//确定一个音符的输出参数,包括乐音的持续时间,音高数据的地址,数据量,读取参数
    {
    read_music_long(cnt);//读当前音符的播放时间,并设定对应的延时
            pp2=music_score[cnt][1];//读取本音符的音组值,以确定读波表数据时的偏移量
            pp3=pow(2,pp2);//计算本音符数据偏移量,在式中给pp2加整数能成倍提高输出频率
    pp0=*sin_table_index[music_score[cnt][0]-1];///pp2;//读取本音符的数据量
    pp1=(sin_table_index[music_score[cnt][0]-1]+1);//取本音符数据指针初值
            p=pp1;//赋正弦数据指针初值
            cnt++;//准备读下一个数据
            if(cnt>33)//乐谱播放完成,这里的33是根据乐谱数据的参数设定的。如果改变乐谱数据量,这里要做对应变化,用小了不能完整播放,用大了会出错
            {
            cc=0;//播放结束
            ET0=0;//关中断
            PWMA_IER = 0x00; //关中断
                PWMA_ENO = 0x00;//关闭PWM输出
    
            }
    }
    
    音长子程序里有三个软件定时程序,是直接使用stc官方的软件延时工具产生的。全音用了一秒,半音0.5秒,四分音用0.25秒。 乐音播放所需要的控制参数都是在这个中断服务里确定的。PWM中断服务则负责按参数进行输出。 本例程的主程序很简单,包括系统设置和播放管理两项。直接列出来吧:
    void main(void)
    {
            mcu_initial();//mcu设置程序
            //打开播放程序,播放完成后重新进入等待
        while(1)//这个是重入语句
        {
    
                    cc=1;//等待状态,由中断int0改变,如果使用cc=0;程序自动重复播放乐曲
                    while(cc);
                    //初始化播放指针并开始播放
                     cnt=0;//把播放计数复位到开始位置
                     ET0=1;
                    TR0 = 1;                                //定时器0开始计时
                   PWMA_IER = 0x01; //使能中断
                   PWMA_ENO |= 0x01; //使能输出
                   PWMA_ENO |= 0x02; //使能输出
    
                            cc=1;
                          while(cc);//等待播放完成,由T0中断服务程序控制这里的CC值
        }
    }
    
  9. 声音的输出 为听到产生的声音,我使用了唯创的PWM功率放大模块WT1312,它能把spwm信号变成推动喇叭的正弦信号并直接推动喇叭发声。把mcu的spwm输出端直接连上功放芯片的PWM输入端就行了。因电流较大。芯片电源单独接了一个4.2/3.7V锂电池。二者不需要共地。功放的输入阻抗是100K,对MCU输出要求很低。它的体积很小,又只用了一个电容,所以我直接用sop23-10/dip10转接板当功放板了。 MCU部分电路没有特殊要求,使用最小系统板就行,我在实验中用了stc32g12k128的降龙棍系统板。 因为测试电路和这个八音盒程序都没使用晶振,所以会有些误差。效果整体还是很不错的。完整的程序见附件,欢迎大家批评指正。

深入分析

要实现八音盒的功能需要输出a~f的8个音节,每个音节有10个8度,所以下来有80个音节(没有计算半音),它们是特定频率的正弦波。专业描述看下图: img

解释: - Octave 0-9 表示八度区。C-D-E-F-G-A-B 为 C 大调七个主音:do re mi fa so la si(简谱记为 1 到 7)。科学音调记号法(scientific pitch notation)就是将上面这两者合在一起表示一个音,比如 A4 就是中音 la,频率为 440 Hz。C5 则是高音 do(简谱是 1 上面加一个点)。 - 升一个八度也就是把频率翻番。如图中黄色框所示,A5 频率 880 Hz,正好是 A4 的两倍。一个八度区有 12 个半音,就是把这两倍的频率间隔等比分为 12,所以两个相邻半音的频率比是 2 开 12 次方,也即大约 1.05946。这种定音高的办法叫做 twelve-tone equal temperament,简称 12-TET。 - 两个半音之间再等比分可以分 100 份,每份叫做一音分(cent)。科学音调记号加上音分一般足够表示准确的音高了。比如 A4 +30 表示比 440 Hz 高 30 音分,可以算出来具体频率是 447.69 Hz。 A4 又称 A440,是国际标准音高。钢琴调音师或者大型乐队乐器之间调音都用这个频率。 - C4 又称 Middle C,是中音八度的开始。图中红框。有一种音高标定方法是和 C4 比较相隔的半音数,比方 B4 就是 +11,表示比 C4 高 11 个半音。 - MIDI note number p 和频率 f 转换关系:p = 69 + 12 x log2(f/440)。这实际上就是把 C4 定为 MIDI note number 60,然后每升降一个半音就加减一个号码。 - 可以看到 E-F 和 B-C 的间隔是一个半音,而七个主音别的间隔都是两个半音,也叫一个全音。 - 标准钢琴琴键有大有小,大的白色琴键是主音,小的黑色琴键是主音升降一个半音后的辅音(图)。一般钢琴是 88 个琴键,从 A0 到 C8。

  1. 波表和音符和音组频率的关系 波表是为了产生正弦波;音组是为了改变频率从而在基础波表的基础上产生低中高音符。 从上图可以看出,我们波表选择的是图中的绿色方框的8个频点,从A1举例,频率为55HZ,这就是上面选择载波频率为55*512的原因。同样,正弦波表里面的数组位数(unsigned int code a_6[513])也是这么来的. 那如果我要发出A2的音节呢? A2音节的频率是110HZ,刚好比A1高一倍。那我们输出正弦波表的时候,空一位输出一次,这样正弦波频率就翻倍了,A1波表就可以输出A2了。同理,A3,A4也可以这么干。这样做不用每个音符都制作一个波表,节省空间。当然这样的坏处是正弦波会变粗糙一些。请看下面具体代码:
    void t0_sever() interrupt 1//用来确定一个音符的输出参数,包括音长,音高数据的地址,数据量,读取参数
    {
     read_music_long(cnt);//读音长,并给出对应定时
        pp2=music_score[cnt][1];//读取本音符的音组值,以确定读数据时的偏移量
        pp3=pow(2,pp2);//本音符数据偏移量
      pp0=*sin_table_index[music_score[cnt][0]-1];///pp2;//读取本音符的数据量
      pp1=(sin_table_index[music_score[cnt][0]-1]+1);//取本音符数据指针初值
        p=pp1;//赋正弦数据指针初值
        cnt++;//准备读下一个数据
        if(cnt>33)//乐谱播放完成
        {
          cc=0;//播放结束
          ET0=0;//关中断
          PWMA_IER = 0x00; //关中断
          PWMA_ENO = 0x00;//关闭PWM输出
    
        }
    }
    
    void PWMA_ISR() interrupt 26 //循环读取指定数组内容,控制脉宽输出,每周期读一次
    { 
        if(PWMA_SR1 & 0X01)
        {
            PWMA_SR1 &=~0X01; 
             PWM1_Duty = *p;
                p=p+pp3;//pp3为读取增量,对应的是音组名,0为最低音组,1算低音,2是中音,3是高音,4是最高音
                if(p >(pp1+pp0 )    )//p1为对应数组初地址,pp0为该数组长度,也就就该数组的数据量,这个语句没把握
                {
                p=pp1;//指针初始化
                }
            PWMA_CCR1H = (u8)(PWM1_Duty >> 8); //设置占空比时间
            PWMA_CCR1L = (u8)(PWM1_Duty);
        }
        PWMA_SR1 = 0;
    }
    
  2. 音长的实现 音长就是音符持续的输出时间长度,音符是通过T0中断切换的。所以每次切换的时候会根据表格里面的音长来改变T0定时器的间隔,从而改变该音符的播放时长。
     void read_music_long(unsigned char music_long)
     {
     switch (music_score[music_long][2])//确定对应的音长
    {
        case 1:t0_1();break;
        case 2:t0_2();break;
        case 4:t0_4();break;
    default: t0_1();
    }
    

总结

  1. SPWM构建最低音波表,生成基本音符。
  2. 在基本音符基础上加上音组的偏移量来读取基础波表,从而实现频率倍数关系,实现其它的低中高音符。
  3. 每次读取乐谱数据的时候根据音符的音长来设定定时器,从而控制该音符的播放时长。
  4. 循环读取并播放所有音符,即可实现整个乐谱的播放。