解析WeNet云端推理部署代码_华为云开发者社区的博客-程序员秘密

技术标签: ASR  WeNet  pytorch  语音识别  grpc  技术交流  

摘要:WeNet是一款开源端到端ASR工具包,它与ESPnet等开源语音项目相比,最大的优势在于提供了从训练到部署的一整套工具链,使ASR服务的工业落地更加简单。

本文分享自华为云社区《WeNet云端推理部署代码解析》,作者:xiaoye0829 。

WeNet是一款开源端到端ASR工具包,它与ESPnet等开源语音项目相比,最大的优势在于提供了从训练到部署的一整套工具链,使ASR服务的工业落地更加简单。如图1所示,WeNet工具包完全依赖于PyTorch生态:使用TorchScript进行模型开发,使用Torchaudio进行动态特征提取,使用DistributedDataParallel进行分布式训练,使用torch JIT(Just In Time)进行模型导出,使用LibTorch作为生产环境运行时。本系列将对WeNet云端推理部署代码进行解析。

图1:WeNet系统设计[1]

1. 代码结构

WeNet云端推理和部署代码位于wenet/runtime/server/x86路径下,编程语言为C++,其结构如下所示:

其中:

  • 语音文件读入与特征提取相关代码位于frontend文件夹下;
  • 端到端模型导入、端点检测与语音解码识别相关代码位于decoder文件夹下,WeNet支持CTC prefix beam search和融合了WFST的CTC beam search这两种解码算法,后者的实现大量借鉴了Kaldi,相关代码放在kaldi文件夹下;
  • 在服务化方面,WeNet分别实现了基于WebSocket和基于gRPC的两套服务端与客户端,基于WebSocket的实现位于websocket文件夹下,基于gRPC的实现位于grpc文件夹下,两种实现的入口main函数代码都位于bin文件夹下。
  • 日志、计时、字符串处理等辅助代码位于utils文件夹下。

WeNet提供了CMakeLists.txt和Dockerfile,使得用户能方便地进行项目编译和镜像构建。

2. 前端:frontend文件夹

1)语音文件读入

WeNet只支持44字节header的wav格式音频数据,wav header定义在WavHeader结构体中,包括音频格式、声道数、采样率等音频元信息。WavReader类用于语音文件读入,调用fopen打开语音文件后,WavReader先读入WavHeader大小的数据(也就是44字节),再根据WavHeader中的元信息确定待读入音频数据的大小,最后调用fread把音频数据读入buffer,并通过static_cast把数据转化为float类型。

struct WavHeader {
  char riff[4];  // "riff"
  unsigned int size;
  char wav[4];  // "WAVE"
  char fmt[4];  // "fmt "
  unsigned int fmt_size;
  uint16_t format;
  uint16_t channels;
  unsigned int sample_rate;
  unsigned int bytes_per_second;
  uint16_t block_size;
  uint16_t bit;
  char data[4];  // "data"
  unsigned int data_size;
};

这里存在的一个风险是,如果WavHeader中存放的元信息有误,则会影响到语音数据的正确读入。

2)特征提取

WeNet使用的特征是fbank,通过FeaturePipelineConfig结构体进行特征设置。默认帧长为25ms,帧移为10ms,采样率和fbank维数则由用户输入。

用于特征提取的类是FeaturePipeline。为了同时支持流式与非流式语音识别,FeaturePipeline类中设置了input_finished_属性来标志输入是否结束,并通过set_input_finished()成员函数来对input_finished_属性进行操作。

提取出来的fbank特征放在feature_queue_中,feature_queue_的类型是BlockingQueue<std::vector<float>>。BlockingQueue类是WeNet实现的一个阻塞队列,初始化的时候需要提供队列的容量(capacity),通过Push()函数向队列中增加特征,通过Pop()函数从队列中读取特征:

  • 当feature_queue_中的feature数量超过capacity,则Push线程被挂起,等待feature_queue_.Pop()释放出空间。
  • 当feature_queue_为空,则Pop线程被挂起,等待feature_queue_.Push()。
    线程的挂起和恢复是通过C++标准库中的线程同步原语std::mutex、std::condition_variable等实现。
    线程同步还用在AcceptWaveform和ReadOne两个成员函数中,AcceptWaveform把语音数据提取得到的fbank特征放到feature_queue_中,ReadOne成员函数则把特征从feature_queue_中读出,是经典的生产者消费者模式。

3. 解码器:decoder文件夹

1)TorchAsrModel

通过torch::jit::load对存在磁盘上的模型进行反序列化,得到一个ScriptModule对象。

torch::jit::script::Module model = torch::jit::load(model_path);

2)SearchInterface

WeNet推理支持的解码方式都继承自基类SearchInterface,如果要新增解码算法,则需继承SearchInterface类,并提供该类中所有纯虚函数的实现,包括:

// 解码算法的具体实现
virtual void Search(const torch::Tensor& logp) = 0;
// 重置解码过程
virtual void Reset() = 0;
// 结束解码过程
virtual void FinalizeSearch() = 0;
// 解码算法类型,返回一个枚举常量SearchType
virtual SearchType Type() const = 0;
// 返回解码输入
virtual const std::vector<std::vector<int>>& Inputs() const = 0;
// 返回解码输出
virtual const std::vector<std::vector<int>>& Outputs() const = 0;
// 返回解码输出对应的似然值
virtual const std::vector<float>& Likelihood() const = 0;
// 返回解码输出对应的次数
virtual const std::vector<std::vector<int>>& Times() const = 0;

目前WeNet只提供了SearchInterface的两种子类实现,也即两种解码算法,分别定义在CtcPrefixBeamSearch和CtcWfstBeamSearch两个类中。

3)CtcEndpoint

WeNet支持语音端点检测,提供了一种基于规则的实现方式,用户可以通过CtcEndpointConfig结构体和CtcEndpointRule结构体进行规则配置。WeNet默认的规则有三条:

  • 检测到了5s的静音,则认为检测到端点;
  • 解码出了任意时长的语音后,检测到了1s的静音,则认为检测到端点;
  • 解码出了20s的语音,则认为检测到端点。
    一旦检测到端点,则结束解码。另外,WeNet把解码得到的空白符(blank)视作静音。

4)TorchAsrDecoder

WeNet提供的解码器定义在TorchAsrDecoder类中。如图3所示,WeNet支持双向解码,即叠加从左往右解码和从右往左解码的结果。在CTC beam search之后,用户还可以选择进行attention重打分。

图2:WeNet解码计算流程[2]

可以通过DecodeOptions结构体进行解码参数配置,包括如下参数:

struct DecodeOptions {
  int chunk_size = 16;
  int num_left_chunks = -1;
  float ctc_weight = 0.0;
  float rescoring_weight = 1.0;
  float reverse_weight = 0.0;
  CtcEndpointConfig ctc_endpoint_config;
  CtcPrefixBeamSearchOptions ctc_prefix_search_opts;
  CtcWfstBeamSearchOptions ctc_wfst_search_opts;
};

其中,ctc_weight表示CTC解码权重,rescoring_weight表示重打分权重,reverse_weight表示从右往左解码权重。最终解码打分的计算方式为:

final_score = rescoring_weight * rescoring_score + ctc_weight * ctc_score;
rescoring_score = left_to_right_score * (1 - reverse_weight) +
right_to_left_score * reverse_weight

TorchAsrDecoder对外提供的解码接口是Decode(),重打分接口是Rescoring()。Decode()返回的是枚举类型DecodeState,包括三个枚举常量:kEndBatch,kEndpoint和kEndFeats,分别表示当前批数据解码结束、检测到端点、所有特征解码结束。

为了支持长语音识别,WeNet还提供了连续解码接口ResetContinuousDecoding(),它与解码器重置接口Reset()的区别在于:连续解码接口会记录全局已经解码的语音帧数,并保留当前feature_pipeline_的状态。

由于流式ASR服务需要在客户端和服务端之间进行双向的流式数据传输,WeNet实现了两种支持双向流式通信的服务化接口,分别基于WebSocket和gRPC。

4. 基于WebSocket

1)WebSocket简介

WebSocket是基于TCP的一种新的网络协议,与HTTP协议不同,WebSocket允许服务器主动发送信息给客户端。 在连接建立后,客户端和服务端可以连续互相发送数据,而无需在每次发送数据时重新发起连接请求。因此大大减小了网络带宽的资源消耗 ,在性能上更有优势。

WebSocket支持文本和二进制两种格式的数据传输 。

2)WeNet的WebSocket接口

WeNet使用了boost库的WebSocket实现,定义了WebSocketClient(客户端)和WebSocketServer(服务端)两个类。

在流式ASR过程中,WebSocketClient给WebSocketServer发送数据可以分为三个步骤:1)发送开始信号与解码配置;2)发送二进制语音数据:pcm字节流;3)发送停止信号。从WebSocketClient::SendStartSignal()和WebSocketClient::SendEndSignal()可以看到,开始信号、解码配置和停止信号都是包装在json字符串中,通过WebSocket文本格式传输。pcm字节流则通过WebSocket二进制格式进行传输。

void WebSocketClient::SendStartSignal() {
  // TODO(Binbin Zhang): Add sample rate and other setting surpport
  json::value start_tag = {
   {"signal", "start"},
                           {"nbest", nbest_},
                           {"continuous_decoding", continuous_decoding_}};
  std::string start_message = json::serialize(start_tag);
  this->SendTextData(start_message);
}

void WebSocketClient::SendEndSignal() {
  json::value end_tag = {
   {"signal", "end"}};
  std::string end_message = json::serialize(end_tag);
  this->SendTextData(end_message);
}

WebSocketServer在收到数据后,需要先判断收到的数据是文本还是二进制格式:如果是文本数据,则进行json解析,并根据解析结果进行解码配置、启动或停止,处理逻辑定义在ConnectionHandler::OnText()函数中。如果是二进制数据,则进行语音识别,处理逻辑定义在ConnectionHandler::OnSpeechData()中。

3)缺点

WebSocket需要开发者在WebSocketClient和WebSocketServer写好对应的消息构造和解析代码,容易出错。另外,从以上代码来看,服务需要借助json格式来序列化和反序列化数据,效率没有protobuf格式高。

对于这些缺点,gRPC框架提供了更好的解决方法。

5. 基于gRPC

1)gRPC简介

gRPC是谷歌推出的开源RPC框架,使用HTTP2作为网络传输协议,并使用protobuf作为数据交换格式,有更高的数据传输效率。在gRPC框架下,开发者只需通过一个.proto文件定义好RPC服务(service)与消息(message),便可通过gRPC提供的代码生成工具(protoc compiler)自动生成消息构造和解析代码,使开发者能更好地聚焦于接口设计本身。

进行RPC调用时,gRPC Stub(客户端)向gRPC Server(服务端)发送.proto文件中定义的Request消息,gRPC Server在处理完请求之后,通过.proto文件中定义的Response消息将结果返回给gRPC Stub。

gRPC具有跨语言特性,支持不同语言写的微服务进行互动,比如说服务端用C++实现,客户端用Ruby实现。protoc compiler支持12种语言的代码生成。

图1:gRPC Server和gRPC Stub交互[1]

2)WeNet的proto文件

WeNet定义的服务为ASR,包含一个Recognize方法,该方法的输入(Request)、输出(Response)都是流式数据(stream)。在使用protoc compiler编译proto文件后,会得到4个文件:wenet.grpc.pb.h,http://wenet.grpc.pb.cc,wenet.pb.h,http://wenet.pb.cc。其中,wenet.pb.h/cc中存储了protobuf数据格式的定义,wenet.grpc.pb.h中存储了gRPC服务端/客户端的定义。通过在代码中包括wenet.pb.h和wenet.grpc.pb.h两个头文件,开发者可以直接使用Request消息和Response消息类,访问其字段。

service ASR {
  rpc Recognize (stream Request) returns (stream Response) {}
}

message Request {

  message DecodeConfig {
    int32 nbest_config = 1;
    bool continuous_decoding_config = 2;
  }

  oneof RequestPayload {
    DecodeConfig decode_config = 1;
    bytes audio_data = 2;
  }
}

message Response {

  message OneBest {
    string sentence = 1;
    repeated OnePiece wordpieces = 2;
  }

  message OnePiece {
    string word = 1;
    int32 start = 2;
    int32 end = 3;
  }

  enum Status {
    ok = 0;
    failed = 1;
  }

  enum Type {
    server_ready = 0;
    partial_result = 1;
    final_result = 2;
    speech_end = 3;
  }

  Status status = 1;
  Type type = 2;
  repeated OneBest nbest = 3;
}

3)WeNet的gRPC实现

WeNet gRPC服务端定义了GrpcServer类,该类继承自wenet.grpc.pb.h中的纯虚基类ASR::Service。

语音识别的入口函数是GrpcServer::Recognize,该函数初始化一个GRPCConnectionHandler实例来进行语音识别,并通过ServerReaderWriter类的stream对象来传递输入输出。

Status GrpcServer::Recognize(ServerContext* context,
                             ServerReaderWriter<Response, Request>* stream) {
  LOG(INFO) << "Get Recognize request" << std::endl;
  auto request = std::make_shared<Request>();
  auto response = std::make_shared<Response>();
  GrpcConnectionHandler handler(stream, request, response, feature_config_,
                                decode_config_, symbol_table_, model_, fst_);
  std::thread t(std::move(handler));
  t.join();
  return Status::OK;
}

WeNet gRPC客户端定义了GrpcClient类。客户端在建立与服务端的连接时需实例化ASR::Stub,并通过ClientReaderWriter类的stream对象,实现双向流式通信。

void GrpcClient::Connect() {
  channel_ = grpc::CreateChannel(host_ + ":" + std::to_string(port_),
                                 grpc::InsecureChannelCredentials());
  stub_ = ASR::NewStub(channel_);
  context_ = std::make_shared<ClientContext>();
  stream_ = stub_->Recognize(context_.get());
  request_ = std::make_shared<Request>();
  response_ = std::make_shared<Response>();
  request_->mutable_decode_config()->set_nbest_config(nbest_);
  request_->mutable_decode_config()->set_continuous_decoding_config(
      continuous_decoding_);
  stream_->Write(*request_);
}

http://grpc_client_main.cc中,客户端分段传输语音数据,每0.5s进行一次传输,即对于一个采样率为8k的语音文件来说,每次传4000帧数据。为了减小传输数据的大小,提升数据传输速度,先在客户端将float类型转为int16_t,服务端在接受到数据后,再将int16_t转为float。c++中float为32位。

int main(int argc, char *argv[]) {
  ...
  // Send data every 0.5 second
  const float interval = 0.5;
  const int sample_interval = interval * sample_rate;
  for (int start = 0; start < num_sample; start += sample_interval) {
    if (client.done()) {
      break;
    }
    int end = std::min(start + sample_interval, num_sample);
    // Convert to short
    std::vector<int16_t> data;
    data.reserve(end - start);
    for (int j = start; j < end; j++) {
      data.push_back(static_cast<int16_t>(pcm_data[j]));
    }
    // Send PCM data
    client.SendBinaryData(data.data(), data.size() * sizeof(int16_t));
    ...
}

总结

本文主要对WeNet云端部署代码进行解析,介绍了WeNet基于WebSocket和基于gRPC的两种服务化接口。

WeNet代码结构清晰,简洁易用,为语音识别提供了从训练到部署的一套端到端解决方案,大大促进了工业落地效率,是非常值得借鉴学习的语音开源项目。

参考

[1] https://grpc.io/docs/what-is-grpc/introduction/

[2]WeNet: Production First and Production Ready End-to-End Speech Recognition Toolkit

[3]WeNet源码

[4]WeNet: Production First and Production Ready End-to-End Speech Recognition Toolkit

[5] U2++: Unified Two-pass Bidirectional End-to-end Model for Speech Recognition

点击关注,第一时间了解华为云新鲜技术~

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/devcloud/article/details/121927145

智能推荐

intellij idea 插件 ideaVim 用法_weixin_33883178的博客-程序员秘密

intellij idea 插件 ideaVim - Genji_ - 博客园http://www.cnblogs.com/nova-/p/3535636.html IdeaVim插件使用技巧 - - ITeye技术网站http://kidneyball.iteye.com/blog/1828427 Ctrl+Alt+V  --打开或关闭Idea Vim 当打开idea vim后,当前编...

【操作系统】CPU寄存器详解_公子无缘的博客-程序员秘密_cpu 寄存器

寄存器是 CPU 内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果以及一些 CPU 运行需要的信息。本文将归纳下面几中寄存器:目录一 通用寄存器二 标志寄存器三指令寄存器四 段寄存器五 控制寄存器六 调试寄存器七 描述符寄存器八 任务寄存器九 MSR寄存器一 通用寄存器 最常用的,也是最基础的有8个通用寄存器(注意一般看到的EAX、ECX也是指的这类寄存器再32位CPU上的拓展,另...

android studio : Could not find org.jetbrains.kotlin:kotlin-stdlib-jre7:1.5.31_Mars-xq的博客-程序员秘密

插件版本配置:仓库配置:ext.kotlin_version = '1.5.31'repositories { maven{ url 'https://maven.aliyun.com/repository/google'} maven{ url 'https://maven.aliyun.com/repository/gradle-plugin'} maven{ url 'https://maven.aliyun.com/repository/public'} ma

[USACO09OCT]热浪Heat Wave 洛谷 1339 最短路_A_loud_name的博客-程序员秘密

题目大意单源最短路···········分析写dij就好了, 但是 我写了dij+堆优化版本的。学习了如何使用c++的优先队列。ps:家里的键盘很恶心啊:f5、f7、f11太小了,按不到。 ps:c++的模板正在补全中。code//dij+堆优化版本#include<iostream>#include<cstring>#include<cstdio>#include<cmath>#i

【操作系统】用户态线程和内核态线程有什么区别?_吻雨_Beta的博客-程序员秘密_用户线程和内核线程

本文内容转载于“拉勾教育”的讲义,详细可看拉勾教育的课程。本人学习之余做做笔记,顺便当个搬运工。目录用户态线程和内核态线程有什么区别?什么是用户态和内核态?系统调用过程线程模型用户态线程内核态线程用户态线程和内核态线程之间的映射关系总结用户态线程和内核态线程有什么区别?这是一个组合型的问题,由很多小问题组装而成,比如:用户态和内核态是什么?用户级线程和内核级线程是一个怎样的对应关系?内核响应系统调用是一个怎样的过程?什么是用户态和内核态?Kernel

随便推点

vscode代码索引_VSCode 配置文件的变量索引_weixin_39646725的博客-程序员秘密

VS Code 的配置文件可以使用一些预设好的变量,更加方便的配置task和debugging。本文将简述一部分自带的变量,这些变量的基本解析格式 ${变量名}。预设变量${workspaceFolder} - VS Code 中打开的文件夹目录 (通常是项目的位置)${workspaceFolderBasename} - 没有任何斜杠 (/)的 VS Code 中打开的文件夹目录${file} ...

vs2010 c++项目创建简易教程_启迪小天才的博客-程序员秘密_vs2010怎么创建一个c项目

VS2010 C++输出hello worldVisual Studio是微软公司推出的开发环境,是目前流行的Windows平台应用程序开发环境。下面通过“hello world”程序介绍如何在Microsoft Visual Studio2010(VS2010)【更高版本的如小括号提示】集成开发环境中创建一个简单的C++程序。1、创建项目与源文件step1:打开VS2010,在VS2010主窗口的主菜单栏中选中文件(File),然后选择新建(New),单击项目(Project),如图所示。ste

操作系统之文件管理(一)_Ssaty.的博客-程序员秘密_操作系统文件管理代码

第1关:移动文件请通过Python编程,将一个目录下的文件全部移动到另一个目录下。import osimport shutildef movefiles(sourceDir, targetDir): # 请在此添加代码,补全函数movefiles 实现将sourceDir下文件移动到targetDir下的功能 #-----------Begin---------- if not os.path.exists(targetDir): os.mkdir(tar

英语歌曲:Home(家)_北京小辉的博客-程序员秘密

Another summer day 又一个夏天 Has come and gone away 来了又走 In Paris or Rome… 在巴黎或者罗马 But I wanna go home 但是我只想回家… uhmMay be surrounded by 可能被成千上万的人 A million people I 所拥戴追逐 但我 Still feel al

一元四次方程c语言程序编写,一元高次方程数值解法C程序实现探讨..doc_墨村拓哉的博客-程序员秘密

一元高次方程数值解法C程序实现探? 一元高次方程作为方程的一部分,对我们后续的学习起着相当重要的作用求解一元高次方程的根在计算数学方面既是难点也是重点。一元三次方程和一元四次方称有一般解法,但是比较复杂,且超过了一般的知识范围5次以及5次以上的代数方程,没有一般的公式解法。文我们了解了系数在有理数域且只有有理根的一元高次方程的解法技巧一元二次方程根式解的推敲了一元三次、四次方程的根式解;最后介绍了...

《Java代码审计》作者柯俊师傅告诉你为什么要学Java代码审计,不看是你的损失!..._Ms08067安全实验室的博客-程序员秘密

出品|MS08067实验室(www.ms08067.com)李柯俊:国内某知名企业实验室高级web研究员,《java代码审计:入门篇》作者,曾在freebuf、 安全客发表多篇高质量技术...

推荐文章

热门文章

相关标签