风筝
发表于: 2018-12-14 15:48:53 | 显示全部楼层

在本篇文章中,我们将主要介绍如何基于ATtiny85制作数字音乐盒,该设备可以播放以MIDI格式存储在内存卡中的音乐:

midiplayer.jpg

音符听起来像一个破破的音乐盒或大键琴,有四个通道,所以最多可以同时播放四个音符。我的示范音乐弹奏的是D小调的巴赫赋格曲;以下是播放的声音:midiplayer.mp3


您可以轻松地对其进行编程,以便从Music Box Maniacs等网站播放您喜欢的任何MIDI音乐盒曲调,您可以将其用作电子贺卡、音乐求婚戒指盒、电子门铃的基础,或任何其他基于音乐的项目。


项目介绍

这个项目开始于我发现Music Box Maniacs,这是一个提供曲调的网站,你可以打印成纸条,用于各种机械音乐盒。这些曲调也可以作为MIDI文件下载,我认为编写一个程序将MIDI格式转换为我之前的Digital Music Box [Updated]项目所需的二进制数字格式会很有趣。使用MIDI文件格式的描述我写了一个转换器程序(在Lisp中),但后来意识到我可以不需要中间的转换步骤,而是制作一个可直接从微控制器的闪存中读取MIDI文件的音乐盒项目。这还有一个优点,即它将删除我原始程序的32音符范围限制,该限制将编码为32位数字中的位位置。


我的MIDI转换器处理MIDI格式的一个子集,适合在Music Box Maniacs网站上播放音乐盒曲调,我用它的几个曲调测试了它。但是,我不确定它将如何处理一般的MIDI文件。


电路

该电路与我之前的音乐盒相同,如果在解析MIDI代码时出现错误,则会添加一个错误指示灯以提供反馈。 LED闪烁的次数告诉您MIDI格式在何处发生错误。


PWM输出通过电解电容直接馈入8Ω扬声器,以去除DC。扬声器的电感滤除了波形的高频成分:

tinymidiplayer.gif


电源线上的10μF电容使ATtiny85能够应对音乐引起的电流尖峰。


如果要将输出馈送到音频放大器,则必须包含低通滤波器,否则可能会使放大器过载。


列出MIDI数据

您想要播放的乐曲的MIDI数据只包含在程序的开头部分;以下是一个例子,简写为:

  1. const uint8_t Tune[] PROGMEM = {
  2.   0x4d, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x01,
  3.   0x03, 0xc0, 0x4d, 0x54, 0x72, 0x6b, 0x00, 0x00, 0x0a, 0x7e, 0x00, 0xff,
  4.   ...
  5.   0x50, 0xb0, 0x5b, 0x00, 0x00, 0xff, 0x2f, 0x00
  6. };
复制代码

这是我用于以正确格式列出MIDI文件的过程。

●    将MIDI文件复制到主目录中。

●    打开终端应用程序。

●    键入以下命令,替换MIDI文件的名称:

xxd -i musicbox.mid

-i参数告诉xxd输出C include格式的数据。 输出看起来像:

  1. unsigned char musicbox_mid[] = {
  2.   0x4d, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x01,
  3.   0x03, 0xc0, 0x4d, 0x54, 0x72, 0x6b, 0x00, 0x00, 0x0a, 0x7e, 0x00, 0xff,
  4.   ...
  5.   0x50, 0xb0, 0x5b, 0x00, 0x00, 0xff, 0x2f, 0x00
  6. };
  7. unsigned int musicbox_mid_len = 2708;
复制代码

最后一行显示数据的长度;这应该小于约6000,以使MIDI数据适合可用的闪存空间。

●    从终端窗口中剪切并粘贴,以替换Tiny MIDI Player源文件中的相应行。


setup()函数

音乐盒使用ATtiny85定时器和看门狗定时器。这些是在setup()中配置的。


首先,64MHz锁相环(PLL)用作T / C1的时钟源:

  1.   PLLCSR = 1<<PCKE | 1<<PLLE;
复制代码

然后,定时器/计数器1设置为PWM模式,使其充当模数转换器,使用OCR1B中的值来改变占空比。

  1.   TIMSK = 0;                     // Timer interrupts OFF
  2.   TCCR1 = 1<<CS10;               // 1:1 prescale
  3.   GTCCR = 1<<PWM1B | 2<<COM1B0;  // PWM B, clear on match
  4.   OCR1B = 128;
  5.   DDRB = 1<<DDB4;                // Enable PWM output on pin 4
复制代码

方波的频率由OCR1C指定;我们将它设置为默认值255,它将64MHz时钟除以256,得到250kHz方波。这足够高于我们的采样率,以避免抗锯齿问题。


定时器/计数器0设置为产生中断以输出样本:

  1.   TCCR0A = 3<<WGM00;             // Fast PWM
  2.   TCCR0B = 1<<WGM02 | 2<<CS00;   // 1/8 prescale
  3.   OCR0A = 19;                    // Divide by 20
  4.   TIMSK = 1<<OCIE0A;             // Enable compare match, disable overflow
复制代码

该中断的速率是16MHz系统时钟除以预分频器8,OCR0A中的值为19 + 1,给出100kHz。该中断用于依次输出四个通道,多路复用,因此每个通道的采样率为25kHz。中断调用中断服务程序ISR(TIMER0_COMPA_vect),它计算并输出样本。


最后,看门狗定时器配置为每16ms发出一次中断,用于定时音符输出:

  1. WDTCR = 1<<WDIE | 0<<WDP0;     // Interrupt every 16ms
复制代码

跳转到指定楼层
风筝
发表于: 2018-12-14 16:40:07 | 显示全部楼层

产生波形

MIDI播放器使用DDS(Direct Digital Synthesis)产生波形。为了给这个项目一个音乐盒的声音我想给波形衰减。 ATtiny85不提供硬件乘法,因此为了避免需要乘法,基础波形采用的是矩形波,所以我们只需要将包络的幅度乘以1或-1。此外,我使包络线性衰减,因此我们可以简单地通过递减计数器来计算幅度值。


对于每个通道,有三个变量; Freq [] - 当前音符值,Acc [] - 相位累加器和Amp [] - 包络幅度值。对于每个样本,Freq []值被添加到相位累加器Acc []。 Acc []的最高位用于生成通道的方波。 Freq []的值越大,顶部位产生的频率越高。最后,波形乘以包络Amp []。四个通道复用在一起,结果输出到模拟输出。


中断服务程序

该程序的关键部分是T / C0中断服务程序,它将波形样本输出到模拟输出,并以约95kHz的速率调用。对于当前通道c,它更新频率累加器Acc [c]和幅度Amp [c],并计算当前音符的值。然后输出到T / C1的比较寄存器OCR1B,在引脚4上给出一个模拟值:

  1. ISR(TIMER0_COMPA_vect) {
  2.   static uint8_t c;
  3.   signed char Temp, Mask, Env, Note;
  4.   Acc[c] = Acc[c] + Freq[c];  
  5.   Amp[c] = Amp[c] - (Amp[c] != 0);
  6.   Temp = Acc[c] >> 8;
  7.   Temp = Temp & Temp<<1;
  8.   Mask = Temp >> 7;
  9.   Env = Amp[c] >> Volume;
  10.   Note = (Env ^ Mask) + (Mask & 1);
  11.   OCR1B = Note + 128;
  12.   c = (c + 1) & 3;
  13. }
复制代码

逐行解释此代码:

  1. Acc[c] = Acc[c] + Freq[c];
复制代码

将当前通道Freq [c]的频率值添加到频率累加器Acc [c]。 Freq [c]的值越大,Acc [c]的变化越快。

  1. Amp[c] = Amp[c] - (Amp[c] != 0);
复制代码

减小通道的幅度值。 (Amp [c]!= 0)部分确保一旦达到零,它就保持为零。

  1. Temp = Acc[c] >> 8;
复制代码

将Temp设置为频率累加器的前8位。

  1. Temp = Temp & Temp<<1;
复制代码

如果前两位为1,则该行将顶部位设置为1,否则设置为0,这将给出具有25/75标记空间比的矩形波。对于这个项目,我决定使用一个矩形波,它具有更丰富的谐波和更好的声音。

  1. Mask = Temp >> 7;
复制代码

由于Temp和Mask是有符号值,因此在整个字节中将顶部位向下扫描。如果最高位为0,则得到0x00,如果为1则得到0xFF。

  1. Env = Amp[c] >> Volume;
复制代码

默认情况下,Volume为8,将Env设置为幅度的顶部字节。

  1. Note = (Env ^ Mask) + (Mask & 1);
复制代码

最后,把它们放在一起。如果Mask为0x00,则将Note设置为Env值。如果Mask为0xFF,则将Note设置为Env + 1的补码,或减去Env值。因此,Note现在包含一个在正负电流幅度之间变化的波形。

  1. OCR1B = Note + 128;
复制代码

输出寄存器设置为Note + 128,以提供无符号的8位值。

  1. c = (c + 1) & 3;
复制代码

四个通道在连续中断时输出,在输出端多路复用通道。


生成比例

良好的调节比例由数组Scale []中的常量生成:

  1. unsigned int Scale[] = {
  2. 10973, 11626, 12317, 13050, 13826, 14648, 15519, 16442, 17419, 18455, 19552, 20715};   
复制代码

第一个数字10973对应于C0。为了获得C4,中间C,即四个八度音高,我们将其除以24得到686.因此,Acc [c]的最高位将以25000 /(65536/685)或261.7 Hz,中间C的频率变化。 。


改变声音

两个变量可让您更改音符的声音。您可以在7到9之间改变音量以尝试不同的音量设置。你可以改变14到12之间的腐烂来改变信封; 14给出了长衰减,12给出了最短的衰减。超出这些范围的值可能不会有用。


MINI解释器

MIDI解释器读取在音乐盒合成器上播放曲调所需的MIDI子集。它假定只有一个MIDI通道,并读取速度和分度(每拍节拍)设置,但忽略大多数其他设置。


读取数据

MIDI解释器使用以下函数来读取MIDI数据:


readIgnore()跳过文件中指定的字节数:

  1. void readIgnore (int n) {
  2.   Ptr = Ptr + n;
  3. }
复制代码

readNumber()读取具有指定字节数精度的数字,最多4个:

  1. unsigned long readNumber (int n) {
  2.   long result = 0;
  3.   for (int i=0; i<n; i++) result = (result<<8) + pgm_read_byte(&Tune[Ptr++]);
  4.   return result;
  5. }
复制代码

readVariable()以MIDI变量精度格式读取数字。这可以包含一到四个字节:

  1. unsigned long readVariable () {
  2.   long result = 0;
  3.   uint8_t b;
  4.   do {
  5.     b = pgm_read_byte(&Tune[Ptr++]);
  6.     result = (result<<7) + (b & 0x7F);
  7.   } while (b & 0x80);
  8.   return result;
  9. }
复制代码

每个字节对结果贡献7位;如果顶部位置位,则表示后面跟着另一个字节。


播放音符

解释器调用noteOn()在音乐盒的下一个可用通道上播放音符:

  1. void noteOn (uint8_t number) {
  2.   uint8_t octave = number/12;
  3.   uint8_t note = number%12;
  4.   unsigned int freq = Scale[note];
  5.   uint8_t shift = 9-octave;
  6.   Freq[Chan] = freq>>shift;
  7.   Amp[Chan] = 1<<Decay;
  8.   Chan = (Chan + 1) & 3;
  9. }
复制代码

播放MIDI数据

最后,这是播放MIDI数据的主要例程。变量Ptr是指向要读取的下一个字节的指针:

  1. void playMidiData () {
  2.   Ptr = 0;                                  // Begin at start of file
复制代码

MIDI文件中的第一个块是标题,它指定了音轨的数量和division(节拍):

  1. // Read header chunk
  2.   unsigned long type = readNumber(4);
  3.   if (type != MThd) error(1);
  4.   unsigned long len = readNumber(4);
  5.   unsigned int format = readNumber(2);
  6.   unsigned int tracks = readNumber(2);
  7.   unsigned int division = readNumber(2);    // Ticks per beat
  8.   TempoDivisor = (long)division*16000/Tempo;
复制代码

division是节拍中的细分数量;通常为960。然后我们读取指定数量的轨道块:

  1.   // Read track chunks
  2.   for (int t=0; t<tracks; t++) {
  3.     type = readNumber(4);
  4.     if (type != MTrk) error(2);
  5.     len = readNumber(4);
  6.     EndBlock = Ptr + len;
复制代码

然后,我们读取每个轨道块末尾的连续事件:

  1.     // Parse track
  2.     while (Ptr < EndBlock) {
  3.       unsigned long delta = readVariable();
  4.       uint8_t event = readNumber(1);
  5.       uint8_t eventType = event & 0xF0;   
  6.       if (delta > 0) Delay(delta/TempoDivisor);
复制代码

每个事件都指定delta,事件发生之前的时间延迟应该生效。对于同时发生的事件,delta为零。


元事件的事件类型为0xFF:

  1.       // Meta event
  2.       if (event == 0xFF) {
  3.         uint8_t mtype = readNumber(1);
  4.         uint8_t mlen = readNumber(1);
  5.         // Tempo
  6.         if (mtype == 0x51) {
  7.           Tempo = readNumber(mlen);
  8.           TempoDivisor = (long)division*16000/Tempo;
  9.         // Ignore other meta events
  10.         } else readIgnore(mlen);
复制代码

我们感兴趣的唯一一个是Tempo元事件,它指定以微秒为单位的节拍持续时间。默认情况下,这是500000;即半秒,相当于120bpm。


其余事件是MIDI事件,由事件类型中的第一个十六进制数字标识。我们感兴趣的唯一一个是0x90,Note On,它在下一个可用的音乐盒频道上播放音符:

  1.       // Note off - ignored
  2.       } else if (eventType == 0x80) {
  3.         uint8_t number = readNumber(1);
  4.         uint8_t velocity = readNumber(1);
  5.       // Note on
  6.       } else if (eventType == 0x90) {
  7.         uint8_t number = readNumber(1);
  8.         uint8_t velocity = readNumber(1);
  9.         noteOn(number);
  10.       // Polyphonic key pressure
  11.       } else if (eventType == 0xA0) readIgnore(2);
  12.       // Controller change
  13.       else if (eventType == 0xB0) readIgnore(2);
  14.       // Program change
  15.       else if (eventType == 0xC0) readIgnore(1);
  16.       // Channel key pressure
  17.       else if (eventType == 0xD0) readIgnore(1);
  18.       // Pitch bend
  19.       else if (eventType == 0xD0) readIgnore(2);
  20.       else error(3);
  21.     }
  22.   }
  23. }
复制代码

我目前忽略了速度值velocity,因为它不适用于音乐盒,但您可以使用它来设置音符的初始幅度。


我们必须识别其他MIDI事件以允许我们跳过它们,因为它们的长度会有所不同。最后,任何其他信号都会在错误LED上发出错误信号。


编译程序

为了能够支持四个通道,ATtiny85需要以16MHz时钟运行;幸运的是,它提供了16MHz时钟选项,无需晶体,使用内部PLL将内部8MHz时钟提升至16MHz。


我使用Spence Konde的ATTiny Core 编译了程序。 在Board菜单上的ATtinyCore标题下选择ATtiny25/45/85选项。 然后在后续菜单中选择 Timer 1 Clock: CPU, B.O.D. Disabled, ATtiny85, 16 MHz (PLL) 。 选择Burn Bootloader以适当设置保险丝。 然后使用ISP上传程序(系统内编程); 我使用的是Sparkfun的Tiny AVR Programmer Board。


以上就是整个MIDI数字音乐盒的程序。

回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

主题 32 | 回复: 41



手机版|

GMT+8, 2024-4-18 13:35 , Processed in 0.141782 second(s), 8 queries , Gzip On, MemCache On. Powered by Discuz! X3.5

YiBoard一板网 © 2015-2022 地址:河北省石家庄市长安区高营大街 ( 冀ICP备18020117号 )

快速回复 返回顶部 返回列表