NCS再探--nRF5340 Audio

1.前情提要

官方文档介绍:

nRF5340 Audio — nRF Connect SDK 1.9.99 documentation

截止到本文发布,该应用仅仅是实验性的。LE Audio强制要求支持的LC3编解码模块在NCS中可能是需要单独收费的,否则无法同步相关代码仓库,而只能使用SBC编码。

硬件:

PCA10121-nRF5340 Audio DK(22-05-10发布)

nRF5340 Audio DK - nordicsemi.com

开发环境:

Ubuntu 18.04(环境搭建可以参考笔者另一篇博客:NCS初探--搭建Linux下VSCode开发环境_我我我只会printf的博客-CSDN博客)

开发方式:

目前仅支持编译脚本与命令行编译,VSCode插件暂未写明支持。因手上开发板还未到货,所以使用命令行编译。

软件:

NCS-main分支最新同步

2.代码同步

此例子需要额外的三个拓展库:

拉取方式: 

west config manifest.group-filter +nrf5340_audio

west update

3.代码编译

在运行编译命令的串口先运行初始化脚本:

source ncs/zephyr/zephyr-env.sh

然后切换到NCS/nrf5340_audio/tools/buildprog目录下修改 nrf5340_audio_dk_devices.json 文件,把snr替换为你开发板的序列号:

这个序列号可以在板子USB连接PC后使用:

nrfjprog -i

查看。然后再使用:

python3 buildprog.py -c app -b debug -d both

完成代码编译。

而使用:

python3 buildprog.py -c app -b debug -d both -p

则可以烧写代码。

使用:

python3 buildprog.py -h

可以查看buildprog这个程序的选项:

4.代码分析

有一些分析可以参考之前的博客:

ZephyrOS--浅谈Bluetooth LE_我我我只会printf的博客-CSDN博客

无论设备定义为音频网关还是收听器(耳机),都是一份代码,只是配置项不同。

main函数如下:

void main(void)
{
	int ret;

	LOG_DBG("nRF5340 APP core started");
	//开启时钟
	ret = hfclock_config_and_start();
	ERR_CHK(ret);
	//LED初始化
	ret = led_init();
	ERR_CHK(ret);
	//按键初始化
	ret = button_handler_init();
	ERR_CHK(ret);
	//通道检测
	ret = channel_assign_check();
	ERR_CHK(ret);
	//打印固件信息
	ret = fw_info_app_print();
	ERR_CHK(ret);
	//固件与硬件间匹配检测
	ret = board_version_valid_check();
	ERR_CHK(ret);
	//获得板子固件信息
	ret = board_version_get(&board_rev);
	ERR_CHK(ret);
	//如果定义了MAX14690电源管理芯片,则启用相关初始化
	if (board_rev.mask & BOARD_REVISION_VALID_MSK_MAX14690_PMIC) {
		ret = pmic_init();
		ERR_CHK(ret);

		ret = pmic_defaults_set();
		ERR_CHK(ret);
	}
	//如果定义了使用SD卡,则启用相关初始化
	if (board_rev.mask & BOARD_VERSION_VALID_MSK_SD_CARD) {
		ret = sd_card_init();
		if (ret != -ENODEV) {
			ERR_CHK(ret);
		}
	}
	//电源管理模块初始化
	ret = power_module_init();
	ERR_CHK(ret);
	//音频同步定时器初始化
	ret = audio_sync_timer_init();
	ERR_CHK(ret);
	//如果当前设备作为网关,则初始化USB
#if ((CONFIG_AUDIO_DEV == GATEWAY) && (CONFIG_AUDIO_SOURCE_USB))
	ret = audio_usb_init();
	ERR_CHK(ret);
#else
	//否则初始化i2s相关模块
	ret = audio_datapath_init();
	ERR_CHK(ret);
	audio_i2s_init();
	ret = hw_codec_init();
	ERR_CHK(ret);
	audio_sync_timer_sync_evt_send();
#endif

	//初始化蓝牙,当初始化完成时会调用传入回调
	ret = ble_core_init(on_ble_core_ready);
	ERR_CHK(ret);

	//等待初始化完成
	while (!(bool)atomic_get(&ble_core_is_ready)) {
		(void)k_sleep(K_MSEC(100));
	}
	//设置LED状态
	ret = leds_set();
	ERR_CHK(ret);
	//启用流控
	ret = streamctrl_start();
	ERR_CHK(ret);
	//通过当前音频线路播放tone音
	ret = audio_datapath_tone_play(440, 500, 0.2);
	ERR_CHK(ret);

	while (1) {
		//流控时间处理
		streamctrl_event_handler();
		//堆栈信息打印
		STACK_USAGE_PRINT("main", &z_main_thread);
	}
}

一些普通的硬件初始化直接略过。首先看channel_assign_check:

注意这个切换肯定是要重启耳机的,因为这个初始化在main里是放在while之前的。切换方式在config中已经说的很明白了:

这里的channel_assignment_get()函数使用很有意思:

	if (!channel_assignment_get(&(enum audio_channel){ AUDIO_CHANNEL_LEFT })) {
		/* Channel already assigned */
		return 0;
	}

直接初始化一个值为AUDIO_CHANNEL_LEFT的局部enum audio_channel变量,然后取它的地址,但是没有变量名。所以只看返回值,而不关心实际get到的是左还是右耳设置,因为没有变量名,所以根本无法使用。而对于这个demo来说左耳还是右耳的设置保存在UICR(User information configuration registers)中:

 接下来是打印固件信息,里面有个编译时间戳也很有意思:

这个是怎么来的?

在 nrf5340_audio文件夹下的CMakeLists.txt中有:

也就是使用了CMake的函数,可以把某个字符串替换为时间戳,而这个字符串在所有.c中都没有,而是在一个.in的文件中有,这是因为使用了CMake的函数用.in源文件生成了.c,同时在这个过程中会替换掉一些特殊字符串:

但是像 NRF5340_AUDIO_VERSION_MAJOR 这个标识你是搜不到的,为什么在实际生成的.c文件中又已经被替换了?

这是由于CMake内置的一些标签(此处的标签这个概念只是个人理解,其实就和define一样,也可以理解为宏定义):

所以,项目名(NRF5340_AUDIO)+_VERSION_MAJOR等这些是自动生成的,它们的值会根据你设置的版本号自动被赋值。其他这个函数的内容无非就是一些配置获取与打印,不再赘述。

接下来的板子硬件版本检测也很简单:

mask是一个int32类型的数,它的不同位被置1表示不同板子型号,只需要判断它即可。 

其他关于硬件初始化的部分不再赘述,直接看比较核心的定时器,在官网上对于耳机端有一个流程图:

可以看到NET和APP核需要用同一个时钟源保证同步,而且是有一个 presentation compensation(演示补偿,也可以理解为音频播放补偿),在此基础上我们看一下同步定时器初始化:

int audio_sync_timer_init(void)
{
	nrfx_err_t ret;
	//定时器1初始化,回调为 event_handler(其实为空)
	//配置:
	//	频率:1MHz
	//	模式:定时器
	//	位宽:32bit
	//	优先级:7
	//	上下文:无
	ret = nrfx_timer_init(&timer_instance, &cfg, event_handler);
	if (ret - NRFX_ERROR_BASE_NUM) {
		LOG_ERR("nrfx timer init error - Return value: %d", ret);
		return ret;
	}
	//使能定时器
	nrfx_timer_enable(&timer_instance);

	/* Initialize capturing of I2S frame start event timestamps */
	//申请一个dppi通道
	ret = nrfx_dppi_channel_alloc(&dppi_channel_i2s_frame_start);
	if (ret - NRFX_ERROR_BASE_NUM) {
		LOG_ERR("nrfx DPPI channel alloc error (I2S frame start) - Return value: %d", ret);
		return ret;
	}
	//设置该通道任务为 AUDIO_SYNC_TIMER_I2S_FRAME_START_EVT_CAPTURE
	nrf_timer_subscribe_set(timer_instance.p_reg, AUDIO_SYNC_TIMER_I2S_FRAME_START_EVT_CAPTURE,
				dppi_channel_i2s_frame_start);
	//设置该通道事件为 NRF_I2S_EVENT_FRAMESTART
	nrf_i2s_publish_set(NRF_I2S0, NRF_I2S_EVENT_FRAMESTART, dppi_channel_i2s_frame_start);
	//通道使能
	ret = nrfx_dppi_channel_enable(dppi_channel_i2s_frame_start);
	if (ret - NRFX_ERROR_BASE_NUM) {
		LOG_ERR("nrfx DPPI channel enable error (I2S frame start) - Return value: %d", ret);
		return ret;
	}

	/* Initialize functionality for synchronization between APP and NET core */
	ret = nrfx_dppi_channel_alloc(&dppi_channel_timer_clear);
	if (ret - NRFX_ERROR_BASE_NUM) {
		LOG_ERR("nrfx DPPI channel alloc error (timer clear) - Return value: %d", ret);
		return ret;
	}
	//设置该通道事件为 AUDIO_SYNC_TIMER_NET_APP_IPC_EVT
	nrf_ipc_publish_set(NRF_IPC, AUDIO_SYNC_TIMER_NET_APP_IPC_EVT, dppi_channel_timer_clear);
	//设置该通道任务为 NRF_TIMER_TASK_CLEAR
	nrf_timer_subscribe_set(timer_instance.p_reg, NRF_TIMER_TASK_CLEAR,
				dppi_channel_timer_clear);
	ret = nrfx_dppi_channel_enable(dppi_channel_timer_clear);
	if (ret - NRFX_ERROR_BASE_NUM) {
		LOG_ERR("nrfx DPPI channel enable error (timer clear) - Return value: %d", ret);
		return ret;
	}
	//通过以上设置,每当有I2S帧开始时,保存定时器的值到cc[0]
	//每当有APP从IPC4接收到NET的消息这个事件发生时,重置TIMER1
	return 0;
}

太久没看SDK更新了,现在DPPI的函数改为了订阅和发布,其实和之前还是一样的,发布是触发事件,订阅是事件被触发后,需要执行的任务。

关于USB的初始化不再展开,Zephyr的USB驱动库我还没有仔细研究过,这里USB设备是作为 Audio Class的 HEADSET(这里应该是耳机类型) 在PC上被识别:

大致看了一下,具体配置应该是从设备树文件设置的(之前也看到有问题说USB是直接在相关类.h文件设置的,具体未考究),因为hs_dev是从由设备树自动生成的define中解析得到相关信息的(只需要记住 DT_ 开头的宏定义都和设备树文件相关)。我们需要注意的是USB设备的回调函数:ops,它是我们获取到数据后用来处理数据的途径:

目前这个demo是不支持麦克风的,所以只需要接收成功的回调和更改功能的回调:

其中更改功能配置的回调其实就是个空函数,目前什么情况都没有处理,不再赘述,主要看一下收到数据后怎样处理,这个函数的原型是:

也就是在回调函数中,我们处理的主要是buffer里面的数据,注意这个buffer需要在回调里面,我们手动去释放,使用的是net_buf_unref这个函数,函数内容:

其次是对单次数据量的判断,这个demo中,目前只支持48KHz,16bit的音频,所以单包数据的字节数是48000(每秒) * 2(bytes) / 1000(转化为ms) * 2(立体声) = 192 bytes(每毫秒),在源码中有:

所以正常情况下,每1ms这个回调被触发一次,然后接收到192字节的音频数据。 

所以我们通过USB获取到PC上的音频数据后,音频被一包一包的放进了 fifo_rx 这个队列中去了。 而这个fifo被定义在 audio_codec.c 中:

至于 BLOCK_SIZE_BYTES 则有: 

关于I2S的BLOCK的定义可以搜索其他博客。 而这个10ms作为一帧则和BLE Audio的规范相关,目前支持10ms和7.5ms两种情况,7.5好像是为了兼容经典蓝牙,具体记不清楚了。

接下来就是如果配置为耳机会初始化I2S相关内容,在讲这部分前,我们可以先看一下官方对于接收端的描述:

Synchronization module flow

The received audio data in the I2S-based firmware devices follows the following path:

  1. The LE Audio Controller Subsystem for nRF53 running on the network core receives the compressed audio data.

  2. The controller subsystem sends the audio data to the Zephyr Bluetooth LE host similarly to the Bluetooth: HCI RPMsg sample.

  3. The host sends the data to the stream control module (streamctrl.c).

  4. The data is sent to a FIFO buffer.

  5. The data is sent from the FIFO buffer to the audio_datapath.c synchronization module. The audio_datapath.c module performs the audio synchronization based on the SDU reference timestamps. Each package sent from the gateway gets a unique SDU reference timestamp. These timestamps are generated on the headset controllers. This enables the creation of True Wireless Stereo (TWS) earbuds where the audio is synchronized in the CIS mode. It does also keep the speed of the inter-IC sound (I2S) interface synchronized with the sending and receiving speed of Bluetooth packets.

  6. The audio_datapath.c module sends the compressed audio data to the LC3 or the SBC audio decoders for decoding.

  7. The audio decoder decodes the data and sends the uncompressed audio data (PCM) back to the audio_datapath.c module.

  8. The audio_datapath.c module continuously feeds the uncompressed audio data to the hardware codec.

  9. The hardware codec receives the uncompressed audio data over the inter-IC sound (I2S) interface and performs the digital-to-analog (DAC) conversion to an analog audio signal.

整个流程翻译一下就是:

1.接收端的网络核接收到网关发来的,经过编码的音频数据

2.网络核通过rpmsg方式发给主机(对于5340来说)

3.主机收到还未解码的音频数据后,先发送给流控模块(streamctrl.c)

4.在发送到流控模块后会被缓存到一个fifo中

5. 再从fifo中发送给同步模块(audio_datapath.c),同步是基于接收到的SDU的时间戳来完成的,这个时间戳是接收端的Controller在接收到SDU时添加上去的。这可以保证I2S接口与BLE包的发送和接收速度同步

6.(audio_datapath.c)同步模块会再把数据发送给解码器解码

7.音频解码器解码后再发送回同步模块(audio_datapath.c)(其实就是在同步模块内调用了一个解码器接口,输入编码音频,输出解码音频)

8.同步模块(audio_datapath.c)把解码后的音频送到硬件编解码器

9.硬件编解码器通过I2S接口收到音频数据后,再进行数模转化,到耳机中(使用的3.5的模拟耳机接口)

在了解了这些之后,我们再来看相关代码:

在同步模块初始化中最关键是注册了一个回调函数:

而这个回调函数是:

  

这个函数主要分为两个部分,一部分是针对TX数据的,一部分是针对RX数据的,先看TX数据:

 这里有两个索引不得不说:

对于out的fifo来说,它的使用方式和环形缓冲差不多,在环形缓冲中我们知道首先有一个缓冲区,一般来讲是一个数组,然后有一个写指针和一个读指针,而在这里,它们被看做生产者与消费者模型,我们每写入一个数据块,被认为是生产了一个数据块可供其他模块消费,每消费掉一个数据块,就又可以拿来装新的数据。 这里的prod_blk_idx就是生产者内存块索引对应写指针,cons_blk_idx是消费者内存块索引对应读指针。

这里可以由此.c文件的宏定义算得,该fifo一共使用内存是fifo[3840],它们每96字节被分为一个blk,也就是1ms的单个耳机需要的bytes,一共有40个blk,每个耳机有20个,也就是一共能缓存20ms的音频数据。

演示(播放)延迟先不考虑,继续看TX方向的一些列操作:

接下来看RX方向的处理:

其实可以看到最终起作用的是 audio_i2s_set_next_buf 这个函数,而它里面其实就是调用的底层驱动接口:

这个函数中还有一个很重要的就是漂移补偿,先看一下对于漂移补偿状态的定义:

//漂移补偿状态
enum drift_comp_state {
	//等待接收数据
	DRFT_STATE_INIT, /* Waiting for data to be received */
	//校准并调零本地延迟
	DRFT_STATE_CALIB, /* Calibrate and zero out local delay */
	//相对于SDU参考调整I2S偏移
	DRFT_STATE_OFFSET, /* Adjust I2S offset relative to SDU Reference */
	//漂移补偿锁定-轻微修正
	DRFT_STATE_LOCKED /* Drift compensation locked - Minor corrections */
};

在漂移补偿函数中有:

设置状态的函数是:

设置漂移补偿中的校准和调零本地延迟方法是:

关于上图中的误差(单位us)如何转换为了音频时钟频率寄存器的值,这个宏定义怎么推导到的还不清楚,因为数学知识太久没用了。相关公式:

也可以转化为:

以及最大最小和中位值:

而根据SDU参考时间调整I2S偏移是:   

最后锁定漂移补偿状态后,会做细微的调整,其实处理和上面差不多:

这里还有两个临界值:

在代码中可以看出,当误差太大,在+-DRIFT_ERR_THRESH_UNLOCK之外时,会从 DRFT_STATE_LOCKED 状态切换回 DRFT_STATE_OFFSET ,当误差在+-DRIFT_ERR_THRESH_LOCK 之内时,才会切换到 DRFT_STATE_LOCKED 。所以正常状态下,应该是在这两个状态间来回切换的。

接下来看I2S的初始化,这里选择音频时钟源:

注意这里的 i2s_comp_handler 就是我们之前在 audio_datapath_init 中初始化的那个回调函数:

它们是使用 i2s_blk_comp_callback 这个全局的函数指针传递的。

最后关于硬件编解码器初始化就不再赘述了,在相关初始化完成后,会触发 AUDIO_SYNC_TIMER_NET_APP_IPC_SIGNAL_IDX 事件:

关于蓝牙初始化和LED设置也不再赘述,直接看流控启动:

先是初始化了一个fifo,这个fifo使用宏定义被定义在当前.c文件中:

而在 data_fifo_init  中主要是对 fifo 中的消息队列和内存池初始化:

这里对内存片使用的是slab这个单词,具体是否和Linux中的slab内存管理使用相同的设计思想笔者没有仔细研究。

其次在流控初始化中如果当前设备是耳机,则有初始化:

首先看一下软件编解码器的初始化:

其中对于编解码器的配置在:

int sw_codec_init(struct sw_codec_config sw_codec_cfg)
{
	switch (sw_codec_cfg.sw_codec) {
	case SW_CODEC_LC3: {
#if (CONFIG_SW_CODEC_LC3)
		int ret;
        //判断当前编解码器配置
		if (m_config.sw_codec != SW_CODEC_LC3) {
			/* Check if LC3 is already initialized */
            //10ms帧长
			ret = sw_codec_lc3_init(NULL, NULL, CONFIG_AUDIO_FRAME_DURATION_US);
			if (ret) {
				return ret;
			}
		}
        //判断是否重复使能
		if (sw_codec_cfg.encoder.enabled) {
			if (m_config.encoder.enabled) {
				LOG_WRN("The LC3 encoder is already initialized");
				return -EALREADY;
			}
			uint16_t pcm_bytes_req_enc;
            //编码器初始化
            //48KHz-16bits-10ms-96000-SW_CODEC_MONO
			ret = sw_codec_lc3_enc_init(
				CONFIG_AUDIO_SAMPLE_RATE_HZ, CONFIG_AUDIO_BIT_DEPTH_BITS,
				CONFIG_AUDIO_FRAME_DURATION_US, sw_codec_cfg.encoder.bitrate,
				sw_codec_cfg.encoder.channel_mode, &pcm_bytes_req_enc);
			if (ret) {
				return ret;
			}
		}
    
		if (sw_codec_cfg.decoder.enabled) {
			if (m_config.decoder.enabled) {
				LOG_WRN("The LC3 decoder is already initialized");
				return -EALREADY;
			}
            //解码器初始化
            //同上
			ret = sw_codec_lc3_dec_init(CONFIG_AUDIO_SAMPLE_RATE_HZ,
						    CONFIG_AUDIO_BIT_DEPTH_BITS,
						    CONFIG_AUDIO_FRAME_DURATION_US,
						    sw_codec_cfg.decoder.channel_mode);
			if (ret) {
				return ret;
			}
		}
		break;
#endif /* (CONFIG_SW_CODEC_LC3) */
		LOG_ERR("LC3 is not compiled in, please open menuconfig and select LC3");
		return -ENODEV;
	}
	case SW_CODEC_SBC: {
#if (CONFIG_SW_CODEC_SBC)
		m_sbc_enc_params.s16ChannelMode = CONFIG_SBC_CHANNEL_MODE_MONO;

		uint8_t sbc_pcm_stride;

		/* Since we encode mono+mono instead of stereo, numOfChannels will always be 1 */
		m_sbc_enc_params.s16NumOfChannels = 1;
		m_sbc_enc_params.s16SamplingFreq = SBC_sf48000;
		m_sbc_enc_params.s16NumOfBlocks = CONFIG_SBC_NO_OF_BLOCKS;
		m_sbc_enc_params.s16NumOfSubBands = CONFIG_SBC_NO_OF_SUBBANDS;
		m_sbc_enc_params.s16BitPool = CONFIG_SBC_BITPOOL;
		/* BitPool will be set in the driver by bitrate */

		m_sbc_enc_params.s16AllocationMethod = CONFIG_SBC_BIT_ALLOC_METHOD;
		m_sbc_enc_params.mSBCEnabled = 0;
		sbc_first_frame_received = false;
		SBC_Encoder_Init(&m_sbc_enc_params);

		/* Since only mono decode is supported when using SBC,
		 * pcm_stride will always be 1
		 */
		sbc_pcm_stride = 1;

		(void)OI_CODEC_SBC_DecoderReset(&context, sw_codec_sbc_dec_data.data,
						sizeof(sw_codec_sbc_dec_data), SBC_MAX_CHANNELS,
						sbc_pcm_stride, false);
		break;
#endif /* (CONFIG_SW_CODEC_SBC) */
		LOG_ERR("SBC is not compiled in, please open menuconfig and select SBC");
		return -ENODEV;
	}
	default:
		LOG_ERR("Unsupported codec: %d", sw_codec_cfg.sw_codec);
		return false;
	}

	m_config = sw_codec_cfg;
	return 0;
}

这里的LC3是不开源的,所以只能看的到接口,具体实现没有源码无法获悉。而SBC是使用的开源实现,具体配置和实现可以直接看代码。你应该知道的是,经典蓝牙音频强制支持SBC,而低功耗蓝牙音频强制支持LC3。

在软件编解码器中还比较重要的是编码线程:

在拿到完整的音频数据后,会通过编码器接口把音频数据编码:

 编码后会把音频数据发送出去:

 这个函数里面更新了SDU的参考时间,再向下不再分析:

回到 audio_headset_start 中,接下去的硬件编解码器的设置不再赘述,看一下I2S是怎么启动的:

再次回到流控初始化中,这里很重要的 audio_datapath_thread 线程被创建,它的入口函数是:

在 audio_datapath_stream_out 中对音频进行解码:

其中的解码和编码基本差不多,都是提供了相关的接口,我们主要看一下演示补偿,这个和之前的漂移补偿有点像,演示补偿也有几个状态:

enum pres_comp_state {
	//初始化演示补偿
	PRES_STATE_INIT, /* Initialize presentation compensation */
	//测量演示延迟
	PRES_STATE_MEAS, /* Measure presentation delay */
	//等待
	PRES_STATE_WAIT, /* Wait for some time */
	//演示补偿锁定
	PRES_STATE_LOCKED /* Presentation compensation locked */
};

我们先看一下等待状态和锁定状态:

可以看到上面的状态很简单,关键在于其他两个状态:

演示延迟是依托于漂移补偿之上的,所以这里测量是10次, 而关于 current_pres_dly_us 的赋值,在上文中的漂移补偿中已经提到:

此状态机只是获得了调整误差均值,实际应用在下面的代码中:

在实际应用中,10ms的音频数据占用20个内存块,左声道10块,右声道10块,所以限定20块,即10ms的音频数据。 获得块数后,根据块数对音频缓存区做处理:

官方文档对于演示延迟有如下说明:

翻译:

让我们再次回到调用演示补偿的地方,它下面直接就是解码,解码之前说过和编码差不多,不再赘述,直接看下面:

回到创建 audio_datapath_thread 的地方,接着往下看:

现在简单看一下蓝牙传输初始化:

这个函数最底层调用的:

再深入同步HCI发送函数:

这里可以看到,发送函数是把需要发送的内容放到一个发送队列里,然后等待传入的信号量,实际发送并不在这个函数中, 在我之前的博客:

ZephyrOS--浅谈Bluetooth LE_我我我只会printf的博客-CSDN博客

中有提到HCI初始化的两个线程,发送和接收,在发送thread中: 

 再向下底层已经看不到相关实现了,因为send发送函数指针在controller层被赋值,而这个demo中controller是Nordic提供的hex文件,所以无法看到源码。

再次回到 m_ble_transport_init 函数,主要看一下以下三个函数:

 在 ble_acl_common_init 中主要是注册了ACL连接回调,相关函数在本.c文件中,不在赘述:

 而在 ble_acl_common_start 中主要是开启了扫描,这个函数不再具体分析:

而在 ble_trans_iso_init 这个函数中对单播和广播分别进行初始化,这里我们主要先关注单播,首先ISO的传输方向上规定:

获取连接信息的函数是: 

而回调函数是:

下面看一下tone音播放:

最后看一下一直放在main循环中的函数:

可以看到总共就处理了两种事件类型,一种是按键,另一种是BLE传输事件。先看按键事件,在这之前补充一下前文没有提到的按键事件触发:

对应的,在处理按键事件时也是判断上述信息:

而按键对应的事件如下:

而关于功能在官方文档上的描述如下:

主要看一下开始或暂停播放,注意这里的播放与暂停只对handset也就是耳机生效(或者是网关的BIS模式生效):

为什么它们都调用的一个处理函数,因为在CIS下其实这个函数什么也不做:

 关于音量加减与测试等不再具体分析。看一下BLE传输事件都处理了哪些:

这些事件在哪里被触发?在之前介绍的 ble_trans_iso_init 函数中有传入蓝牙相关事件的回调ops:

这个函数指针合集里面包含:

在连接回调中有:

 而在断开连接的回调中的事件是:

最后一个 BLE_EVT_STREAMING 事件只在BIG中有使用到,在 m_ble_transport_init 函数中初始化BIG:

关于CIS时的peer或者BIS的stream的状态有以下几种: 

不同的 BLE_EVT 构成了一个状态机,而每种 BLE_EVT 事件下每种流的状态又是一个状态机,关于 BLE_EVT_CONNECTED 和 BLE_EVT_DISCONNECTED 不再赘述,我们看一下 BLE_EVT_LINK_READY 连接准备好时的处理:

在 headset 模式下,只是改变了状态,而在网关模式下会开启音频传输:

而因为USB在前面已经初始化过了,所以这里就只是把两个全局的fifo传入到里面:

4.总结 

代码梳理完了,但是感觉没有串联在一起,最后想要以一条完整的音频链路把整个流程再次分析一下,首先音乐是从PC通过USB传输到网关,然后经由编码器编码后通过BLE发送到HEADSET也就是耳机,耳机再解码,然后数字转模拟到3.5mm接口。这是一整个大致流程,细分数据流向后各个模块分析如下:

4.1 audio_data->fifo_rx

首先在main函数中的USB初始化函数中就已经启动USB了,也就是USB此时已经可以和PC之间传输音频了:

这里接收音频就是在ops这个函数指针合集中的接收回调 data_received :

但是此时接收到的音频数据并没有地方可以存放,因为fifo_rx还没有被初始化,它被初始化是在 main -> streamctrl_event_handler -> m_ble_transport_evt_handler -> audio_gateway_start -> audio_usb_start 中。在这个阶段,网关通过USB从PC接收到了音频数据,并把接收到的音频数据存在了 audio_codec.c 文件中fifo_rx:

注意,USB的接收回调每1ms会被调用一次,对于48K16bits双通道的音频数据来说1ms的数据量是192bytes,对于10ms一帧的音频数据需要调用10次接收,一共接收1920bytes,也就是这个fifo_rx 会存储10个数据块,每个数据块192bytes。 

拓展一下,当使用I2S做音频源时,其实仍旧使用的是 fifo_rx,在main -> streamctrl_event_handler -> m_ble_transport_evt_handler -> audio_gateway_start 有通过配置项确定使用的是哪种音频源:

而在这个函数中,把fifo_rx 给到了ctrl_blk:

后续音频传输,都以 ctrl_blk.in.fifo作为输入缓冲。至于ctrl_blk.out.fifo的使用就要看下面耳机端了。

4.2 fifo_rx->BLE_data

这一步把从USB获取到的原始音频解码然后放到BLE的待发送fifo中,在 main -> streamctrl_event_handler -> m_ble_transport_evt_handler -> audio_gateway_start -> audio_codec_start -> encoder_thread中会循环获取上一步放进fifo_rx中的音频数据,并进行编码,同时发送出去:

主机端音频处理到此就结束了,但是存在两个问题,一是我并没有找到LC3编码器把一帧音频数据编码后是多少bytes,二是我们目前是通过调用接口直接发送的这一帧音频数据,但是实际底层是否对音频数据进行了分隔,我们并不了解。后面预定的开发板到货了实际调试时,这个值是可以通过打印看到的。

4.3 BLE_data->ble_fifo_rx

而耳机端的音频数据是从BLE接收到的,它在 main -> streamctrl_start -> m_ble_transport_init -> ble_trans_iso_init -> ble_iso_rx_data_handler 中被保存到 ble_fifo_rx 中的,这个fifo初始化就是在 streamctrl_start 中的:

然后在 m_ble_transport_init 中经由 ble_trans_iso_init 函数设置了BLE的接收回调函数 ble_iso_rx_data_handler:

 然后在这个BLE的接收回调函数中把音频数据保存到了 ble_fifo_rx 中的:

注意,ble_fifo_rx 它的内存块数量是5个,每个内存块的大小是 sizeof(struct ble_iso_data) ,这个 ble_iso_data 结构体就是保存的单个ISO包的数据:
 

这里我们可以知道单个ISO数据包最大时190字节,上述结构体中的其他数据应该是耳机的controller在接收时填充的。

4.4 ble_fifo_rx-> ctrl_blk.out.fifo

在接收到未解码的音频包后,音频包的使用在 main -> streamctrl_start -> audio_datapath_thread 这个线程中有循环解码的函数 audio_datapath_stream_out :

而在函数 audio_datapath_stream_out 中我们通过判断对音频时间戳的处理可以知道,每10ms的音频数据被放在一个包里面:

注意上图,如果sdu时间间隔小于15ms,且误差在千分之一,则认为没有误差,强制把时间间隔改为10ms,从这里我们知道每个ISO的190bytes都可以完整的传输一整帧10ms的音频数据。所以下面解码后对解码后的数据进行了判断:

最后数据就流到了 ctrl_blk.out.fifo 中:

至于这个fifo的使用,它是怎么通过I2S传输给硬件解码器的?

它首次被设置是在 main -> streamctrl_start -> audio_headset_start ->  audio_datapath_start -> audio_datapath_i2s_start 中:

它在每次传输完成后被设置是在 main -> audio_datapath_init ->  audio_datapath_i2s_blk_complete 这个每次传输完成后的回调中:

至此整个音频数据流向及分析结束。 

2022-07-04更新:

在.config中可以看到一个宏定义:

CONFIG_LC3_MONO_BITRATE=96000

默认LC3编解码器的码率是96Kbit,所以单通道一帧音频数据被编码为:

96000*10/(8*1000) = 120bytes

在BAP中规定的一些固定配置为:

可以看到测试demo中LC3的配置用的应该是48_4 

后续:

使用官方的LC3不是需要收费,而是需要签保密协议。

笔者尝试使用zephyr中的liblc3codec替换了Nordic的接口,但存在一些问题。

2022-09-07更新:

笔者曾经给官方提过SBC编码器存在丢包导致的crash问题,官方回复了一个临时的解决方案,虽然不会再crash了,但是丢包时音频会明显卡顿。结果今天再更新这个SDK,官方已经把SBC编解码器去掉了。。

2022-10-10更新:

节前发现官方开放了LC3编解码器仓库的权限,现在已经不需要签协议即可使用Nordic提供的LC3编解码器(吐槽一下,本来就应该直接开放)。实际更新了官方的仓库后,实际使用时,单播状态下使用一段时间后存在耳机端crash问题,广播状态目前未发现问题。 看了一下更新后代码,与上文中代码已经存在一些差异,因为此demo仍被标记为实验性质,所以这也是在所难免的,后续代码可能还会有其他改动,不过笔者已经不打算再修改了。

  • 5
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 17
    评论
评论 17
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值