大多数 Arduino 都不支持 DAC,但是都会支持 PWM。理论上有足够大存储空间的开发板都可以用来直接播放声音,从原理上来说就是将PWM 输出当作是可变的模拟信号,通过PWM 间接实现了 DAC 的功能。 之前介绍过用Curie Nano 直接播放声音【参考1】,这次将音频信息存储在板子上的SPINOR中 。Curie Nano板载SPI 芯片的大小是2MB 和主控的存储空间相比大了很多。根据估计,最多可以存储 2*1024K/8K=256S的音频信息。基本上可以存储一首歌曲。
播放音频的这个大目标可以分解为两个个小目标: 一. 将歌曲信息存储到 SPI NOR 中; 二. 从SPINOR读取歌曲信息,按照原始的采样率播放出来 下面就是每一步的做法。 将歌曲存储到 SPI NOR 中首先,需要将音频的编码转化为8000Hz,8Bits。通常来说8000Hz 是人类感知音频旋律的下限,低于这个采样率,人们能够明显感知到与原始旋律存在差别。笔者使用的是免费 的“Switch Sound File Converter”这款软件。设置输出为 8000Hz,8 Bits 单声道的 PCM 未压缩格式。
接下来是要提取转化后的音频信息。对于单声道的WAV 文件来说,文件头是非常小的,完全可以和数据混合在一起而不需要特别处理。当然,在这步还需要计算一个大概的时间从而得到是否需要裁减文件大小。这步结束后,得到的是一个二进制文件。 下面的目标是将这个二进制存入SPI NOR中。这个步骤也是整个代码中最复杂和繁琐的部分。在 Arduino 101 中,自带了CurieSerialFlash 库,可以完成 SPI 的很多操作。 在执行代码前,需要运行示例文件中的 EraseEveryThing 将SPINOR 内容清除干净。SPINOR 的特性要求只有擦除之后才能写入。后面的代码中,如果遇到创建程序错误,不妨先考虑一下是不是忘记擦除了。 在这里,PC 上需要运行一个上位机程序,通过串口将数据发送给 Arduino 101。101 上运行的代码负责接收和写入SPINOR 中。为了让上下位机配合工作起来,这里还设计了一个简单的传输协议(理论上用 ZMODEM 之类的协议是最好的,但是会导致整体复杂度升高很多)。 协议很简单,传输分为两个阶段。第一个阶段是下位机等待上位机发送4字节长的文件大小;收到之后,下位机发送字符“N”通知上位机继续发送;然后就开始了第二阶段的传输,上位机将数据“打包”成一个带有顺序号码以及校验和的包。下位机收到之后进行检查,如果不正确,下位机发送“R”通知上位机重新发送;如果正确,下位机发送“N”通知上位机继续发送。重复着一个过程直到数据传输完毕。 下位机代码: [kenrobot_code]#include <SerialFlash.h>
#include <SPI.h>
//文件名
const char *filename = "1990.bin";
//缓冲大小,可以根据实际情况调整越大效率越高
#define BUFFERSIZE 16
//发送操作的超时设定
#define TIMEOUT 3000UL
//Arduino 101 上 SPI CS 是 21Pin
const int FlashChipSelect = 21;
SerialFlashFile file;
char buffer[BUFFERSIZE];
//文件大小
unsigned long filesize=0;
byte *p=(byte *)&filesize;
unsigned long total=0;
void setup() {
//收到的文件大小字节数,一共4位
byte c=0;
//每次收到的数值
byte r;
Serial.begin(115200);
//使用 Arduino 硬串口输出 Debug 信息
Serial1.begin(115200);
Serial1.println("Hello, world?");
//初始化 SPI 芯片
if (!SerialFlash.begin(ONBOARD_FLASH_SPI_PORT, ONBOARD_FLASH_CS_PIN)) {
Serial1.println("SPI init fail");
while (1); }
//如果没有收够4字节,则一直等待接收
while (c<4) {
while (Serial.available() > 0)
{
//比如文件大小为 0x10000, 那么上位机发出来的顺序是
// 00 00 01 00, 因此这里需要一个顺序的调换
r=Serial.read();
*(p+c)=r;
c++;
} //while (Serial.available() > 0)
} //while (c<4)
Serial1.print("filesize="); Serial1.println(filesize);
if (SerialFlash.create(filename, filesize)!= true)
{
Serial1.println("Can't create file!");
while (1) {};
}
Serial1.println("Create file success!!");
// Open the file
file = SerialFlash.open(filename);
//通知上位机继续发送
Serial.print('N');
}
void loop() {
byte buffer[BUFFERSIZE];
int i;
byte checksum;
Next:
//接收数据的字节数
int counter=0;
//接收超时的变量
unsigned long elsp=millis();
//如果接收数量小于缓冲区大小并且未超时,那么继续接收
while ((counter<BUFFERSIZE)&&(millis()-elsp<TIMEOUT))
{
while (Serial.available()>0)
{
buffer[counter]=Serial.read();
counter++;
} //while
}
//如果接收数量不足退出上面的循环
if (counter!=BUFFERSIZE) {
//通知上位机重新发送
Serial.print("R");
Serial1.print("R");
goto Next;
}
//检查接收到的数据校验和
checksum=0;
for (i=0;i<BUFFERSIZE-1;i++)
{
checksum=checksum+buffer;
//Serial.print(buffer);
//Serial.print(" ");
//Serial1.print(buffer);
//Serial1.print(" ");
}
//校验失败通知上位机重新发送
if (checksum!=0) {
Serial.print("R");
Serial1.print("R");
goto Next;
}
//写入文件
file.write(buffer, BUFFERSIZE-2);
//如果当前收到的总数据小于文件大小,那么要求上位机继续发送
if (total<filesize)
{
Serial.print('N');
//有效值只有 BUFFERSIZE-2
total=total+BUFFERSIZE-2;
Serial1.print("N");
}
//否则停止
else {
Serial1.print("Total received");
Serial1.print(total);
while (1==1) {}
} //else
}[/kenrobot_code] 上面的代码中,使用的是 16 Bytes 作为串口缓冲,一次传输的有效数据只有16-2=14个,效率为(16-2)/16=87.5%,如果觉得慢,可以尝试加大缓冲,这样能够明显加快速度。 对应发送数据的上位机代码如下:
[mw_shl_code=pascal,true]unit Unit2;
interface
uses
Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, Vcl.Graphics,
Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls, CPortCtl, CPort;
//每次发送的数据大小为 BUFFERSIZE - 2
//其余2个分别是 Checksum 和 order
Const
BUFFERSIZE=16;
type
TForm2 = class(TForm)
ComPort1: TComPort;
Button1: TButton;
Button2: TButton;
OpenDialog1: TOpenDialog;
Memo1: TMemo;
procedure Button1Click(Sender: TObject);
procedure Button2Click(Sender: TObject);
procedure FormClose(Sender: TObject; var Action: TCloseAction);
procedure ComPort1RxChar(Sender: TObject; Count: Integer);
private
{ Private declarations }
public
{ Public declarations }
end;
var
Form2: TForm2;
stream: TFileStream;
BytesSend:integer;
Index:BYTE;
Buffer:array[0..BUFFERSIZE-1] of byte; //发送文件的缓存
implementation
{$R *.dfm}
procedure TForm2.Button1Click(Sender: TObject);
begin
ComPort1.ShowSetupDialog;
end;
procedure TForm2.Button2Click(Sender: TObject);
begin
if OpenDialog1.Execute then
begin
Memo1.Lines.Add(OpenDialog1.FileName);
stream := TFileStream.Create(OpenDialog1.FileName,fmOpenRead);
Memo1.Lines.Add(IntToStr(stream.size)+' Bytes');
ComPort1.Open;
Memo1.Lines.Add('Connected!');
//发送文件大小
Buffer[0]:=stream.size and $FF;
Buffer[1]:=(stream.size shr 8) and $FF;
Buffer[2]:=(stream.size shr 16) and $FF;
Buffer[3]:=(stream.size shr 24) and $FF;
ComPort1.Write(Buffer,4);
Memo1.Lines.Add(IntToStr(Buffer[0])+' '+IntToStr(Buffer[1])+' '+IntToStr(Buffer[2])+' '+IntToStr(Buffer[3]));
BytesSend:=0;
Form2.Refresh;
end;
end;
procedure TForm2.ComPort1RxChar(Sender: TObject; Count: Integer);
var
s,t: String;
aSize,i:integer;
checksum:byte;
begin
ComPort1.ReadStr(s, Count);
if (s[Count]='N') or ((s[Count]='R')and(BytesSend=0)) then
begin
Memo1.Lines.Add('Received N');
if BytesSend<stream.size then
begin
fillchar(Buffer,BUFFERSIZE,0);
// 读取文件并取得实际读取出来的长度
aSize:=stream.Read(Buffer,BUFFERSIZE-2);
//计算Checksum, 就是 Buffer 第一个到倒数第二个加起来要求为0
checksum:=0;
for i := 0 to BUFFERSIZE-3 do
begin
checksum:=checksum-Buffer;
end;
//放置Checksum
Buffer[BUFFERSIZE-2]:=checksum;
//放置顺序号
Buffer[BUFFERSIZE-1]:=Index;
inc(Index);
ComPort1.Write(Buffer,BUFFERSIZE);
{t:='';
for i := 0 to BUFFERSIZE-1 do
begin
t:=t+ IntToHex(Buffer,2)+' ';
end;
Memo1.Lines.Add(t);
}
BytesSend:=BytesSend+aSize;
Form2.Caption:=IntToStr(BytesSend)+'/'+IntToStr(stream.size);
Form2.Refresh;
end
else
Memo1.Lines.Add('Completed!');
end;
//如果收到 R 就再次发送
if s[Count]='R' then
begin
ComPort1.Write(Buffer,BUFFERSIZE);
{ t:='';
for i := 0 to BUFFERSIZE-1 do
begin
t:=t+ IntToHex(Buffer,2)+' ';
end;
Memo1.Lines.Add(t);
}
Memo1.Lines.Add('R');
end;
end;
procedure TForm2.FormClose(Sender: TObject; var Action: TCloseAction);
begin
if Stream<>NIL then stream.Destroy;
ComPort1.Close;
end;
end.[/mw_shl_code] 配合上位机程序,把 1990.bin 存储到 CuireNano的SPI NOR中。
从SPINOR读取歌曲信息,按照原始的采样率播放出来同样使用CurieSerialFlash库,使用 readfile读取音频信息,再用 PWM 的Pin进行输出: [mw_shl_code=bash,true]#include <CurieSerialFlash.h>
#include <SPI.h>
#define OUTPIN 3
#define BUFFERLEN 1024
const int FlashChipSelect = 21; // digital pin for flash chip CS pin
SerialFlashFile file;
byte buffer[BUFFERLEN];
void setup() {
Serial.begin(115200);
if (!SerialFlash.begin(ONBOARD_FLASH_SPI_PORT, ONBOARD_FLASH_CS_PIN)) {
Serial.println("Unable to access SPI Flash chip");
}
file = SerialFlash.open("1990.bin");
analogWriteFrequency(OUTPIN, 64000);
}
void loop() {
for (uint32_t i=0;i<file.size() / sizeof(buffer);i++)
{
file.read(buffer,BUFFERLEN);
for (unsigned int j = 0; j < BUFFERLEN-1; j++)
{
analogWrite(OUTPIN, buffer[j]);
delayMicroseconds(125);
}
analogWrite(OUTPIN, buffer[BUFFERLEN-1]);
}
file.seek(0);
}[/mw_shl_code]
从前面的音频格式转换过程可以得知,应该用 8000Hz 的速度进行输出,读取文件速度如果无法跟上那么就会出现中断卡顿的问题。为了解决研究这个问题,使用示波器进行简单的实验。实验的方法是:读取之前设置GPIO 为高,读取完成之后设置 GPIO 为低。测试结果如下: 1. file.read(buffer,16); 2. file.read(buffer,64); 3. file.read(buffer,128); 最终,选择每次从SPINOR中取出 1024 Bytes,这个操作会花费将近100us,相应的,代码在读取之后省去一个125us的Delay。
在播放的时候,直接用D3播放声音比较小,还可以在喇叭前面添加诸如 LM358 用来放大声音。
工作视频:
https://v.qq.com/x/page/r0549d1loji.html
|