可能是电路最简单的MP3播放器

最近正在基于STM32F411+WM8978开发一个MP3播放器,支持WAV、FLAC和MP3格式,因此积累了好多音频方面的代码。于是,我脑中浮现了在制作一个播放器的念头,这个播放器最好能做到最简化,让所有拥有最小系统的人都可以体验到音乐的乐趣。

STM32F411的时钟频率有100MHz,那么能流畅播放MP3的最低频率能到多少呢?经过实测,30M!没想到流畅解码FLAC和MP3格式的音频居然这么轻松,突然觉得用F4做这个MP3有点浪费……于是脑洞大开,能不能用F1完成这个播放器呢?

网易云音乐下载得到的FLAC文件都是LV8的,解码需要90k左右的内存,F1自然是全系列都不能满足,但是LV2需要的内存和MP3差不多,需要30k多的内存,这样大部分大容量的F103就能满足了。由于懒得每首歌都转换,就没有移植FLAC的解码,其实是很好实现的,并且解码FLAC所需要的性能是比MP3小的。

由于使用F4芯片里面有独立的音频PLL和全功能的IIS,F1里面的IIS是残废的(除非使用外置时钟,否则时钟误差极大),因此就排除了用外置DAC的想法。突然,我想到大容量的芯片里面有内置的DAC!那么我们能不能就用这个DAC就输出音频呢!

首先查看选型表,我们常见的STM32F103RCT6就能满足我们的需求:STM32F103RC大容量系列,有48k的内存(据说是和ZET6一样的晶元,说不定能用上64K,但是这里48K已经足够),还有双路DAC,正好两个声道,用它做播放器,完全可以满足我们的要求。

等等…这个DAC是12位的。我们常见的音频文件都是16位的,这个DAC并不能接受我们常见的音频文件,需要进行一个转换,这也成了浪费我时间最多的步骤。有人可能会问,12位音质会不会很差啊?我可以负责的告诉你,音质绝对说的过去,至少木耳是听不出来和手机明显的差别的。

说完了DAC的位数,好多人可能还会怀疑这个内置DAC的性能问题。经过手册查询,这个DAC输出值从最低到最高需要4us,也就是250KHz,对于音频完全没问题,而且音频也不会出现幅度这么大的数据。并且带一个可配置的输出缓冲器,可以省掉外部的运放,直接驱动一定的负载,真的离极简设计越来越近了。

说干就干。首先将以前移植好的SD卡驱动简单移植,实现了文件的读取以后,就开始移植WAV播放程序。先移植它是因为WAV不需要解码,直接读文件送进去就好。这里需要提一下我程序框架的问题。由于以前已经有成熟的MP3,因此程序都是有一套框架的。基本的就是设置IIS等参数,然后读文件,等待当前缓冲区播放完再继续读取然后解码。因此我的程序中出现一些IIS字眼不要担心……只是直接移植,懒得改了。

音频播放是定时器+DAC+DMA,这个例程ST官方就有提供,我只是简单移植就让DAC双通道输出了正弦波。

定时器部分就是产生一个和音频文件采样率相同的时钟给DAC,让DAC按照这个速度去找DMA取数据,DMA有两个缓冲区,播放一个缓冲区的时候读取和解码另一个缓冲区,这样纯硬件的播放可以保证恒定并且正确的播放速度,并且不需要使用CPU进行数据的搬运。当一个缓冲区播放完以后,产生中断,切换缓冲区,继续读取。下面是DMA和定时器的初始化代码。

uint8_t AudioPlay_I2SConfig(uint8_t Bits,uint32_t SampleRate,uint16_t BufSize)
{
  DMA_InitTypeDef            DMA_InitStructure;
  TIM_TimeBaseInitTypeDef    TIM_TimeBaseStructure;
  
  TIM_DeInit(TIM2);
  TIM_TimeBaseStructInit(&TIM_TimeBaseStructure);
  TIM_TimeBaseStructure.TIM_Period = 72000000/SampleRate;
  TIM_TimeBaseStructure.TIM_Prescaler = 0;
  TIM_TimeBaseStructure.TIM_ClockDivision = 0;
  TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
  TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);
  TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update);
  TIM_Cmd(TIM2, ENABLE);
  
  DMA_DeInit(DMA2_Channel4);
  DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&(DAC->DHR12RD);
  DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)&DualSine12bit;
  DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST;
  DMA_InitStructure.DMA_BufferSize = BufSize;
  DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
  DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
  DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Word;
  DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Word;
  DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;
  DMA_InitStructure.DMA_Priority = DMA_Priority_High;
  DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
  
  DMA_Init(DMA2_Channel4, &DMA_InitStructure);
  DMA_Cmd(DMA2_Channel4, ENABLE);
  DMA_ITConfig(DMA2_Channel4,DMA_IT_TC|DMA_IT_HT,ENABLE);
  
  return 0;
}

接下来就是把缓冲区改成文件了。一到这儿我傻眼了,STM32F4的DMA有双缓冲模式但是F1没有。正在我一筹莫展的时候,我发现F1的DMA有一个半传输中断,这玩意儿不就和双缓冲一样的东西嘛。只需要取两倍的缓冲区大小,然后使能半传输和传输完成中断,就能实现类似双缓冲的无缝播放的效果。

void DMA2_Channel4_5_IRQHandler(void)
{
  if(DMA_GetITStatus(DMA2_IT_TC4))
    DataRequestFlag = 1;
  else if(DMA_GetITStatus(DMA2_IT_HT4))
    DataRequestFlag = 2;
  
  DMA_ClearITPendingBit(DMA2_IT_TC4 | DMA2_IT_HT4);
}
void* AudioPlay_GetCurrentBuff(void)
{
  if(DataRequestFlag == 1)
  {
    return (void*)((uint32_t)DualSine12bit + AudioPlayInfo.BufferSize);
  }
  else if(DataRequestFlag == 2)
  {
    return DualSine12bit;
  }
  else
  {
    return NULL;
  }
}

注意这里的DataRequestFlag一定要加__IO也就是volatile修饰,要不会被优化掉,卡死在while里。 这里的DualSine12Bit就是缓冲区了,根据中断类型返回是缓冲区起始地址还是加上一个缓冲区(也就是后一半)的地址。 有了数据,声音是出来的。但是杂音非常非常大,只能听到一点点音乐。我想起来我是直接把立体声16位的数据送给DAC的双通道左对齐寄存器DAC->DHR12LD,我原以为这样就能一次传输两个通道的数据并且舍弃掉低4位,但是我错了。查阅资料发现,IIS的16位数据是signed short,也就是有符号位的,并且负数是用补码保存的,然而我们这里的DAC应该工作在中值为2048(一半参考电压,加耦合电容),因此音频数据我们需要稍微处理一下,如下面程序所示,除以16再加2048转换成无符号数。

void AudioPlay_DataProc(uint16_t* buff,uint16_t num)
{
  uint16_t i;
  
  for(i = 0;i < num;i ++)
  {
    buff[i] = (((int16_t*)buff)[i] / 16 + 2048);
  }
}

经过这样处理,音频已经能很好地播放了,并且音质严重超出了我的预期,真的是很不错,并且DAC的驱动力真的很大,我的耳机串了一个1k的电阻,音量才到人耳舒适的范围,加上两个10uF的耦合电容,要是不算最小系统和SD卡模块总共只需要四个元件。由于电阻很大,因此电容很小也能获得很低的响应频率。

https://img.yuanze.wang/posts/mp3-dac/sch.png
小板子简易原理图

为了换歌曲方便,我加了两个按键,分别是上一曲和下一曲,还加了一个10K的双联音频电位器调整音量,自此一个WAV播放器就搞定了。

https://img.yuanze.wang/posts/mp3-dac/photo1.jpg
硬件整体照

硬件连接如上图所示,整个硬件主要由一个最小系统、一个SD卡模块以及一块自己焊接的小板子组成,小板子上总共有两个电阻、两个电容、一个电位器、两个按键和一个耳机座。连接单片机的端口可以在程序里修改,SD卡使用SPI2,CS=PB12CLK=PB13MISO=PB14MOSI=PB15。 整个播放器只用了8个IO口!要是只用一个下一曲还可以再省一个,最小系统周围一圈IO口显得空空荡荡的。

https://img.yuanze.wang/posts/mp3-dac/photo2.jpg
小板子正面

https://img.yuanze.wang/posts/mp3-dac/photo3.jpg
小板子背面

https://img.yuanze.wang/posts/mp3-dac/photo4.jpg
最小系统的IO看起来空荡荡的

https://img.yuanze.wang/posts/mp3-dac/photo5.jpg
SD卡模块

接下来的MP3解码也就轻而易举了。移植了Helix库,声音也很轻松的出来了。

最后,我还将MP3解码器移植了一份直接从ROM读取数据的版本,这样就可以方便的做开机语音和语音提示之类的了。程序只占用了40k,ROM还剩下了200K左右,放44.1KHz16Bit64KBps的单声道音频可以放半分钟多,足够了。有兴趣的可以用J-Link烧一个不带任何ID3信息的MP3文件到0x08020000也就是128k的地方感受一下开机语音。

 STM32工程