Tflite-micro在ESP32实现离线命令识别

本文会介绍如何使用TensorFlow Lite构建一个本地语音识别系统,内容包括语音前端处理、语音识别模型的训练以及如何将其转换为TensorFlow Lite格式并部署到ESP32。

一、前言

语音识别作为一种人机交互的重要方式,正在逐渐成为智能设备中的核心功能之一。然而,传统的语音识别系统通常需要依赖于云端服务器进行音频数据的处理和分析,这带来了延迟和隐私等问题。TensorFlow Lite提供了在本地设备上进行高效而快速的语音识别的解决方案,能够在资源受限的环境中运行,并且具有快速推理的能力。通过将语音识别模型部署到本地设备,减少对云端服务器的依赖。

二、语音特征提取

1音频信号采集与传输

语音识别的输入是音频信号,它是声音在时间上的波动。音频信号可以通过麦克风或其他录音设备采集得到。

  • 采样率(Sample rate):采样率指的是每秒钟对音频信号进行采样的次数。常见的采样率有8KHz、16KHz、44.1KHz和48KHz等。采样率决定了数字音频的频率范围和保真度。

  • 采样位数(Bits per sample):采样位数是描述音频数字化过程中每个样本所占用的位数。在数字音频中,声音波形通过在每个采样点上量化为离散的数字值来表示。采样的位数定义了每个样本可以表达的离散级别数量。较高的位数表示更高的精度和动态范围,可以更准确地捕捉音频信号的细节和动态变化。通常用于音频的常见位深度包括8位、16位和24位。16位是CD音质的标准,而24位则提供更高的动态范围。较高的位深度会占用更多的存储空间和带宽,但可以提供更高的音频质量。

  • I2S(Inter-IC Sound):I2S是一种数字音频传输接口标准,用于在音频设备之间传输音频数据和时钟信号。它通常用于传输单声道或立体声音频数据,可以支持高质量的音频传输。

    • PCM(Pulse Code Modulation):PCM是一种基本的音频编码方式,将模拟音频信号转换为数字信号。PCM将音频信号进行采样,并将每个采样点的幅度值离散化为固定位数的数字值,通常使用16位或24位来表示每个样本。

    • PDM(Pulse-Density Modulation):PDM是一种音频编码方式,通过脉冲密度调制来表示音频信号的连续变化。PDM编码使用脉冲的数量和频率来表示音频信号的幅度和频率,适用于高分辨率和高动态范围的音频数据传输。

    • TDM(Time-Division Multiplexing):TDM是一种音频通信模式,用于在单个接口上传输多个音频数据流。通过时间分割多路复用技术,TDM可以在单个接口上同时传输多个音频数据流。每个音频数据流被分配到不同的时隙,按照时间顺序轮流进行传输。

2音频特征相关概念

在进行语音识别之前,通常需要对音频信号进行预处理,包括去噪、音量归一化、语音活动检测等步骤,以提高语音识别的准确性和鲁棒性。

  • 时域和频域:音频信号可以在时域和频域两个维度上进行分析。时域表示信号在时间上的变化,而频域表示信号在频率上的组成成分。

  • 傅里叶变换:傅里叶变换是一种将信号从时域转换到频域的方法,可以将信号表示为一系列频率分量的叠加。

  • 声谱图:声谱图(Spectrogram)是一种用于可视化音频信号的频谱内容随时间变化的图形表示。它可以将音频信号的频谱信息以时间为横轴、频率为纵轴,通过颜色或灰度来表示信号的幅度或能量。声谱图通常用于音频信号分析、语音识别、音乐处理等领域,可以提供对音频信号的频谱特性和时域特性的直观理解。

  • 梅尔频谱:声谱图往往是很大的一张图,为了得到合适大小的声音特征,往往把它通过梅尔标度滤波器组(mel-scale filter banks),变换为梅尔频谱。Mel频率(Mel scale)是一种用于模拟人耳对音频感知的非线性频率刻度。它是基于人耳对不同频率的感知方式而设计的,因为人耳对频率的感知并不是线性的,而是在低频时较为敏感,在高频时较不敏感。在音频信号处理中,将频率转换为Mel频率可以提供更符合人耳感知的特征表示,有助于提高音频处理算法的性能和效果。

  • 中心频率:中心频率用于描述频率带或滤波器的中心位置,可以用于分析和处理音频信号的频谱特征。在频谱分析中,通常会将频谱区域分成一系列的频带,每个频带都有一个中心频率。这些频带可以是等宽的,也可以是根据人耳感知的特性进行设计的。以梅尔滤波器组为例,梅尔滤波器组是一组用于将频谱分解成梅尔频谱的滤波器。每个梅尔滤波器都有一个中心频率,表示该滤波器所对应的频带在频谱中的中心位置。

3语音特征提取

MFCC与Filterbank是比较常用的语音特征,特征提取的流程如下图所示:

Tflite-micro在ESP32实现离线命令识别

(1)预加重预加重用于提高高频部分的能量,以改善信号的频率响应。由于音频信号中低频部分的振幅较高,而高频部分的振幅较低的特点。这可能会导致高频部分在传输过程中受到损失,使得高频信号变得不够明显。预加重通过对信号进行滤波来解决这个问题,使用一个一阶高通滤波器(通常是一个差分方程,由于音频信号中低频部分的振幅变化较慢,相邻采样点的差异较小,差分操作可以减小低频部分的振幅;而高频部分的振幅变化较快,相邻采样点的差异较大,差分操作会保留更多高频部分的振幅)对信号进行处理:
y(n) = x(n) - α * x(n-1)
其中,x(n) 是输入信号的当前采样点,x(n-1) 是上一个采样点的值,y(n) 是预加重后的输出信号,α 是预加重滤波器的增益系数。

(2)分帧分帧是将不定长的音频切分成固定长度的小段。由于语音信号是快速变化的,而傅里叶变换适用于分析平稳的信号,通过分帧,可以假设在每个帧内频谱是平稳的,从而可以进行频域分析和处理。在语音识别中,一般把帧长取为10~30ms,这样一帧内既有足够多的周期,又不会变化太剧烈。

(3)加窗每帧信号通常要与一个平滑的窗函数相乘,让帧两端平滑地衰减到零,这样可以降低傅里叶变换后旁瓣的强度,取得更高质量的频谱。

Tflite-micro在ESP32实现离线命令识别

如下图所示[1],加窗之后,帧与帧连接处的信号会被弱化,造成信息丢失,而傅里叶变换是逐帧进行的,为了避免窗边界对信号的遗漏,在对帧做偏移的时候,帧间要有重叠。

Tflite-micro在ESP32实现离线命令识别

(4)FFT变换与能量谱计算傅里叶变换可以将信号从时域转到频域。傅里叶变换可以分为连续傅里叶变换和离散傅里叶变换,因为用的是数字音频(而非模拟音频),所以用到的是离散傅里叶变换。傅里叶变换完成后得到频域信号,每个频带范围的能量大小不一,不同音素的能量谱不一样。常用的计算方式为:

Tflite-micro在ESP32实现离线命令识别

(5)梅尔滤波器组(Mel-filterbank)Mel频率与实际频率的转换公式如下:

Tflite-micro在ESP32实现离线命令识别

Tflite-micro在ESP32实现离线命令识别

Mel滤波器组通常需要设定频率上限和下限,屏蔽掉某些不需要或者有噪声的频率范围(一般下限设置为20hz左右,上限为音频采样率的二分之一),并将其转换为Mel频率。然后在Mel频率轴上配置k个通道的三角形滤波器组,k一般为40。三角窗函数为:

Tflite-micro在ESP32实现离线命令识别

其中k代表三角窗个数,f(m)代表了将Mel频率轴等分为k+1份后(即K+2个点)每个点所代表的频率。举个实际的例子,假设我们在0-4000设置40个三角窗滤波器,那么根据上面的计算其在Hz频率轴上的图像如下所示[2]:

Tflite-micro在ESP32实现离线命令识别

(6)降噪针对Mel-filterbank 输出进行降噪的一种常见方法是谱减法(Spectral Subtraction)。谱减法基于以下假设:原始信号在频域上是平稳的,而噪声信号在频域上是非平稳的。因此,通过比较信号谱与估计的噪声谱,可以估计信号中的噪声成分并进减去这部分噪声成分。

(7)PCEN通道能量归一化PCEN(Per-Channel Energy Normalization)算法是一种用于音频处理和语音识别的特征增强技术,它旨在提高语音信号的可感知性和鲁棒性。PCEN算法基于感知音量调整(Perceptual Contrast Enhancement)的思想,通过对音频信号进行归一化和平滑处理来增强信号中的有用信息。具体而言,PCEN算法首先计算每个频带的能量,然后对能量进行归一化处理,以减小不同频带之间的能量差异,然后PCEN算法应用平滑滤波器对归一化的能量进行平滑处理,以增强信号的稳定性和鲁棒性。下图[3]为一个包括鸟鸣、昆虫鸣叫和一辆经过车辆的声景。其中(a)对梅尔频率谱的对数变换,所有幅度都被映射到类似于分贝的刻度上;而(b)通道能量归一化则可以增强瞬态事件(鸟鸣),同时丢弃静态噪声(昆虫)和响度缓慢变化的声音(车辆)。

Tflite-micro在ESP32实现离线命令识别

(8)对数压缩对纵轴的放缩,可以放大低能量处的能量差异。

(9)DCT变换DCT(Discrete Cosine Transform,离散余弦变换)是一种线性变换。它将时域上的信号转换为频域上的信号,通过对信号进行加权和求和操作来得到频域表示。对Filterbank做DCT变换后就能得到MFCC特征,MFCC和高斯混合模型 - 隐马尔可夫模型(GMM-HMM)共同演变成为自动语音识别(ASR)的标准方法。由于DCT是一种线性变换,它仅保留了信号的线性特性,却可能会丢弃语音信号中的一些高度非线性的信息。所以随着深度学习在语音系统中的出现,在深度神经网络对高度相关输入不敏感的情况下,通常会选择Filterbank而非MFCC作为语音信号的特征输入到神经网络进行学习。

三、 模型训练与部署

1模型参数设置

models.py中定义了模型的参数初始化函数,在Demo中:

  • 使用的采样率sample_rate=16000
  • 音频数据长度clip_duration_ms=1000
  • 分帧大小window_size_ms=30
  • 帧移步长window_stride_ms=20
  • 特征维数feature_bin_count=40


def prepare_model_settings(label_count, sample_rate, clip_duration_ms,
                           window_size_ms, window_stride_ms, feature_bin_count,
                           preprocess):
  # 期望的采样率为:16000
  desired_samples = int(sample_rate * clip_duration_ms / 1000)
  # 每帧的采样点数为:480
  window_size_samples = int(sample_rate * window_size_ms / 1000)
  # 每帧的移动采样点数为:320
  window_stride_samples = int(sample_rate * window_stride_ms / 1000)
  length_minus_window = (desired_samples - window_size_samples)
  if length_minus_window < 0:
    spectrogram_length = 0
  else:
    # 频谱长度为:1 + (16000-480)/320 = 49
    spectrogram_length = 1 + int(length_minus_window / window_stride_samples)
  if preprocess == 'average':
    fft_bin_count = 1 + (_next_power_of_two(window_size_samples) / 2)
    average_window_width = int(math.floor(fft_bin_count / feature_bin_count))
    fingerprint_width = int(math.ceil(fft_bin_count / average_window_width))
  elif preprocess == 'mfcc':
    average_window_width = -1
    fingerprint_width = feature_bin_count
  elif preprocess == 'micro':
    average_window_width = -1
    # 语音"fingerprint"维度为:40
    fingerprint_width = feature_bin_count
  else:
    raise ValueError('Unknown preprocess mode "%s" (should be "mfcc",'
                     ' "average", or "micro")' % (preprocess))
  # 特征数据大小为:40*49=1960
  fingerprint_size = fingerprint_width * spectrogram_length
  return {
      'desired_samples': desired_samples,
      'window_size_samples': window_size_samples,
      'window_stride_samples': window_stride_samples,
      'spectrogram_length': spectrogram_length,
      'fingerprint_width': fingerprint_width,
      'fingerprint_size': fingerprint_size,
      'label_count': label_count,
      'sample_rate': sample_rate,
      'preprocess': preprocess,
      'average_window_width': average_window_width,
  }


2语音数据特征提取

在input_data.py文件中,通过构建tensorflow的一个网络子图来实现音频文件.wav的读取、wav解码、音量调整、数据边界padding规整、添加背景噪声、计算频谱、计算特征:


class AudioProcessor(object):
  prepare_processing_graph():
    ...
    if model_settings['preprocess'] == 'average':
      self.output_ = tf.nn.pool(input=tf.expand_dims(spectrogram, -1), ...)
      ...
    elif model_settings['preprocess'] == 'mfcc':
      self.output_ = audio_ops.mfcc(spectrogram, ...)
      ...
    elif model_settings['preprocess'] == 'micro':
      ...
      micro_frontend = frontend_op.audio_microfrontend(
            int16_input,
            sample_rate=sample_rate,
            window_size=window_size_ms,
            window_step=window_step_ms,
            num_channels=model_settings['fingerprint_width'],
            out_scale=1,
            out_type=tf.float32)
      self.output_ = tf.multiply(micro_frontend, (10.0 / 256.0))
    ...


针对’micro’的特征类型,TensorFlow Lite封装了音频前端的算子,这个算子接收音频数据并根据配置输出对应的未堆叠帧的滤波器组特征,具体操作包括:切片窗口函数、短时傅立叶变换、滤波器组计算、降噪、自动增益控制与对数缩放。最终输出一个二维的Tensor,每一行对应于时间维度,每一列是特征维度。该算子的步骤在tensorflow/lite/experimental/microfrontend/lib/frontend.c中体现: 


struct FrontendOutput FrontendProcessSamples(struct FrontendState* state,
                                             const int16_t* samples,
                                             size_t num_samples,
                                             size_t* num_samples_read) 
{
  struct FrontendOutput output;
  output.values = NULL;
  output.size = 0;

  // 加汉宁窗
  if (!WindowProcessSamples(&state->window, samples, num_samples, num_samples_read)) {
    return output;
  }

  // 通过第三方库kissfft计算FFT
  int input_shift = 15 - MostSignificantBit32(state->window.max_abs_output_value);
  FftCompute(&state->fft, state->window.output, input_shift);

  // 计算能量谱
  int32_t* energy = (int32_t*)state->fft.output;
  FilterbankConvertFftComplexToEnergy(&state->filterbank, state->fft.output, energy);

  // 计算Mel-Filterbank
  FilterbankAccumulateChannels(&state->filterbank, energy);
  uint32_t* scaled_filterbank = FilterbankSqrt(&state->filterbank, input_shift);

  // 通过谱减法做降噪
  NoiseReductionApply(&state->noise_reduction, scaled_filterbank);

  // PCEN算法,归一化能量
  if (state->pcan_gain_control.enable_pcan) {
    PcanGainControlApply(&state->pcan_gain_control, scaled_filterbank);
  }

  // 对数压缩
  int correction_bits = MostSignificantBit32(state->fft.fft_size) - 1 - (kFilterbankBits / 2);
  uint16_t* logged_filterbank = LogScaleApply(&state->log_scale, scaled_filterbank,
                    state->filterbank.num_channels, correction_bits);

  // 最终的输出
  output.size = state->filterbank.num_channels;
  output.values = logged_filterbank;
  return output;
}


Tensorboard得到的结果如下所示:

Tflite-micro在ESP32实现离线命令识别

一个样本的spectrogram与micro特征图:

Tflite-micro在ESP32实现离线命令识别    Tflite-micro在ESP32实现离线命令识别

3模型训练

模型的训练过程都是tensorflow的常规步骤(见train.py文件),训练时所使用的参数如下: 


# 要识别的命令集
WANTED_WORDS = "up,down"

# 训练步长与学习率:此处为前12000步使用0.001的学习率,后3000步使用0.0001的学习率
TRAINING_STEPS = "12000,3000"
LEARNING_RATE = "0.001,0.0001"
number_of_labels = WANTED_WORDS.count(',') + 1

# 总的分类数为:WANTED_WORDS + 'silence' + 'unknown'
number_of_total_labels = number_of_labels + 2
equal_percentage_of_training_samples = int(100.0/(number_of_total_labels))

# 'silence'和'unknown'的训练样本比例 
SILENT_PERCENTAGE = equal_percentage_of_training_samples
UNKNOWN_PERCENTAGE = equal_percentage_of_training_samples

# 验证与测试集样本比例
VALIDATION_PERCENTAGE = 10
TESTING_PERCENTAGE = 10

# 预处理的特征类型:支持average, mfcc, micro
PREPROCESS = 'micro'

# 帧窗移动的步长
WINDOW_STRIDE = 20

# 支持的模型: single_fc, conv, low_latency_conv, low_latency_svdf, tiny_embedding_conv
MODEL_ARCHITECTURE = 'tiny_conv' 

VERBOSITY = 'WARN' # 训练过程的打印级别
EVAL_STEP_INTERVAL = '100' # 每100步验证一次
SAVE_STEP_INTERVAL = '100' # 每100步保存一次模型
BATCH_SIZE = '64' # 一次训练的样本数

# 输入与输出的目录与文件名定义
DATASET_DIR =  './dataset/'
LOGS_DIR = 'logs/'
TRAIN_DIR = 'train/'
MODELS_DIR = 'models'
MODEL_TF = os.path.join(MODELS_DIR, 'model.pb')
MODEL_TFLITE = os.path.join(MODELS_DIR, 'model.tflite')
FLOAT_MODEL_TFLITE = os.path.join(MODELS_DIR, 'float_model.tflite')
SAVED_MODEL = os.path.join(MODELS_DIR, 'saved_model')

# 量化阈值
QUANT_INPUT_MIN = 0.0
QUANT_INPUT_MAX = 26.0
QUANT_INPUT_RANGE = QUANT_INPUT_MAX - QUANT_INPUT_MIN

# 执行训练
!python ./speech_commands/train.py \
--data_dir={DATASET_DIR} \   # 数据目录
--wanted_words={WANTED_WORDS} \  # 命令集
--silence_percentage={SILENT_PERCENTAGE} \  # 作为silence的样本比例
--unknown_percentage={UNKNOWN_PERCENTAGE} \ # 作为unknown的样本比例
--validation_percentage={VALIDATION_PERCENTAGE} \  # 验证集比例
--testing_percentage={TESTING_PERCENTAGE} \ # 测试集比例
--batch_size={BATCH_SIZE} \  # 一次训练的样本数
--preprocess={PREPROCESS} \  # 特征类型
--window_stride={WINDOW_STRIDE} \  # 帧移步长
--model_architecture={MODEL_ARCHITECTURE} \  # 模型类型
--how_many_training_steps={TRAINING_STEPS} \ # 训练步数
--learning_rate={LEARNING_RATE} \  # 学习率
--train_dir={TRAIN_DIR} \  # 训练目录
--summaries_dir={LOGS_DIR} \ # 日志目录,tensorboard读取
--verbosity={VERBOSITY} \  # 打印级别
--eval_step_interval={EVAL_STEP_INTERVAL} \  # 每100步验证一次
--save_step_interval={SAVE_STEP_INTERVAL} \ # 每100步保存一次
--feature_bin_count=40 \  # 特征维度
--window_size_ms=30 \  # 窗大小
--clip_duration_ms=1000 \ # 音频数据长度
--sample_rate=16000 \ # 采样率
--optimizer='gradient_descent' \ # 优化器,同时还支持momentum
--quantize=False  # 是否量化训练,需要tensorflow<=1.15才能置为True


Tensorboard输出的acu与cross_entropy训练曲线如下:

Tflite-micro在ESP32实现离线命令识别

由于训练过程中只保留了checkpoint文件,还需要将模型固化为pb:


!python ./speech_commands/freeze.py \
--wanted_words=$WANTED_WORDS \
--window_stride_ms=$WINDOW_STRIDE \
--preprocess=$PREPROCESS \
--model_architecture=$MODEL_ARCHITECTURE \
--start_checkpoint=$TRAIN_DIR$MODEL_ARCHITECTURE'.ckpt-'{TOTAL_STEPS} \
--save_format=saved_model \
--output_file={SAVED_MODEL}


4量化

由于训练时使用非量化训练,得到的pb模型数据是float32类型,为了提高模型推理的速度,需要对模型做int8的量化处理并将模型转化为.tflite文件:


with tf.Session() as sess:
  float_converter = tf.lite.TFLiteConverter.from_saved_model(SAVED_MODEL)
  float_tflite_model = float_converter.convert()
  float_tflite_model_size = open(FLOAT_MODEL_TFLITE, "wb").write(float_tflite_model)
  print("Float model is %d bytes" % float_tflite_model_size)

  converter = tf.lite.TFLiteConverter.from_saved_model(SAVED_MODEL)
  converter.optimizations = [tf.lite.Optimize.DEFAULT]
  converter.inference_input_type = tf.lite.constants.INT8
  converter.inference_output_type = tf.lite.constants.INT8
  def representative_dataset_gen():
    set_size = audio_processor.set_size('testing') #get test set size
    for i in range(set_size): # change 100 to set_size
      data, _ = audio_processor.get_data(1, i*1, model_settings,
                                         BACKGROUND_FREQUENCY, 
                                         BACKGROUND_VOLUME_RANGE,
                                         TIME_SHIFT_MS,
                                         'testing',
                                         sess)
      flattened_data = np.array(data.flatten(), dtype=np.float32).reshape(1, 1960)
      yield [flattened_data]
  converter.representative_dataset = representative_dataset_gen
  tflite_model = converter.convert()
  tflite_model_size = open(MODEL_TFLITE, "wb").write(tflite_model)
  print("Quantized model is %d bytes" % tflite_model_size)


5部署

在嵌入式设备端,通过以下命令将.tflite模型文件转为C代码中的unsigned char g_model[]全局变量,内容为模型的训练参数。


# Install xxd if it is not available
#!apt-get update && apt-get -qq install xxd
# Convert to a C source file
MODEL_TFLITE_MICRO = os.path.join(MODELS_DIR, 'model.cc')
!xxd -i {MODEL_TFLITE} > {MODEL_TFLITE_MICRO}
# Update variable names
REPLACE_TEXT = MODEL_TFLITE.replace('/', '_').replace('.', '_')
!sed -i 's/'{REPLACE_TEXT}'/g_model/g' {MODEL_TFLITE_MICRO}


最终可以得到model.cc内容为: 
const unsigned char g_model[] DATA_ALIGN_ATTRIBUTE = {
    0x20, 0x00, 0x00, 0x00, 0x54, 0x46, 0x4c, 0x33, 0x00, 0x00, 0x00, 0x00,
    ... 
    0x03, 0x00, 0x00, 0x00};  // 模型参数
const int g_model_len = 18712; // 模型参数数量
固件的软件处理流程如下图所示: 

Tflite-micro在ESP32实现离线命令识别

  • 硬件:主控芯片使用ESP32,麦克风芯片使用INMP441,通过I2S读取PCM编码方式的音频输入数据,bck引脚接GPIO26,ws引脚接GPIO32,无data_out,data_in接GPIO33,采样率16000,16位采样位数,单声道。

  • 软件:启动两个任务,audio capture task用于将I2S数据读取到Ringbuffer,model inference task负责音频数据的特征提取、模型推理以及后处理。

四、总结

在本文中,我们了解了如何训练和优化语音识别模型,并将其转换为TensorFlow Lite格式以在ESP32上运行。使用TensorFlow Lite进行本地语音识别的优势在于可以非常方便地训练自己的模型,不需要依赖芯片厂商。然而,本文中使用的语音识别模型准确性与内存使用率上还有进一步优化的空间。

【参考文献】

[1]https://blog.csdn.net/qq_36002089/article/details/126849445

[2]https://haythamfayek.com/2016/04/21/speech-processing-for-machine-learning.html#fn:2

[3]https://www.justinsalamon.com/news/per-channel-energy-normalization-why-and-how

链接:https://mp.weixin.qq.com/s/UBoRS0SMbWdV5tyQxxjH_g

- 本文来自网络,如有侵权,请联系本站处理。

2023-09   阅读(1519)   评论(0)
 标签: ai TinyML ESP32

涨知识
新冠肺炎

新型冠状病毒肺炎(Corona Virus Disease 2019,COVID-19),简称“新冠肺炎”,世界卫生组织命名为“2019冠状病毒病” [1-2] ,是指2019新型冠状病毒感染导致的肺炎。

评论:
相关文章
ESP32 使用DAC模拟输出完成两路呼吸灯

ESP32的DAC函数可以实现真正的模拟输出。


在 ESP32 上使用 LEDC (PWM)

ESP32 没有Arduino输出 PWM 的 analogWrite(pin, value) 方法,取而代之的 ESP32 有一个 LEDC 来实现PWM功能。


Micropython基于ESP32的多线程开发

本文学习如何使用ESP32开发板来进行多线程的开发。


ESP8266 Arduino WIFI

ESP8266有三种工作模式,分别为:AP,STA,AP混合STA


ESP32 SPI

ESP32有四个SPI外设,分别为SPI0、SPI1、HSPI和VSPI。

搜索
小鹏STEM教研服务

专属教研服务系统,助您构建STEM课程体系,打造一站式教学环境。