《STM32H7R7开发指南 V1.1 》第六十章 录音机实验

第六十章 录音机实验


       上一章,我们实现了一个简单的音乐播放器,本章我们将在上一章的基础上,继续用ES8388实现一个简单的录音机,录制WAV格式的录音。

       60.1 SAI录音简介

       60.2 硬件设计

       60.3 软件设计

       60.4 下载验证


        60.1 SAI录音简介

       本章涉及的知识点基本上在上一章都有介绍。本章要实现WAV录音,还是和上一章一样,要了解:WAV文件格式、ES8388和SAI接口。WAV文件格式,我们在上一章已经做了详细介绍了,这里就不作介绍了。

       STM32H7R7开发板将板载的一个MIC分别接入到了ES8388的2个差分输入通道(LIP/LIN和RIP/RIN,原理图见:图59.2.1)。代码上,我们采用立体声WAV录音,不过,左右声道的音源都是一样的,录音出来的WAV文件,听起来就是个单声道效果。

       关于ES8388的录音设置(即ADC的相关设置),我们在上一章的介绍已经顺带都介绍了,所以本章我们就不再介绍ES8388的寄存器了,想了解读者可以参考第59章的介绍,或者参考本例程源代码和ES8388的pdf数据手册理解。

       上一章我们向大家介绍了STM32H7R7的SAI放音,通过上一章的了解,我们知道:STM32H7R7 SAI的全双工通信,需要用到SAI的两个子模块(SAI_A和SAI_B),一个工作在主模式,产生FS、SCK和MCLK,一个工作在从模式,通过SD引脚接收数据。

       本章我们必须向ES8388提供WS(FS),CK(SCK)和MCK(MCLK)等时钟,同时又要录音,所以只能使用全双工模式。工作在主模式的SAI子模块循环发送数据0X0000,给ES8388,以产生CK、WS和MCK等信号,工作在从模式的SAI子模块,则接收来自ES8388的ADC数据(ADCDAT),并保存到SD卡,实现录音。

       本章我们将同时使用SAI的两个子模块,以实现录音功能,SAI的相关寄存器,我们在上一章已经介绍的差不多了,这里就不再进行寄存器介绍,大家可以参考《STM32H7Rx参考手册_V6(英文版).pdf》第56.6小节。

       最后,我们看看要通过STM32H7R7的SAI,驱动ES8388实现WAV录音的简要步骤,如下:

       1)初始化ES8388

       这个过程就是前面所讲的ES8388 MIC录音配置步骤,让ES8388的ADC以及其模拟部分工作起来。

       2)初始化SAI_A和SAI_B

       本章要用到SAI的全双工模式,所以,SAI_A和SAI_B都需要配置,其中SAI_A配置为主模式,SAI_B设置为从模式,且与SAI_A同步。他们的其他配置(协议、时钟电平特性、slot相关参数)基本一样,只是一个是发送一个是接收,且都要使能DMA。同时,还需要设置音频采样率,不过这个只需要设置SAI_A的即可,还是通过上一章介绍的查表法设置。

       3)设置发送和接收DMA

       放音和录音都是采用GPDMA传输数据的,本章放音其实就是个幌子,不过也得设置GPDMA(使用GPDMA1的通道0),配置同上一章一模一样,不过不需要开启GPDMA传输完成中断。对于录音,则使用的是GPDMA1的通道1实现的GPDMA数据接收,我们需要配置GPDMA1,本章将使用双缓冲循环模式,外设和存储器都是16位宽,并开启传输完成中断(方便接收数据)。

       4)编写接收通道DMA传输完成中断服务函数

       为了方便接收音频数据,我们使用DMA传输完成中断,每当一个缓冲接数据满了,硬件自动切换为下一个缓冲,同时进入中断服务函数,将已满缓冲的数据写入SD卡的wav文件。过程如图60.1.1所示:


图60.1.1 DMA双缓冲接收音频数据流框图


       5)创建WAV文件,并保存wav头

       前面4步完成,其实就可以开始读取音频数据了,不过在录音之前,我们需要先在创建一个新的文件,并写入wav头,然后才能开始写入我们读取到的的PCM音频数据。

       6)开启DMA传输,接收数据

       然后,我们就只需要开启DMA传输,然后及时将SAI_B读到的数据写入到SD卡之前新建的wav文件里面,就可以实现录音了。

       7)计算整个文件大小,重新保存wav头并关闭文件

       在结束录音的时候,我们必须知道本次录音的大小(数据大小和整个文件大小),然后更新wav头,重新写入文件,最后因为FATFS,在文件创建之后,必须调用f_close,文件才会真正体现在文件系统里面,否则是不会写入的!所以最后还需要调用f_close,以保存文件。


        60.2 硬件设计


       1. 例程功能

       本章实验功能简介:开机后,先初始化各外设,然后检测字库是否存在,如果检测无问题,再检测SD卡根目录是否存在RECORDER文件夹,如果不存在则创建,如果创建失败,则报错。在找到SD卡的RECORDER文件夹后,即进入录音模式(包括配置ES8388和SAI等),此时可以在耳机(或喇叭)听到采集到的音频。KEY0用于开始/暂停录音,KEY1用于保存并停止录音,KEY_UP用于播放最近一次的录音。

       当我们按下KEY0的时候,可以在屏幕上看到录音文件的名字、码率以及录音时间等,然后通过KEY1可以保存该文件,同时停止录音(文件名和时间也都将清零),在完成一段录音后,我们可以通过按KEY_UP按键,来试听刚刚的录音。LED0用于提示程序正在运行,LED1用于提示是否处于暂停录音状态。


       2. 硬件资源

       本实验,大家需要准备1个microSD/SD卡(在里面新建一个MUSIC文件夹,并存放一些歌曲在MUSIC文件夹下)和一个耳机(非必备),分别插入SD卡接口和耳机接口,然后下载本实验就可以实现录音机的效果。实验用到的硬件资源如下:


       1)LED灯

              LED0 – PD14

              LED1 – PC0


       2)独立按键

              KEY0- PE9

              KEY1- PE8

              KEY2- PE7

              KEY_UP- PC13 (程序中的宏名:WK_UP)


       3)串口1 (PB14/PB15连接在板载USB转串口芯片CH340上面)


       4)正点原子2.8/3.5/4.3/7/10寸TFTLCD模块(包括MCU屏和RGB屏,都支持)


       5)SD卡:通过SDMMC连接


       6)NOR FLASH


       7)SAI,驱动ES8388芯片


       8)开发板板载的咪头或自备麦克风输入


       9)喇叭或耳机


       录音机实验与上一章(音乐播放器实验)用到的硬件资源基本一样,我们这里就不重复介绍了,有差异的是这次我们用到板载的咪头用于信号输入,也可以通过3.5mm的音频接口通过LINE_IN接入麦克风输入录音音源。


        60.3 程序设计


       60.3.1 程序解析


       1. recorder驱动代码

       这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码,RECORDER的驱动主要包括两个文件:recorder.c和recorder.h。

       音乐播放器实验中我们已经学过配置ES8388的方法,我们在recoder.c编写函数配置ES8388工作在PCM录音模式,我们编写代码如下:

/**
 * @brief       进入PCM 录音模式
 * @param       无
 * @retval      无
 */
void recoder_enter_rec_mode(void)
{
SAI1_TX_DMACx->CCR &= ~DMA_IT_TC; 
/* 关闭传输完成中断(这里不用中断送数据) (如果在这里不关闭dma就会卡在清空数据过程)*/
    sai1_tx_callback = NULL;
 
    es8388_adda_cfg(0, 1);          /* 开启ADC */
    es8388_input_cfg(0);            /* 开启输入通道(通道1,MIC所在通道) */
    es8388_mic_gain(8);             /* MIC增益设置为最大 */
    es8388_alc_ctrl(3, 4, 4);       /* 开启立体声ALC控制,以提高录音音量 */
    es8388_output_cfg(0, 0);        /* 关闭通道1和2的输出 */
    es8388_spkvol_set(0);           /* 关闭喇叭. */
    es8388_sai_cfg(0, 3);           /* 飞利浦标准,16位数据长度 */
    
sai1_saib_init(SAI_MODEMASTER_TX, SAI_CLOCKSTROBING_RISINGEDGE, 
SAI_DATASIZE_16);   /* SAI1 Block B,主发送,16位数据 */
sai1_saia_init(SAI_MODESLAVE_RX, SAI_CLOCKSTROBING_RISINGEDGE, 
SAI_DATASIZE_16);   /* SAI1 Block A从模式接收,16位 */
 
    sai1_samplerate_set(REC_SAMPLERATE);    /* 设置采样率 */
 
sai1_tx_dma_init((uint8_t *)&SAI_PLAY_BUF[0], (uint8_t *)&SAI_PLAY_BUF[1], 
1, 1);   /* 配置TX DMA,16位 */
    SAI1_TX_DMACx->CCR |= DMA_IT_TC;
sai1_rx_dma_init(p_sai_recbuf1, p_sai_recbuf2, REC_SAI_RX_DMA_BUF_SIZE / 2,
 1);     /* 配置RX DMA */
 
sai1_rx_callback = recoder_sai_dma_rx_callback;     
/* 初始化回调函数指sai_rx_callback */
 
    sai1_play_start();           /* 开始SAI数据发送(主机) */
    sai1_rec_start();            /* 开始SAI数据接收(从机) */
 
    recoder_remindmsg_show(0);
}

       该函数就是用我们前面介绍的方法,激活ES8388的PCM模式,本章,我们使用的是44.1Khz采样率,16位单声道线性PCM模式,以及发送与接收DMA配置。

       由于最后要把录音写入到文件,这里需要准备wav的文件头,为方便,我们定义了一个__WaveHeader结构体来定义文件头的数据字节,这个结构体包含了前面提到的wav文件的数据结构块:

typedef __PACKED_STRUCT
{
    ChunkRIFF riff;                /* riff块 */
    ChunkFMT fmt;                /* fmt块 */
    //ChunkFACT fact;            /* fact块 线性PCM,没有这个结构体 */
    ChunkDATA data;              /* data块 */
} __WaveHeader;

       我们定义一个recoder_wav_init()函数方便初始化文件信息,代码如下:

void recoder_wav_init(__WaveHeader *wavhead)
{
    wavhead->riff.ChunkID = 0x46464952;   /* RIFF" */
    wavhead->riff.ChunkSize = 0;          /* 还未确定,最后需要计算 */
    wavhead->riff.Format = 0x45564157;    /* "WAVE" */
    wavhead->fmt.ChunkID = 0x20746D66;    /* "fmt " */
    wavhead->fmt.ChunkSize = 16;          /* 大小为16个字节 */
    wavhead->fmt.AudioFormat = 0x01;      /* 0x01,表示PCM; 0x00,表示IMA ADPCM */
    wavhead->fmt.NumOfChannels = 2;       /* 双声道 */
    wavhead->fmt.SampleRate = REC_SAMPLERATE;/* 采样速率 */
wavhead->fmt.ByteRate=wavhead->fmt.SampleRate*4;
/* 字节速率=采样率*通道数*(ADC位数/8) */
    wavhead->fmt.BlockAlign = 4;            /* 块大小=通道数*(ADC位数/8) */
    wavhead->fmt.BitsPerSample = 16;        /* 16位PCM */
    wavhead->data.ChunkID = 0x61746164;     /* "data" */
    wavhead->data.ChunkSize = 0;            /* 数据大小,还需要计算 */
}

       录音完成我们还要重新计算录音文件的大小写入文件头,以保证音频文件能正常被解析。我们把这些数据直接按顺序写入文件即可完成录音操作,结合文件操作和按键功能定义,我们用wav_recoder()函数实现录音过程,代码如下:

/**
 * @brief       WAV录音
 * @param       无
 * @retval      无
 */
void wav_recorder(void)
{
    uint8_t res, i;
    uint8_t key;
    uint8_t rval = 0;
    uint32_t bw;
    
    __WaveHeader *wavhead = 0;
    DIR recdir;                                 /* 目录 */
    FIL *f_rec = 0;                             /* 录音文件 */
    
    uint8_t *pdatabuf;                          /* 数据缓存指针 */
    uint8_t *pname = 0;
    uint8_t timecnt = 0;                        /* 计时器 */
    uint32_t recsec = 0;                        /* 录音时间 */
 
    while (f_opendir(&recdir, "0:/RECORDER"))   /* 打开录音文件夹 */
    {
        lcd_show_string(30, 230, 240, 16, 16, "RECORDER文件夹错误!", RED);
        delay_ms(200);
        lcd_fill(30, 230, 240, 246, WHITE);     /* 清除显示 */
        delay_ms(200);
        f_mkdir("0:/RECORDER");                 /* 创建该目录 */
    }
 
    /* 申请内存 */
    for (i = 0; i < REC_SAI_RX_FIFO_SIZE; i++)
    {
        p_sai_recfifo_buf[i] = mymalloc(SRAMIN, REC_SAI_RX_DMA_BUF_SIZE); 
/* SAI录音FIFO内存申请 */
 
        if (p_sai_recfifo_buf[i] == NULL)
        {
            break;  /* 申请失败 */
        }
    }
 
p_sai_recbuf1 = mymalloc(SRAMIN, REC_SAI_RX_DMA_BUF_SIZE);          
/* SAI录音内存1申请 */
p_sai_recbuf2 = mymalloc(SRAMIN, REC_SAI_RX_DMA_BUF_SIZE);          
/* SAI录音内存2申请 */
f_rec = (FIL *)mymalloc(SRAMIN, sizeof(FIL));                       
/* 开辟FIL字节的内存区域 */
wavhead = (__WaveHeader *)mymalloc(SRAMIN, sizeof(__WaveHeader));   
/* 开辟__WaveHeader字节的内存区域 */
pname = mymalloc(SRAMIN, 30);   
/* 申请30个字节内存,文件名类似"0:RECORDER/REC00001.wav" */
 
    if (!p_sai_recbuf2 || !f_rec || !wavhead || !pname)
    {
        rval = 1;
    }
 
    if (rval == 0)
    {
        recoder_enter_rec_mode();/* 进入录音模式,此时耳机可以听到咪头采集到的音频 */
        pname[0] = 0;            /* pname没有任何文件名 */
 
        while (rval == 0)
        {
            key = key_scan(0);
 
            switch (key)
            {
                case KEY1_PRES:                  /* STOP&SAVE */
                    if (g_rec_sta & 0x80)        /* 有录音 */
                    {
                        g_rec_sta = 0;                           /* 关闭录音 */
                        wavhead->riff.ChunkSize = g_wav_size + 36; 
/* 整个文件的大小-8; */
                        wavhead->data.ChunkSize = g_wav_size;    /* 数据大小 */
                        f_lseek(f_rec, 0);                         
/* 偏移到文件头. */
                        f_write(f_rec, (const void *)wavhead, 
sizeof(__WaveHeader), &bw); /* 写入头数据 */
                        f_close(f_rec);
                        g_wav_size = 0;
                    }
 
                    g_rec_sta = 0;
                    recsec = 0;
                    LED1(1);                      /* 关闭DS1 */
                    lcd_fill(30, 190, lcddev.width, lcddev.height, WHITE); 
/* 清除显示,清除之前显示的录音文件名 */
                    break;
 
                case KEY0_PRES:                   /* REC/PAUSE */
                    if (g_rec_sta & 0x01)         /* 如果是暂停,继续录音 */
                    {
                        g_rec_sta &= 0xFE;        /* 取消暂停 */
                    }
                    else if (g_rec_sta & 0x80)    /* 已经在录音了,暂停 */
                    {
                        g_rec_sta |= 0x01;        /* 暂停 */
                    }
                    else    /* 还没开始录音 */
                    {
                        recsec = 0;
                        recoder_new_pathname(pname);    /* 得到新的名字 */
                        text_show_string(30, 190, lcddev.width, 16, "录制:", 
16, 0, RED);
                        text_show_string(30 + 40, 190, lcddev.width, 16, 
(char *)pname + 11, 16, 0, RED); /* 显示当前录音文件名字 */
                        recoder_wav_init(wavhead);      /* 初始化wav数据 */
                        res = f_open(f_rec, (const TCHAR *)pname,
FA_CREATE_ALWAYS | FA_WRITE);
 
                        if (res)                        /* 文件创建失败 */
                        {
                            g_rec_sta = 0;              /* 创建文件失败,不能录音 */
                            rval = 0xFE;                /* 提示是否存在SD卡 */
                        }
                        else
                        {
                            res = f_write(f_rec, (const void *)wavhead,
                            sizeof(__WaveHeader), &bw); /* 写入头数据 */
                            recoder_msg_show(0, 0);
                            g_rec_sta |= 0x80;          /* 开始录音 */
                        }
                    }
 
                    if (g_rec_sta & 0x01)
                    {
                        LED1(0);            /* 提示正在暂停 */
                    }
                    else 
                    {
                        LED1(1);
                    }
                    break;
 
                case WKUP_PRES:             /* 播放最近一段录音 */
                    if (g_rec_sta != 0x80)  /* 没有在录音 */
                    {
                        if (pname[0])       /* 如果按键被按下,且pname不为空 */
                        {
                            text_show_string(30, 190, lcddev.width, 16, "播放:",
16, 0, RED);
                            text_show_string(30 + 40, 190, lcddev.width, 16,
          (char *)pname + 11, 16, 0, RED); 
/* 显示当播放的文件名字 */
                            recoder_enter_play_mode();  /* 进入播放模式 */
                            audio_play_song(pname);     /* 播放pname */
                            lcd_fill(30, 190, lcddev.width, lcddev.height,
                           WHITE); /* 清除显示,清除之前显示的录音文件名 */
                            recoder_enter_rec_mode();   /* 重新进入录音模式 */
                        }
                    }
                    break;
            }
 
            if (recoder_sai_fifo_read(&pdatabuf))   
/* 读取一次数据, 读到数据了, 写入文件 */
            {
                res = f_write(f_rec, pdatabuf, REC_SAI_RX_DMA_BUF_SIZE, &bw);
                /* 写入文件 */
 
                if (res)
                {
                    printf("write error:%d\r\n", res);
                }
 
                g_wav_size += REC_SAI_RX_DMA_BUF_SIZE;  /* WAV数据大小增加 */
            }
            else 
            {
                delay_ms(5);
            }
            
            timecnt++;
 
            if ((timecnt % 20) == 0)
            {
                LED0_TOGGLE();                               /* LED0闪烁 */
            }
 
            if (recsec != (g_wav_size / wavhead->fmt.ByteRate))    
/* 录音时间显示 */
            {
                LED1_TOGGLE();  /* LED1闪烁 */
                recsec = g_wav_size / wavhead->fmt.ByteRate;  /* 录音时间 */
                recoder_msg_show(recsec, wavhead->fmt.SampleRate * 
wavhead->fmt.NumOfChannels * wavhead->fmt.BitsPerSample); 
/* 显示码率 */
            }
        }
    }
    
    for (i = 0; i < REC_SAI_RX_FIFO_SIZE; i++)
    {
        myfree(SRAMIN, p_sai_recfifo_buf[i]);   /* 录音FIFO内存释放 */
    }
    
    myfree(SRAMIN, p_sai_recbuf1);  /* 释放内存 */
    myfree(SRAMIN, p_sai_recbuf2);  /* 释放内存 */
    myfree(SRAMIN, f_rec);          /* 释放内存 */
    myfree(SRAMIN, wavhead);        /* 释放内存 */
    myfree(SRAMIN, pname);          /* 释放内存 */
}


       2. main.c代码

       由于我们把大部分功能已经在wav_recoder()中实现了,main函数进行必要的外设初始化,显示相关的数据信息后,调用该接口即可实现我们需要的录音机功能了,最后我们在main.c中实现代码如下:

int main(void)
{
    sys_mpu_config();                   /* 配置MPU */
    sys_cache_enable();                 /* 使能Cache */
    HAL_Init();                         /* 初始化HAL库 */
    sys_stm32_clock_init(300, 6, 2);    /* 配置时钟,600MHz */
    delay_init(600);                    /* 初始化延时 */
    usart_init(115200);                 /* 初始化串口 */
    led_init();                         /* 初始化LED */
    key_init();                         /* 初始化按键 */
    hyperram_init();                    /* 初始化HyperRAM */
    lcd_init();                         /* 初始化LCD */
    my_mem_init(SRAMIN);                /* 初始化AXI-SRAM1~4内存池 */
    my_mem_init(SRAMEX);                /* 初始化XSPI2 HyperRAM内存池 */
    my_mem_init(SRAM12);                /* 初始化AHB-SRAM1~2内存池 */
    my_mem_init(SRAMDTCM);              /* 初始化DTCM内存池 */
    my_mem_init(SRAMITCM);              /* 初始化ITCM内存池 */
    exfuns_init();                      /* 为exfuns申请内存 */
    f_mount(fs[0], "0:", 1);            /* 挂载SD卡 */
    f_mount(fs[1], "1:", 1);            /* 挂载NOR Flash */
    f_mount(fs[2], "2:", 1);            /* 挂载NAND Flash */
    
    /* 初始化SD卡 */
    while (sd_init() != 0)
    {
        lcd_show_string(30, 30, 200, 16, 16, "SD Card Error!", RED);
        delay_ms(500);
        lcd_show_string(30, 30, 200, 16, 16, "Please Check! ", RED);
        delay_ms(500);
    }
    lcd_fill(30, 30, 30 + 200, 30 + 16, WHITE);
    
    /* 检查字库 */
    while (fonts_init() != 0)
    {
        lcd_show_string(30, 30, 200, 16, 16, "Font Error!  ", RED);
        delay_ms(500);
        lcd_show_string(30, 30, 200, 16, 16, "Please Check!", RED);
        delay_ms(500);
    }
    lcd_fill(30, 30, 30 + 200, 30 + 16, WHITE);
    
    text_show_string(30, 50, 200, 16, "正点原子STM32开发板",16,0, RED);
    text_show_string(30, 70, 200, 16, "录音机实验", 16, 0, RED);
    text_show_string(30, 90, 200, 16, "正点原子@ALIENTEK", 16, 0, RED);
    
    es8388_init();                      /* ES8388初始化 */
    es8388_hpvol_set(25);               /* 设置耳机音量 */
    es8388_spkvol_set(25);              /* 设置喇叭音量 */
    
    while (1)
    {
        wav_recorder();
    }
}

       可以看到main函数与音乐播放器实验十分类似,封装好了APP,main函数会精简很多。


        60.4 下载验证

       在代码编译成功之后,我们下载代码到正点原子STM32H7R7开发板上,先初始化各外设,然后检测字库是否存在,如果检测无问题,再检测SD卡根目录是否存在RECORDER文件夹,如果不存在则创建,如果创建失败,则报错。在找到SD卡的RECORDER文件夹后,即进入录音模式(包括配置ES8388和SAI等),此时可以在耳机(或喇叭)听到采集到的音频。KEY0用于开始/暂停录音,KEY1用于保存并停止录音,KEY_UP用于播放最近一次的录音。


图60.4.1 录音机实验界面


       此时,我们按下KEY0就开始录音了,此时看到屏幕显示录音文件的名字以及录音时长,如图60.4.2所示:


图60.4.2 录音进行中


       在录音的时候按下KEY0则执行暂停/继续录音的切换,通过LED0指示录音暂停。通过按下KEY1,可以停止当前录音,并保存录音文件。在完成一次录音文件保存之后,我们可以通过按KEY_UP按键,来实现播放这个录音文件(即播放最近一次的录音文件),实现试听。

       我们可以把录音完成的wav文件放到电脑上,可以通过一些播放软件播放并查看详细的音频编码信息,本例程使用的是KMPlayer播放,查看到的信息如图60.4.3所示:


图60.4.3 录音文件属性


       这和我们程序设计时的效果一样,通过电脑端的播放器可以直接播放我们所录的音频。经实测,效果还是非常不错的。


请使用浏览器的分享功能分享到微信等