首页 > 评测 > 嵌入式音频

第三篇-嵌入式系统音频基本实践-播放声音之二

  • 作者:zhanzr
  • 来源:21ic
  • [导读]
  • Everyboard Can Sing

21ic打算携手资(tu)深(ding)直男癌晚期工程师zhanzr21,来给大家讲一讲嵌入式系统与音频处理的故事。

关于zhanzr21

曾经混迹于两岸三地,摸爬滚打在前端后端,搞过学术上过班。现在创业中,欢迎各种撩

点击链接加入群【嵌入式音频信号处理】:https://jq.qq.com/?_wv=1027&k=45wk8Ks

嵌入式音频专用资料代码分享:https://pan.baidu.com/s/1dFh5pWd

本期活动地址:bbs.21ic.com/icview-1713672-1-1.html

TIM截图20170428101100.jpg

1.说明

这一期继续上次的话题,还是播放.因为上次播放为了说明原理,使用了非常原始的软件结构,即使用定时器定时来更新DAC输出.虽然说明了音频播放的本质,但此种方法在实践中很少被用到.原因相信善于思考的读者早就猜到了.那就是对CPU的资源消耗很严重. 举上次的8K采样率为例子, CPU需要每125 us更新一次Sample. 这对于跑几十几百MHz的处理器来说不算什么. 但是通常嵌入式系统使用的20MHz左右的主频率,假使中断+更新操作100个指令周期完成(更新的数据源一般来自外接存储器,已经很保守估计). 那么此操作所占用的CPU资源:

Acpu = 100*0.05 / 125 = 4%

如果这个还算能接受的话, 那么加多一个通道,就是增加一倍. 采样率增多为16KHz则又是一倍. 就算这个占用率可以接受, 这还只是原始音频数据播放, 而实践中经常会使用某种编解码算法以减轻存储与传输的带宽压力. 即使使用更快的处理器可以负担得起这种浪费,从能耗的角度来看也不倾向于使用这种结构.

话说回来, 嵌入式系统的特点就是没有特定的规则, 如果简单的方法能实现设计目的, 也不能说绝对否定这种方法. 本系列文章的目的就是发扬探索精神, 将各种方法都来试验一把,品味其中优劣, 学习诸种原理. 以下介绍几种应用了其他技术的播放实验.

2.实践之一(DAC+DMA)

2.1 双缓冲播放

解决上文所叙的CPU资源占用问题简单直接的方法就是DMA传输. 当然DMA也不是每个处理器都有. 这里只是实验一下子有DMA的处理器如何将DMA利用起来.事实上几乎所有专门处理音频的处理器(DSP,或者音频ASIC)都利用DMA来传输音频数据.

还是接上回的实验, 直接使用上次所叙的DAC输出的硬件结构,接耳机,接音箱,接放大板都可以.

f722_nucleo_dac_earphone1.jpg

将软件改成这样的结构:

3.jpg

 

这个实验看起来简单,其实内容有点多.最主要的是Buffer管理. 下面简单讲讲这个buffer的管理过程.

假设Buffer大小为BUF_SIZE,那么第一次需要从资源处填充BUF_SIZE的内容到这个Buffer.开始DMA,这里注意这两个回调函数:

void HAL_DACEx_ConvCpltCallbackCh2(DAC_HandleTypeDef* hdac)

{

UpdatePointer = PLAY_BUFF_SIZE/2;

}

void HAL_DACEx_ConvHalfCpltCallbackCh2(DAC_HandleTypeDef* hdac)

{

UpdatePointer = 0;

}

分别是DMA传输完成与DMA传输完一半的回调函数.需要用户实现,如果用户不实现,将使用默认的HAL自带的回调函数:

__weak void HAL_DACEx_ConvCpltCallbackCh2(DAC_HandleTypeDef* hdac)

{

/* Prevent unused argument(s) compilation warning */

UNUSED(hdac);

/* NOTE : This function Should not be modified, when the callback is needed,

the HAL_DACEx_ConvCpltCallbackCh2 could be implemented in the user file

*/

}

__weak void HAL_DACEx_ConvHalfCpltCallbackCh2(DAC_HandleTypeDef* hdac)

{

/* Prevent unused argument(s) compilation warning */

UNUSED(hdac);

/* NOTE : This function Should not be modified, when the callback is needed,

the HAL_DACEx_ConvHalfCpltCallbackCh2 could be implemented in the user file

*/

}

看到前面那个__weak关键字没有,有__weak修饰表明这函数可以被重载,或者覆盖,或者隐藏. 这里也不知道该用什么术语,对C++或者其他面向对象语言有了解的同学应该一下子就能明白, 这里不节外生枝以后有时间再展开来说.

首先初始化buffer的时候,将buffer填充了第一次要播放的内容.

4.jpg

在T1时刻开始播放(就是DMA传输), 这时候主循环可以处理其他事务,只是定期检查一下子上面所说的Flag即可.如果BUF_SIZE设定的足够大,检查Flag的期限可以很长.

T2时刻发生了传输完成一半的事件,因此半传输回调函数被调用.通过检查Flag主循环知道了之后取数据填充在Buffer的前一半也就是绿色的那一半.

过一阵子在T3时刻又发生了传输完成事件,全传输回调函数被调用. 通过检查Flag主循环知道之后取数据填充在Buffer的后一半也就是红色的那一半.

如此循环下去播放每个Buffer期间软件只需要干预两次(比如把BUF_SIZE设定为可以装播放0.5秒的数据,则软件干预的间隔为250ms,如果没有很多其他任务,CPU完全可以在此期间Sleep,当然只有CPU能Sleep,其他外设还得干活.).

这种更新数据的方式称之为双缓冲方式.不仅仅是音频应用,很多类似的场合都能用上.

那么说DMA传输数据到DAC,采样率怎样控制呢? 答案是将DAC设为定时器更新触发.再将定时器的重装频率设置为想要的采样率即可.STM32系列的处理器中定时器6与定时器7是专为此功能设计的(这两个定时器当然也能当通用的定时器来用).

2.2 wav格式

这一期的实验烧写文件还是与上一集一样子.只是这次我直接烧写wav文件而不是原始的raw文件.所以这里简单的介绍一下子wave格式以理解实验代码.wave格式以后还要详细一点介绍.

简单的说wav文件就是一种容器格式RIFF的实例化.RIFF文件可以装各种数据,只是为人们熟知的就是wav文件了.我们这个实验中从文件头跳过44个字节就是原始音频数据了.

WAV文件 = WAV头 + 原始音频

这里给个简单的wav头的结构:

struct zWavHdr

{

FOURCC RiffHdr; //"RIFF"

uint32_t ChunkSize; //file size - 8

FOURCC WavHdr;//"WAVE"

FOURCC FmtHdr; //"fmt "

uint32_t HdrLen; //16, length of above

uint16_t DataType; //1->PCM

uint16_t ChanNo; //1 Channel

uint32_t SampleRate;//8000 Hz

uint32_t SamplePerSec; //8000 sample per second

uint16_t BytePerSample;//Bytes per sample

uint16_t BitsPerSample;//Bits per sample

FOURCC dataHdr;//"data"

uint32_t RawSize;//data size from this point

};

记得某大公司某年的笔试题目给出类似这样的一个结构, 对应试者进行提问. 所以有志于以后进大公司的同学们也可以将此结构体当做一个练习,理解一下子这样设计结构体的思路以后兴许用得上. 当然我这里给出的只是我自己进行简单应用的代码, 命名注释什么没怎么细究规范. 真正感兴趣的同学请自行找该公司官方发布的wav头实现代码进行学习. 不那么关心的同学可以今后看本连载了解, 因为以后还会讲到这个wav文件的.

3.实践之二(PWM+LPF)

上次写了文章后, 有人就说还有好多处理器没DAC呢,怎么办.

别担心,这里介绍一种所有处理器都能用的播放音频的方式.就是PWM+LPF的方式.这里LPF指的是低通滤波器.

5.jpg

事实上不只是PWM,PDM也能实现.就算没有PWM,PDM这样的外设,自己计算指令周期从而通过IO口拉高拉低也能实现这功能.

关于滤波器,最简单的一阶RC滤波器就行.根据实验结果,不要滤波器直接接耳机或者8欧姆的扬声器也可以.此种情况是因为PWM到扬声器的传输路径上的分布电容电阻与扬声器本身的分布参数构成了滤波器.但是不是每个扬声器都能构成合适的滤波器.这种直接连接方式一来对IO口负载过大, 多少有点风险(比DAC直接驱动耳机的风险大,因为PWM引脚输出全高的瞬间对外输出全部加在外部负载上, 不像DAC很少有满幅度输出的情况), 二来不利于说明原理, 所以这里还是不跳过LPF这个环节.

现在回到PWM+LPF这种典型方式.这种方式的原理其实就是用PWM+LPF做了一个伪DAC而已.关键在于滤波器的设计,将20KHz以上的频率分量滤去.那么每个周期的占空比就和输出的能量成线性比例关系了.

为了做这个实验,作者专门做了个滤波器板子.有两种滤波器实现,分别是有源一阶与无源二阶RC两种类型(其中无源的后面还是加了一级跟随以便于驱动耳机与扬声器).

电路图如下:

6.jpg

上图是有源的.

7.jpg

上图是无源的.

这两个滤波器经过实验,都可以用.下面一种效果稍为好一点.可能跟我使用的运放有点关系.因为正好手边没有Rail-to-Rail的运放随手拿了个运放焊接上去了.另外上述各种参数都需要在实际使用中进行调整,标注的都是我计算出来的值.购买器件也没有买高精度的,因为都属于模拟范畴的这里不多讲.

这是LPF板子的实物图:

 

8.jpg

 

lpf2.jpg

资源文件还是用之前实验用剩下的8KHz,8bit的样本. 这些都在本章的共享文件夹中可以找到.

为求多样化,新鲜感, 后面的实验尽量用不同的板子来做. 这一节的实验使用Arduino Uno板子来实现. 因为ATmega328的Flash大小为32KB, 所以在资源中截取3秒,也就是24KB的数据出来.

9.jpg

bin文件到C数组有很多种方法和现成工具,作者这里图个快捷,使用HexEdit的一个菜单复制出来.

10.jpg

重要代码如下:(完全代码在共享文件夹中找)

#include "resource.h"

#define speakerPin 11

#define SAMPLE_RATE 8000

uint32_t sample_idx = 0;

bool update_flag = true;

// the setup function runs once when you press reset or power the board

void setup() {

pinMode(2, OUTPUT);

pinMode(speakerPin, OUTPUT);

// 这里要修改PWM引脚的频率,因为Arduino的默认PWM输出使用了64分频,导致要使用的IO口最多只能输出几百Hz的PWM出来.详情请参阅完整代码

// 让定时器1每125us中断一次,这里上节讲过原理

}

ISR(TIMER1_COMPA_vect) {

sample_idx ++;

//翻转IO口以测试实际的更新率

}

void loop() {

if (update_flag)

{

if (sample_idx == RSC_SIZE)

{

//循环

}

//设定输出

update_flag = false;

}

}

代码跟上一篇文章的定时器+DAC的例子基本一样子,只是将DAC换成了PWM+LPF.下图是实际的工作图.

11.jpg

实验证明将信号转接至音箱效果更好, 这是因为小音箱内部也LPF的原因.

4.实践之三(I2S输出,以CS4344为例)

4.1 I2S信号连接

这个实验讲述的应该属于最主流的音频播放方式了. 就是通过I2S接口将数据传输给外部DAC. 使用外部音频DAC的动机大致有两条:

1.如之前的文章所述,就音频角度来讲,外部音频DAC的性能一般情况下都比内部DAC要强.ST与其类似处理器的DAC是被设计用来做测量控制类的应用的.F4,F7这一类的中高端片子所带的DAC也就只有12bit解析度. 而只要1美元或者之下的价格就能采购到16bit双通道的I2S接口音频DAC, 还自带耳放. 比较过两种DAC性能的读者应该有体会.

2.通过I2S或者类似的接口将数据以数字形式发送给外部音频DAC, 使得布线简化.可以将对干扰敏感的音频部分隔离开来. 包括对尺寸敏感的手机等移动设备也有时采用外部音频DAC. 这点还能成为宣传的噱头.比如至少有两个国产手机都曾使用外部音频DAC作为HIFI手机宣传的卖点.

感兴趣的读者可以浏览本人拍的视频,对比两种DAC的性能:

http://v.youku.com/v_show/id_XMjcwNDUyMDA1Mg==.html

首先看看实验图片:

12.jpg

本节实验是用一块本人自制的GD32F105RCT6的开发板子做演示. GD32的片子与ST的同后缀基本兼容, 除了某些细节比如HSE启动等等. 相关改动详情可以参阅共享文件夹中的代码.

由于篇幅问题,此处也不对此板子进行过多阐述.只是介绍一下I2S部分的信号接口, 其余部分将在今后的文章中介绍.

13.jpg

这四根脚连接到外部I2S的DAC即可.

14.jpg

四根线定义分别如下:

MCK:主时钟 = Fsample * SampleDepth * ChannelNum * 某个系数,此系数跟当前采样率相关.

CK: Bit时钟 = Fsample * SampleDepth * ChannelNum

SD: 数据线,相当I2C中的SDA

WS: 左右时钟 = Fsample

至于软件流程则跟本篇文章第一个实验的流程一样, 只是把DAC换成了I2S了.

关于I2S信号接口问题,以后还会讲到.

4.2 下载资源到Flash中

这一节的实验通过插件将音频数据下载到Flash中去. 在共享文件夹中找到"F105_AudioT2_25Q128.FLM"这个文件, 放在你安装的Keil的目录:

15.jpg

将资源文件定位于0xC0000000开始的虚拟位置,

#ifndef __SPI_AUDIO_INFO_H__

#define __SPI_AUDIO_INFO_H__

#include

#define E_FLASH_START_ADD 0xC0000000

//16bit 2ch 8K

#define AUDIO_SEC_1 0x00000000

#define SEC_1_SIZE 128000

//16bit 2ch 8K

#define AUDIO_SEC_2 (AUDIO_SEC_1+SEC_1_SIZE)

#define SEC_2_SIZE 485032

//8bit 1ch 8K

#define AUDIO_SEC_3 (AUDIO_SEC_2+SEC_2_SIZE)

#define SEC_3_SIZE 125548

#endif

...

#include "spi_audio_info.h"

static

const uint8_t raw_audio_16bit2ch8k_2[] __attribute__((at(E_FLASH_START_ADD + AUDIO_SEC_2))) = {

0x1F, 0x00, 0x1F, 0xFB, 0x54, 0xFF, 0xEA, 0xF9, 0x92, 0x00, 0xA7, 0xFB, 0x55, 0x00, 0x38, 0xFB,

....

};

选择Flash类型:

16.jpg

点下载就可以将音频数据下载到版上的Flash芯片中. 当然这个Flash插件是为此F105开发板定制的. 以后会介绍如何写这个插件.

更好的方法是通过USB与文件系统来下载音频数据. 或者下次做个能插TF卡的实验板子.

5.总结与后记

此篇补充了一些常用的播放音频的方式与实验.因为篇幅原因, 有些问题简化带过了. 期望今后讲到具体情况时候再加以铺陈展开. 读者如果有疑问或者建议也可在文章后面留言. 暂时打住,下期见!

  • 本文系21ic原创,未经许可禁止转载!

网友评论