使用支持复音的波表方法在AVR微控制器上生成声音

AVR微控制器相当便宜且普及。 大概,几乎所有嵌入式开发人员都从他们开始。 在业余爱好者中,Arduino掌控球,其心脏通常是ATmega328p。 当然,很多人都想知道:如何让它们听起来好?

如果您查看现有项目,它们有几种类型:

  1. 方脉冲发生器。 在中断中使用PWM或yank引脚生成。 无论如何,都能获得非常有特色的吱吱声。
  2. 使用外部设备,例如MP3解码器。
  3. 使用PWM以PCM或ADPCM格式输出8位(有时为16位)的声音。 由于微控制器中的内存显然不足以满足此要求,因此它们通常使用SD卡。
  4. 使用PWM生成基于MIDI等波形表的声音。

后一种类型对我来说特别有趣,因为 几乎不需要额外的设备。 我向社区介绍我的选择。 首先,一个小演示:



有兴趣的,我要猫。

因此,设备:

  • ATmega8或ATmega328。 移植到其他ATmega并不困难。 甚至在ATtiny上,以后还会更多。
  • 电阻;
  • 电容器
  • 扬声器或耳机;
  • 营养;

像一切。

具有扬声器的简单RC电路连接到微控制器的输出。 输出为8位声音,采样频率为31250Hz。 在8 MHz的晶体频率下,最多可以生成5个声音通道+ 1个用于打击乐的噪声通道。 在这种情况下,几乎所有处理器时间都被使用,但是在填充缓冲区之后,除了声音之外,处理器还可以占用一些有用的东西:


该示例完全适合ATmega8存储器,以8 MHz的晶振频率处理5通道+噪声,并且几乎没有时间在显示器上显示动画。

在此示例中,我还想表明该库不仅可以用作常规的音乐明信片,还可以将声音连接到现有项目,例如用于通知。 即使只使用一个声音通道,通知也比简单的高音扬声器有趣得多。

现在,细节...

波表或波表


数学非常简单。 存在周期性的音调函数,例如音调(t)= sin(t * freq /(2 * Pi))

还具有随时间改变基本音的音量的功能,例如, 音量(t)= e ^(-t)

在最简单的情况下,乐器的声音是这些功能的乘积乐器(t)=音调(t)*音量(t)

在图表上,一切看起来都像这样:



接下来,我们将使用在给定时间听起来的所有乐器,并用一些音量因子(伪代码)对其进行总结:

for (i = 0; i < CHANNELS; i++) { value += channels[i].tone(t) * channels[i].volume(t) * channels[i].volume; } 

只需要选择音量,以免溢出。 几乎就是全部。

噪声通道的工作方式几乎相同,但不是音调功能,而是伪随机序列发生器。

打击乐是噪声通道和低频波的混合体,频率约为50-70 Hz。
当然,以这种方式很难获得高质量的声音。 但是我们只有8 KB的存储空间。 希望可以原谅。

我可以从8位中挤出什么


最初,我专注于ATmega8。 它在没有外部石英的情况下以8 MHz的频率运行,并具有8位PWM,其基本采样频率为8000000/256 = 31250 Hz。 一个计时器使用PWM输出声音,并在溢出期间引起中断,以将下一个值传输到PWM发生器。 因此,我们有256个周期来计算所有内容的样本值,包括中断开销,更新声音通道参数,跟踪需要演奏下一个音符的时间等。

为了进行优化,我们将积极使用以下技巧:

  • 由于我们有一个八位处理器,因此我们将尝试使变量相同。 有时我们会使用16位。
  • 有条件地将计算分为频繁和不频繁。 需要为每个样本计算第一个样本,第二个-每几十个/几百个样本一次就更少了。
  • 为了使负载随时间平均分配,我们使用了循环缓冲区。 在程序的主循环中,我们填充缓冲区,然后在中断中减去它。 如果一切正常,则缓冲区的填充速度快于其清空的速度,我们还有时间进行其他操作。
  • 该代码是用C语言编写的,包含很多内联代码。 实践证明,它是如此之快。
  • 预处理器可以完成所有可以计算的操作,尤其是在除法运算的参与下。

首先,将时间划分为4毫秒的间隔(我称它们为刻度)。 在31250Hz的采样频率下,每个刻度获得125个采样。 必须读取每个样本的事实必须计算每个样本,其余的要计数-每滴答一次或更少一次。 例如,在一个刻度内,乐器的音量将保持不变: 乐器(t)=音调(t)* currentVolume ; 考虑到音量(t)和所选声道的音量,每个滴答声将自动重新计算currentVolume本身。

根据一个简单的8位限制选择了4ms的滴答持续时间:使用8位采样计数器,您可以以高达64 kHz的采样频率工作,而使用8位滴答计数器,我们可以测量高达1秒的时间。

一些代码


通道本身由以下结构描述:

 typedef struct { // Info about wave const int8_t* waveForm; // Wave table array uint16_t waveSample; // High byte is an index in waveForm array uint16_t waveStep; // Frequency, how waveSample is changed in time // Info about volume envelope const uint8_t* volumeForm; // Array of volume change in time uint8_t volumeFormLength; // Length of volumeForm uint8_t volumeTicksPerSample; // How many ticks should pass before index of volumeForm is changed uint8_t volumeTicksCounter; // Counter for volumeTicksPerSample // Info about volume uint8_t currentVolume; // Precalculated volume for current tick uint8_t instrumentVolume; // Volume of channel } waveChannel; 

有条件地,此处的数据分为3部分:

  1. 有关波形,相位,频率的信息。

    waveForm:有关音频(t)函数的信息:对256个字节长的数组的引用。 设置音调,乐器声音。

    waveSample:高字节指示waveForm数组的当前索引。

    waveStep:设置在计数下一个样本时增加waveSample的频率。

    每个样本都被认为是这样的:

     int8_t tone = channelData.waveForm[channelData.waveSample >> 8]; channelData.waveSample += channelaData.waveStep; return tone * channelData.currentVolume; 

  2. 卷信息。 设置随时间改变音量的功能。 由于音量变化不那么频繁,因此您可以减少计数的频率,每个刻度一次。 这样做是这样的:

     if ((channel->volumeTicksCounter--) == 0 && channel->volumeFormLength > 0) { channel->volumeTicksCounter = channel->volumeTicksPerSample; channel->volumeFormLength--; channel->volumeForm++; } channel->currentVolume = channel->volumeForm * channel->instrumentVolume >> 8; 

  3. 设置通道的音量和计算出的当前音量。

请注意:波形为8位,音量也为8位,结果为16位。 在性能略有下降的情况下,您可以使声音(几乎)达到16位。

为了提高生产力,我不得不诉诸于黑魔法。

示例编号1.如何重新计算通道数量:

 if ((tickSampleCounter--) == 0) { //    tickSampleCounter = SAMPLES_PER_TICK – 1; //   - } // volume recalculation should no be done so often for all channels if (tickSampleCounter < CHANNELS_SIZE) { recalculateVolume(channels[tickSampleCounter]); } 

因此,所有通道每个滴答声重新计算音量一次,但不是同时进行。

示例2。将信道信息保持在静态结构中比在数组中便宜。 在不赘述wavechannel.h的实现细节的情况下,我会说此文件使用不同的预处理程序指令多次插入到代码中(等于通道数)。 每个插入都会创建新的全局变量和新的通道计算函数,然后将它们内联到主代码中:

 #if CHANNELS_SIZE >= 1 val += channel0NextSample(); #endif #if CHANNELS_SIZE >= 2 val += channel1NextSample(); #endif … 

示例编号3。如果稍后再开始演奏下一个音符,则没人会注意到。 让我们想象一下这种情况:我们用了一些东西占用了处理器,在此期间缓冲区几乎是空的。 然后我们开始填充它,突然发现有一种新的衡量标准即将来临:我们需要更新当前注释,从数组中读取下一个内容,等等。 如果我们没有时间,那将是典型的口吃。 最好用旧数据填充一下缓冲区,然后再更新通道的状态。

 while ((samplesToWrite) > 4) { //          fillBuffer(SAMPLES_PER_TICK); //     -  updateMusicData(); //    } 

以一种很好的方式,有必要在循环后重新填充缓冲区,但是由于几乎所有内联函数,代码的大小明显过大。

乐曲


使用一个八位的滴答计数器。 当达到零时,开始新的小节,为计数器分配小节的持续时间(以滴答为单位),然后再检查音乐命令数组。

音乐数据存储在字节数组中。 它是这样写的:

 const uint8_t demoSample[] PROGMEM = { DATA_TEMPO(160), // Set beats per minute DATA_INSTRUMENT(0, 1), // Assign instrument 1 (see setSample) to channel 0 DATA_INSTRUMENT(1, 1), // Assign instrument 1 (see setSample) to channel 1 DATA_VOLUME(0, 128), // Set volume 128 to channel 0 DATA_VOLUME(1, 128), // Set volume 128 to channel 1 DATA_PLAY(0, NOTE_A4, 1), // Play note A4 on channel 0 and wait 1 beat DATA_PLAY(1, NOTE_A3, 1), // Play note A3 on channel 1 and wait 1 beat DATA_WAIT(63), // Wait 63 beats DATA_END() // End of data stream }; 

所有以DATA_开头的都是预处理器宏,它们将参数扩展为所需数量的数据字节。

例如,DATA_PLAY命令扩展为2个字节,并存储在其中:命令标记(1位),下一条命令之前的暂停(3位),播放音符的通道号(4位),有关音符的信息(8位)。 最大的限制是此命令不能用于长时间的暂停,最多7个小节。 如果需要更多,则需要使用DATA_WAIT命令(最多63个小节)。 不幸的是,我没有发现是否可以根据宏参数将宏扩展为数组的其他字节数。 甚至警告我也不知道如何显示。 也许你告诉我。

使用方法


在demos目录中,有几个针对不同微控制器的示例。 简而言之,这是自述文件的一部分,我真的没有什么可补充的:

 #include "../../microsound/devices/atmega8timer1.h" #include "../../microsound/micromusic.h" // Make some settings #define CHANNELS_SIZE 5 #define SAMPLES_SIZE 16 #define USE_NOISE_CHANNEL initMusic(); // Init music data and sound control sei(); // Enable interrupts, silence sound should be generated setSample(0, instrument1); // Use instrument1 as sample 0 setSample(1, instrument2); // Init all other instruments… playMusic(mySong); // Start playing music at pointer mySong while (!isMusicStopped) { fillMusicBuffer(); // Fill music buffer in loop // Do some other stuff } 

如果您想做音乐以外的其他事情,则可以使用BUFFER_SIZE来增加缓冲区的大小。 缓冲区大小应为2 ^ n,但是不幸的是,缓冲区大小为256时,会导致性能下降。 直到我弄清楚。

为了提高生产率,可以使用外部石英来增加频率,可以减少通道数量,可以降低采样频率。 使用最后一个技巧,您可以使用线性插值,在某种程度上可以补偿声音质量的下降。

不建议任何延迟,因为 浪费CPU时间。 相反,它自己的方法在microsound / delay.h文件中实现,该文件除了暂停本身外,还用于填充缓冲区。 此方法在短暂停时可能无法非常准确地工作,但在长暂停时或多或少地保持理智。

制作自己的音乐


如果手动编写命令,则需要能够监听发生的情况。 将每次更改都注入微控制器并不方便,特别是如果有其他选择的话。

有一个相当有趣的服务wavepot.com-在线JavaScript编辑器,您需要不时设置声音信号的功能,然后将该信号输出到声卡。 最简单的例子:

 function dsp(t) { return 0.1 * Math.sin(2 * Math.PI * t * 440); } 

我将引擎移植到JavaScript,它位于demos / wavepot.js中 。 该文件的内容必须插入编辑器wavepot.com中,然后您才能进行实验。 我们将数据写入soundData数组,听着,别忘了保存。

我们还应该提到Simulate8bits变量。 根据名称,她模拟了八位声音。 如果突然看来鼓声在嗡嗡作响,并且阻尼乐器发出的声音很安静,那就是八位声音的失真。 您可以尝试禁用此选项并听其区别。 如果音乐中没有静音,则问题将不那么明显。

连接方式


在一个简单的版本中,电路如下所示:

 +5V ^ MCU | +-------+ +---+VC | R1 | Pin+---/\/\--+-----> OUT | | | +---+GN | === C1 | +-------+ | | | --- Grnd --- Grnd 

输出引脚取决于微控制器。 必须根据负载,放大器(如果有)等选择电阻器R1和电容器C1。 我不是电子工程师,所以我不会给出公式;它们很容易与在线计算器一起在Google上进行搜索。

我的R1 = 130欧姆,C1 = 0.33 uF。 我将普通的中文耳机连接到输出。

16位声音到底是什么?


就像我上面说的,当我们将两个八位数字(频率和音量)相乘时,我们得到一个16位数字。 您不能将其舍入为8位,而是在2个PWM通道中输出两个字节。 如果以1/256的比例混合这两个声道,那么我们可以获得16位声音。 当只有一种乐器发出声音时,与八位的区别特别容易在平滑的渐弱声音和鼓声中听到。

16位输出连接:

 +5V ^ MCU | +-------+ +---+VCC | R1 | PinH+---/\/\--+-----> OUT | | | | | R2 | | PinL+---/\/\--+ +---+GND | | | +-------+ === C1 | | --- Grnd --- Grnd 

正确混合2个输出非常重要:R2电阻应比R1电阻大256倍。 越准确,越好。 不幸的是,即使误差为1%的电阻也无法提供所需的精度。 但是,即使选择的电阻不是很准确,失真也会明显降低。

不幸的是,当使用16位声音时,性能会降低,并且5声道+噪音不再有时间在分配的256个时钟周期内进行处理。

在Arduino上可以吗?


是的,你可以。 我在ATmega328p上只有一个中国的纳米克隆,它可以工作。 最有可能的是,ATmega328p上的其他arduins也应该起作用。 ATmega168似乎具有相同的定时器控制寄存器。 它们很可能将保持不变。 在其他需要检查的微控制器上,可能需要添加驱动程序。

demos / arduino328p中有一个草图,但是要使其在Arduino IDE中正常打开,您需要将其复制到项目的根目录。

在此示例中,将生成16位声音,并使用输出D9和D10。 为简化起见,您可以将自己限制为8位声音,并且仅使用一个D9输出。

由于几乎所有的arduins工作在16 MHz,因此,如果需要,可以将通道数增加到8。

那ATtiny呢?


ATtiny没有硬件乘法。 编译器使用的软件乘法速度非常慢,最好避免。 与ATmega相比,使用优化的汇编插件时,性能下降了2倍。 似乎根本没有用ATtiny的意义,但是...

一些ATtiny有一个倍频器PLL。 这意味着在这种微控制器上有两个有趣的功能:

  1. PWM发生器的频率为64 MHz,这提供了250 kHz的PWM周期,比任何ATmega上的8MHz的31250 Hz或16MHz的石英的62500 Hz都要好得多。
  2. 相同的倍频器允许晶体在没有石英的情况下以16 MHz时钟运行。

因此得出结论:某些ATtiny可用于产生声音。 他们设法处理相同的5个仪器+噪声通道,但工作频率为16 MHz,并且不需要外部石英。

不利的一面是无法再增加频率,并且计算几乎一直都在进行。 要释放资源,可以减少通道数或采样率。

另一个缺点是需要一次使用两个定时器:一个用于PWM,另一个用于中断。 计时器通常在这里结束。

在我所知道的PLL微控制器中,我可以提到ATtiny85 / 45/25(8脚),ATtiny861 / 461/261(20脚),ATtiny26(20脚)。

至于内存,与ATmega的区别不是很大。 在8kb中,几种乐器和旋律将非常适合。 以4kb为单位,您可以放置​​1-2个乐器和1-2个乐曲。 很难将2 KB的内容放入其中,但是如果您确实愿意,则可以。 必须分开这些方法,禁用某些功能,例如对通道进行音量控制,减少采样频率和通道数量。 总的来说,对于一个业余爱好者,但是在ATtiny26上有一个可行的例子。

问题所在


有问题。 最大的问题是计算速度。 该代码完全用C编写,带有用于ATtiny的小型汇编程序乘法插入。 优化是给编译器的,有时行为会很奇怪。 进行小的更改似乎不会影响任何更改,您可能会明显降低性能。 此外,从-Os更改为-O3并不总是有帮助。 这样的例子之一是使用256字节的缓冲器。 特别令人不快的是,不能保证在新版本的编译器中,我们不会在同一代码上降低性​​能。

另一个问题是根本没有实现下一个音符之前的衰减机制。 即 当在一个通道上将一个音符替换为另一个音符时,旧的声音会突然中断,有时会听到一声小声的喀哒声。 我想找到一种在不损失性能的情况下摆脱这种情况的方法,但是到目前为止。

没有命令可以平稳地增大/减小音量。 对于短通知铃声而言,这尤其重要,因为最后您需要快速衰减音量,以使声音不会突然中断。 问题的一部分是通过编写一系列带有手动设置音量和短暂暂停的命令。

原则上,选择的方法无法为乐器提供自然的声音。 要获得更自然的声音,您需要将乐器的声音分成攻击持续释放,至少使用前两个声部,并且持续时间比一个振荡周期长得多。 但是,该工具的数据将需要更多。 有一种想法是使用较短的波形表,例如,使用32字节而不是256个波形表,但是如果没有插值,声音质量会急剧下降,而插值会降低性能。 显然,另外8位采样对于音乐来说还不够,但这可以绕开。

缓冲区大小限制为256个样本。 这大约相当于8毫秒,这是可以分配给其他任务的最大积分时间段。 而且,任务的执行仍然由于中断而被周期性地暂停。

对于短暂的暂停,替换标准延迟不能非常准确地工作。

我确定这不是一个完整的列表。

参考文献


Source: https://habr.com/ru/post/zh-CN466121/


All Articles