基于ARM DSP库的OLED音乐频谱

音乐频谱是很多人都喜欢的制作之一,它的电路简单,效果酷炫,但是数字信号处理部分的代码却难倒了一大片人。ARM公司已经考虑到了这点,帮我们开发了官方的DSP库。本文介绍如何使用ARM提供的DSP库制作OLED音乐频谱。

在谈硬件之前,我们首先聊聊DSP库。DSP库是ARM官方提供的函数库,是CMSIS的一部分,同时以二进制库和源文件的形式提供,提供了各类数字信号处理相关的函数,例如FFT、矩阵运算、复数运算、快速三角函数等。这大大降低了编程的难度,同时在Cortex-M4F这类带硬件浮点与DSP指令集的芯片上,ARM官方的库也可以获得最高的效率。在比赛时,为了节约时间,一般使用的是二进制库,配合上官网提供的头文件,就可以快速将DSP加入自己的工程中。

这一次,我选择直接将源代码加入到自己的工程中,来学习DSP库源代码的结构。源代码可以从Keil内置的CMSIS源代码中寻找,文件路径与获取到的DSP库源代码如下图所示。

https://img.yuanze.wang/posts/oled-fft-dsp/dsp-lib.png
DSP库的全部源代码

可以看到,上图中的源代码包括了很多模块,但并不是所有模块都会用到。在制作音乐频谱时,主要通过FFT函数将时域的离散信号转换到频域,以便显示各个频率分量的幅值,进而实现音乐频谱的效果。因此,我们以这一部分代码为例来谈谈如何将DSP库的源代码加到自己的工程中。我们需要的FFT函数属于变换函数,即在TransformFunction文件夹中。打开文件夹,可以找到非常多的函数,如下图所示。

https://img.yuanze.wang/posts/oled-fft-dsp/transform-functions.png
TransformFunction文件夹中的文件

乍一看这么多的文件会没有头绪,但是这些文件的命名都是很规范的。这么多文件主要分为3类,分别为复数快速傅里叶变换(CFFT)、离散余弦变换(DCT)和实数快速傅里叶变换(RFFT)。在CFFT中,有基2(radix2)、基4(radix4)与基8(radix8)之分,基数越大,每一轮循环执行的运算就越多,相应的速度就越快,但程序会更加复杂,并且输入数据必须为基数的整数次幂。文件名最后代表的是这个函数所适用的数据类型,分别有f32(32位浮点数,float)、q15(16位有符号整形,int16_t)、q31(32位有符号整形,int32_t),根据需要选择即可。

音乐频谱并不需要相位的信息,因此选用实数FFT即可,但为了了解DSP库中复数的表示方法,我仍然使用复数FFT来进行后面的计算。由于ADC的精度为12位,因此选择16位整形的基4FFT即可完成要求。首先将arm_cfft_radix4_init_q15.c文件与arm_cfft_radix4_q15.c文件加入工程,这两个函数分别为计算与初始化计算实例使用的函数。

但此时,只有这两个函数是不够的。FFT运算需要使用大量的三角函数计算,在嵌入式处理器上直接计算三角函数开销过大,因此DSP库选择将常见点数的正弦余弦函数做成表格,直接存在ROM中,使用时直接查表,可以大量节约程序的运行时间。这个数学函数的表,在CommonTables文件夹的arm_common_tables.c文件中,因此这个文件也需要加入工程。同样的,这个文件夹中还有一个arm_const_structs.c文件,也是DSP库所有函数所共用的,这些函数都必须要加入到工程中。

同时,FFT函数还需要一个位翻转的函数,这个函数位于FFT的函数在一个文件夹中的arm_bitreversal.c中,也将其加入到工程中。这样,在只使用FFT库的情况下,总共5个文件被加入了工程中。接下来只需要在Keil的工程选项中,加入ARM_MATH_CM3ARM_MATH_ROUNDING的宏定义,就算完整地将FFT函数加入到了工程中。最终的工程截图如下图所示。

https://img.yuanze.wang/posts/oled-fft-dsp/keil.png
Keil工程截图

注意
STM32F103C8T6的ROM只有64K。在优化等级设置为0级时,arm_common_tables.c中未用到的表不会被优化掉,这会导致ROM空间不足,编译失败。因此,至少需要选择1级或以上的优化等级才能够保证编译成功。为了调试方便,建议选择1级的优化等级。

最后,将arm_math.h文件加入我们自己的源文件中。这个头文件是Keil自带的,如果不出意外,直接include之后就会找到Keil内置的对应文件。然后,试着编译一下工程,是不是已经可以编译成功了?接下来就可以使用DSP库中所带的FFT函数了。从移植的过程中已经了解到,FFT需要以下两个函数:

arm_status arm_cfft_radix4_init_q15(arm_cfft_radix4_instance_q15 * S, uint16_t fftLen, uint8_t ifftFlag, uint8_t bitReverseFlag);
void arm_cfft_radix4_q15(const arm_cfft_radix4_instance_q15 * S, q15_t * pSrc);

不难看出,这两个函数分别为初始化与转换对应的函数。对于初始化函数,第1个参数为指向FFT转换实例的指针,也就是函数的工作内存区。我们只需要声明一个对应的数据结构,并将指针赋予函数即可;第2个参数为FFT的点数,对于基4FFT,可以支持16, 64, 256, 1024点的FFT;第3个参数指定函数执行的是正变换0还是逆变换1;第4个参数可以设置输出结果是否要进行位翻转,设置为0时,结果进行按位取反。

对于转换函数来说,只有两个参数。第1个参数为FFT转换实例的指针,第2个就是需要进行转换的数据了。

传入数据的格式
FFT的长度为256点时,输入数据的长度应为512个,其中第0个数据为输入第0个数据的实部,第1个数据为输入第0个数据的虚部,以此类推。转换完毕后,输出的256个数据依然按照这个格式进行存储,但是这些数据前后对称,实际有效的只有前128个数据。

接下来,我们只需要调用转换函数就可以实现FFT的转换了。相关函数如下:

arm_cfft_radix4_instance_q15 scfft;//创建整型复数基4fft实例
arm_cfft_radix4_init_q15(&scfft,FFT_LEN,0,1);//正变换 结果不翻转
arm_cfft_radix4_q15(&scfft,FFT_Buf);//FFT转换

下面来讨论如何获得所需要的数据。对于STM32来说,其拥有着非常完善的数据采集体系,我们只需要使用DAC+DMA+定时器,即可采集到固定采样周期的指定长度的数据。

具体的过程是:将定时器按照采样频率配置好后,使能其触发信号输出;按照硬件电路配置ADC的输入通道与转换时间,并将转换触发器选择为定时器的触发信号源。这样,定时器即可周期性触发 ADC转换,生成采样信号。同时,为了减轻CPU负担,在这种周期性高速采集信号的时候,一般不使用ADC中断来采集数据,而是使用DMA,使能与ADC相关的流,每当ADC转换完成一个数据之后,DMA自动将其搬运到指定的内存区块中,并在搬运完指定数量的数据之后产生DMA中断,停止采样。这样,我们只需要使能上述的过程,待DMA中断产生后,内存缓冲区就已经存满了指定采样频率采样到的指定数量的数据。

下面是ADC与DMA初始化部分的代码。

void ADC_Config(void)
{
   GPIO_InitTypeDef GPIO_InitStructure;
   ADC_InitTypeDef ADC_InitStructure;
   TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
   RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
   RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE);
   
   RCC_ADCCLKConfig(RCC_PCLK2_Div4);//设置ADC分频系数
   
   TIM_TimeBaseStructInit(&TIM_TimeBaseStructure);
   TIM_TimeBaseStructure.TIM_Prescaler = 0;
   TIM_TimeBaseStructure.TIM_Period = SystemCoreClock/(FFT_FREQ_THRESHOLD*2);//采样频率为最高频率的2倍
   TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1;
   TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
   TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure);
   TIM_ARRPreloadConfig(TIM3, ENABLE);
   TIM_SelectOutputTrigger(TIM3, TIM_TRGOSource_Update);//选择TIM3的UPDATE事件更新为触发源
   
   TIM_Cmd(TIM3, ENABLE);
   
   GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1;
   GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
   GPIO_Init(GPIOA, &GPIO_InitStructure);//将PA1配置为模拟输入模式
   
   ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;//独立模式
   ADC_InitStructure.ADC_ScanConvMode = DISABLE;//关闭自动扫描转换
   ADC_InitStructure.ADC_ContinuousConvMode = DISABLE;//关闭连续转换
   ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T3_TRGO;//使用TIM3作为转换触发源
   ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;//数据右对齐
   ADC_InitStructure.ADC_NbrOfChannel = 1;//共使用1个通道
   ADC_Init(ADC1, &ADC_InitStructure);
   ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_55Cycles5);//设置ADC1的常规转换通道为通道1
   ADC_DMACmd(ADC1, ENABLE);//使能ADC1的DMA请求
   ADC_Cmd(ADC1, ENABLE);//启动ADC1
   ADC_ResetCalibration(ADC1);
   while(ADC_GetResetCalibrationStatus(ADC1));//禁止ADC1的校准功能
   ADC_StartCalibration(ADC1);
   while(ADC_GetCalibrationStatus(ADC1));//启动ADC1的校准功能
   ADC_SoftwareStartConvCmd(ADC1, ENABLE);//启动ADC1的软件触发转换
}
void DMA_Config(void)
{
   DMA_InitTypeDef DMA_InitStructure;
   NVIC_InitTypeDef NVIC_InitStructure;
   
   RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
   
   DMA_DeInit(DMA1_Channel1);
   DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)0x4001244C;//ADC1->DR
   DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)ADC_Buf;
   DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
   DMA_InitStructure.DMA_BufferSize = FFT_LEN;//数据长度
   DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
   DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
   DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
   DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;//数据长度16位
   DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;//单次转换模式
   DMA_InitStructure.DMA_Priority = DMA_Priority_High;
   DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
   DMA_Init(DMA1_Channel1, &DMA_InitStructure);
   DMA_ITConfig(DMA1_Channel1, DMA_IT_TC, ENABLE);//传输完成中断
   
   DMA_Cmd(DMA1_Channel1, ENABLE);//开启DMA1通道1
   
   NVIC_InitStructure.NVIC_IRQChannel = DMA1_Channel1_IRQn;//DMA1_Stream1
   NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;//抢占优先级0
   NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
   NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
   NVIC_Init(&NVIC_InitStructure);
}
void DMA1_Channel1_IRQHandler(void)
{
   if(DMA_GetFlagStatus(DMA1_FLAG_TC1))//传输完成中断
   {
      ADC_ConvFlag = 1;
      
      DMA_ClearITPendingBit(DMA_IT_TC);
   }
}

在主函数中,只需要初始化相关的外设,之后在主循环中启动ADC转换、进行FFT运算即可。主函数中的代码如下。

int main(void)
{
   uint16_t i;
   arm_cfft_radix4_instance_q15 scfft;//创建整型复数基4fft实例
   
   delay_init();
   arm_cfft_radix4_init_q15(&scfft,FFT_LEN,0,1);//正变换 结果不翻转
   
   OLED_Init();
   ADC_Config();
   
   while(1)
   {
      while(ADC_ConvFlag == 0);//等待转换完成
      ADC_ConvFlag = 0;
      
      for(i = 0;i < FFT_LEN;i ++)
      {
         FFT_Buf[i*2] = ADC_Buf[i];
         FFT_Buf[i*2+1] = 0;
      }
      //此时转换完成的数据已经从ADC缓冲区复制到FFT缓冲区 可以进行新的转换
      DMA_Config();//初始化DMA并启动新的一次转换
      
      arm_cfft_radix4_q15(&scfft,FFT_Buf);//FFT转换
      fft_show();
   }
}

最后的工作便是将转换完成之后的数据显示到OLED上了。这一部分代码在fft_show()函数中。函数首先将FFT转换完成之后的结果取模,变成各个频率信号分量的强度大小,然后再将数值绘制到一块与OLED显存同样大的内存区域中(因为OLED不能单独打点,且OLED的像素是横着排列的,与竖着的频谱不符),最后将图形绘制到OLED上即可。

void fft_show(void)
{
   uint16_t i, temp;
   uint8_t col_temp;
   
   for(i = 0;i < OLED_X;i ++)
   {
      FFT_Buf[i] = sqrt(FFT_Buf[2*i]*FFT_Buf[2*i] + FFT_Buf[2*i+1]*FFT_Buf[2*i+1]);//取复数的模
      //结束后FFT结果只剩下前半部分为模值
      if(FFT_Buf[i] > 63)//限幅
         FFT_Buf[i] = 63;
   }
   FFT_Buf[0] = 0;//去掉直流分量
   
   for(i = 0;i < OLED_X*OLED_Y;i ++)//清空OLED刷新缓存
   {
      OLED_VRAM[i] = 0;
   }
   
   for(i = 0;i < OLED_X;i ++)
   {
      col_temp = 7;
      temp = FFT_Buf[i];
      
      while(temp > 8)
      {
         OLED_VRAM[col_temp*OLED_X+i] = 0xFF;//先画整个字节的
         col_temp --;
         temp -= 8;
      }
      OLED_VRAM[col_temp*OLED_X+i] = 0xFF << (8-temp);//画最后的不足一个字节的
   }
   
   OLED_bmp(0,0,OLED_X,OLED_Y,OLED_VRAM);//将缓存刷新到屏幕上
}

由于输入的信号是一个驻极体麦克风和10K电阻对3.3V直接分压得到的,虽然可以直接得到就可供ADC采样的单极性信号,但在静态时输入信号在数组的地址0处存在一个直流分量,显示的时候最好把这个分量去掉,否则在屏幕最左侧会有一条竖线。

 STM32工程

下面是实际演示视频。