TinyML语音关键字唤醒

语音唤醒基本是目前主流的智障家居产品的必备功能,比如你喊Hey Google时候,这一段语音是被本地识别匹配的,然后唤醒后再联网,这样可以节约功耗,语音训练的模型都比较大(当然和图像没得比),为了偷懒(学习),还是剽窃谷歌的代码来得快,谷歌为了让人入门方便,还把不同的测试案例分开.

分别是5个测试文件:

  • micro_speech_test.cc => 对频谱图数据进行推断并解释. (不一定要频谱图,刚好这里用的是频谱图.)
  • feature_provider_test.cc => 演示如何采集音频.
  • feature_provider_mock_test.cc => 传递假数据验证,就是上面的功能,不过这里传的是假数据.
  • recognize_commands_test.cc => 演示解释输出确定是否检测到了命令.
  • command_responder_test.cc => 演示收到命令后该做什么.

之前做回归预测用到的资源很有限,我们直接引用AllOpsResolver,这样包含所有操作器,需要资源较多,我们这里试着,只占用自己需要的操作器.

  tflite::MicroMutableOpResolver<4> micro_op_resolver;
  micro_op_resolver.AddDepthwiseConv2D();
  micro_op_resolver.AddFullyConnected();
  micro_op_resolver.AddReshape();
  micro_op_resolver.AddSoftmax();

之后可以和Hello World一样,分配工作区,然后生成interpreter.

  // Build an interpreter to run the model with.
  tflite::MicroInterpreter interpreter(model, micro_op_resolver, tensor_arena,
                                       tensor_arena_size,
                                       &micro_error_reporter);
  interpreter.AllocateTensors();

然后判断输入的size,这里是1*1960,应该就是1960的byte组合而成一个维度,然后这就是数据的格式,然后分别读取g_yes_micro_f2e59fea_nohash_1_data和g_no_micro_f9643d42_nohash_4_data,然后判断输出,输出是1*4的dim,4是因为有4种可能,看看哪个为最高概率并且可信度高,那么这个语音就是什么,4种可能是[安静,未知,Yes,No].

注:如果不能理解,那么先补一下基础知识One-Hot Encoding!

接下来看音频提供程序,但是看之前先关注下audio_provider.h,我们硬件就是要实现这个头文件的实体,注释已被我翻译成中文,想看原文就只能自己去Gayhub看了.


/* 这是一个围绕音频源(比如麦克风)的抽象,预计将返回某个时间点的16位PCM样本数据.采样数据本身应该被调用者尽快使用,因为为了允许内存优化,不能保证采样不会被未来的新数据所覆盖.在实践中,实现应该确保在任何重用之前,有一个合理的时间允许客户访问数据.参考实现可以没有特定平台的依赖性,所以它只是返回一个充满零的数组.对于真正的应用,你应该确保有一个专门的实现可以访问硬件API. */
TfLiteStatus GetAudioSamples(tflite::ErrorReporter* error_reporter,
                             int start_ms /* 开始时间 */, int duration_ms /* 持续时间 */,
                             int* audio_samples_size /* 样本多大 */, int16_t** audio_samples /* 样本payload */);

/* 返回音频数据最后被捕获的时间,单位是毫秒.对于零点代表的时间,准确性或结果的颗粒度,没有任何合同.后续的调用一般不会返回一个更低的值,但如果有溢出包络,也不能保证.(反正溢出代表很大很大了!)这个函数的参考实现只是为每次调用返回一个不断递增的值,因为它需要一个不可移植的平台调用来访问时间信息.对于真正的应用,需要编写你自己的特定平台实现.*/
int32_t LatestAudioTimestamp();

其中audio_provider_test.cc和audio_provider_mock_test.cc就是调用这两个东西,其中带mock就是模拟采样了数据,具体的实体在audio_provider_mock.cc描述了.在单片机中一般是PDM/I2S之类采样然后得出结果.

采集到数据之后,要交给feature_provider_mock_test.cc进行数据特征提取,即变成频谱图,用FFT(或者其他算法),他最主要调用PopulateFeatureData,滑动地量化一秒的数据,至于是怎么量化的,由于解释起来麻烦.(我才不承认是懒),所以需要的可以自己研读代码.反正每30ms生成43个bucket,然后每次采样都会包含上一次采样的10ms,例子中没有用标准库,这个很赞,因为嵌入式平台的MicroLib中,标准库也很吃资源的啊.

量化后确实可以丢到程序里识别,但是如果用户说的是noted,那么可能会被误判成no,这样不太好,所以还要再处理下,这就需要命令识别程序了!(想起了天猫精灵,你只要一句话中包含天猫精灵,他也会反应,这算是命令识别程序一个BUG.),还有就是一般不会说完Yes立马就说No,这样特殊的我们也可以忽略之.

重点敲黑板,命令识别程序会计算过去几个推断中每个单词的平均分数,并决定这些分数是否足够高,这样虽然把多个(例子里是每个)推断结果都输入命令识别程序中.作为入口,先看看命令识别程序的逻辑.(同样,注释被我适量翻译了,可能有误,最好亲自到源码看.)

 public:
/* (构造函数)标签应该是一个与每个单次得分相关的字符串的列表,窗口持续时间控制平滑度,较长的持续时间将使结果具有较高的可信度,但可能会错过一些命令(比如一段时间的多条命令).检测阈值也有类似的效果,高值会增加精度,但会牺牲命令唤醒率.最小计数控制在平均化窗口中需要有多少个结果才会被看作是一个可靠的平均,通俗地讲就是判断出几个有效才开始计算.这可以防止在平均化窗口最初被填充时出现错误的结果,抑制参数在触发了一个参数后,在设定的时间内禁止进一步的识别,这可以帮助减少虚假的识别,比如有人连续读yesno,这么就只识别了yes. */
  explicit RecognizeCommands(tflite::ErrorReporter* error_reporter,
                             int32_t average_window_duration_ms /* 平均窗口时长 */ = 1000,
                             uint8_t detection_threshold /* 检出命令的最低阈值 */ = 200,
                             int32_t suppression_ms /* 听到第一个命令后,在识别第二个命令前锁等待的时间. */ = 1500,
                             int32_t minimum_count /* 窗口计算结果所需的最小推断次数 */ = 3);

  // (实际操作)传入上次的结果,然后继续进行识别.
  TfLiteStatus ProcessLatestResults(const TfLiteTensor* latest_results,
                                    const int32_t current_time_ms,
                                    const char** found_command, uint8_t* score,
                                    bool* is_new_command);

由于内部代码超长,我就这里概述一下,具体还是自己查看源码,先说ProcessLatestResults的流程:

  1. 检查输入的张量类型/形状是否对的.(dim,size,Int8)
  2. 检查current_time_ms验证是否在平均值窗口中的最新结果之后.
  3. 将最新结果添加到将要取平均值的结果列表里.
    • 如果不能提供有效平均值,就score设置0
    • 否则求出结果,如果执行到这一步,就有足够信息确定哪一个结果会胜出,如果胜出(即超出检测阈值),is_new_command设置true,否则做了等于白做..

最后来看一下测试,其中最基础的就是RecognizeCommandsTestBasic,他仅仅是生成一些假数据,然后调用ProcessLatestResults函数.只要返回kTfLiteOk,则说明数据被处理过.这个函数应该不断调用,直到检出命令.在主函数只需要执行它,确保主函数干净.

TF_LITE_MICRO_TEST(RecognizeCommandsTestBasic) {
  tflite::MicroErrorReporter micro_error_reporter;

  RecognizeCommands recognize_commands(&micro_error_reporter);

  const int8_t result_data[] = {127, -128, -128, -128};
  const int result_dims[] = {2, 1, 4};
  TfLiteTensor results = tflite::testing::CreateQuantizedTensor(
      result_data, tflite::testing::IntArrayFromInts(result_dims), -128.0f,
      127.0f);

  const char* found_command;
  uint8_t score;
  bool is_new_command;
  TF_LITE_MICRO_EXPECT_EQ(
      kTfLiteOk, recognize_commands.ProcessLatestResults(
                     &results, 0, &found_command, &score, &is_new_command));
}

最后一个组件就是当检出时,进行命令响应.这个没有什么测试(其实也有,command_responder_test就是这个测试),主要是移植到不同平台实现最终的效果.最后调用的是RespondToCommand函数,当然,拿到found_command后自己写函数也完全没问题,至此,已经学习关于这个模块的全部组件.你现在已经能看懂main_functions.cc了.

上次已经按每个部件分析了功能,应该能顺利看懂main_functions.cc了,这里很简单应该不用我多加废话,但是我们最关心在硬件上的实验,比如STM32F746 DISCO,暂时我们不考虑其他平台怎么移植,这些后续再说.

其中timer.cc就是提供ms计时,每1ms就会使数据+1,audio_provider.cc驱动板子上两个PDM麦克风.而command_responder.cc就是当听到命令时在屏幕上显示.

至于怎么编译,还是参照之前的文章:https://www.taterli.com/7656/

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注