目前国内做语音识别的服务商主要有百度、阿里和讯飞。简单的做了下对比,百度的语音识别采用HTTP REST API 的方式,每次只能上传一个完整的音频文件进行识别,不能边说边识别。讯飞和阿里都采用websocket的方式,支持流式识别,可以持续的将音频流识别为文字。从价格方面来说的话(个人使用),阿里支持2路并发无限次使用,讯飞每日500次免费调用量。对于个人来说话,都够用了。识别效果的话,目前只用过讯飞和百度的,没使用过阿里的,就不做对比了。这里主要介绍C#如何调用讯飞的SDK,阿里的暂不讨论(其实是没写过)。
参考资料:
具体的接入流程就不过多的介绍了,开发者文档里面写的很清楚。主要侃侃如何通过C#完成以上的接入流程,如何发送音频流和接收传回的识别结果。如果只是想使用讯飞的语音识别的话,可以直接打开上面封装的SDK连接,有很详细的Demo参考或者直接转到使用小节。
一、接入
1、接口鉴权
按照讯飞的开发文档,第一步是接口鉴权。在wss握手阶段,请求方需要对请求进行签名,服务端通过签名来校验请求的合法性。
鉴权方式是构造一个请求URL,例如
wss://iat-api.xfyun.cn/v2/iat?authorization=YXBpX2tleT0ia2V5eHh4eHh4eHg4ZWUyNzkzNDg1MTlleHh4eHh4eHgiLCBhbGdvcml0aG09ImhtYWMtc2hhMjU2IiwgaGVhZGVycz0iaG9zdCBkYXRlIHJlcXVlc3QtbGluZSIsIHNpZ25hdHVyZT0iSHAzVHk0WmtTQm1MOGpLeU9McFFpdjlTcjVudm1lWUVIN1dzTC9aTzJKZz0i&date=Wed%2C%2010%20Jul%202019%2007%3A35%3A43%20GMT&host=iat-api.xfyun.cn
通过此URL去请求讯飞服务器时,讯飞服务器验证参数的正确性。如果握手成功,会返回HTTP 101状态码,表示协议升级成功;如果握手失败,则根据不同错误类型返回不同HTTP Code状态码,同时携带错误描述信息。具体的参数生成规则和鉴权失败返回的错误代码请参考讯飞开发文档。
构造URL方法:
public static string BuildAuthUrl(AppSettings _settings)
{
string date = DateTime.UtcNow.ToString("r");
var uri = _settings.ApiType switch
{
Enum.ApiType.ASR => new Uri(_settings.ASRUrl),
Enum.ApiType.TTS => new Uri(_settings.TTSUrl),
_ => throw new Exception("Unknow Api type."),
};
//build signature string
string signatureOrigin = $"host: {uri.Host}ndate: {date}nGET {uri.LocalPath} HTTP/1.1";
string signature = HMACSha256(_settings.ApiSecret, signatureOrigin);
string authorization = $"api_key="{_settings.ApiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="{signature}"";
//Build url
StringBuilder urlBuilder = new StringBuilder();
urlBuilder.Append(uri.ToString());
urlBuilder.Append("?");
urlBuilder.Append("authorization=");
urlBuilder.Append(Base64.Base64Encode(authorization));
urlBuilder.Append("&");
urlBuilder.Append("date=");
urlBuilder.Append(HttpUtility.UrlEncode(date).Replace("+", "%20")); //默认会将空格编码为+号
urlBuilder.Append("&");
urlBuilder.Append("host=");
urlBuilder.Append(uri.Host);
return urlBuilder.ToString();
}
2、数据传输与接收
握手成功后客户端和服务端会建立Websocket连接,客户端通过Websocket连接可以同时上传和接收数据。
当服务端有识别结果时,会通过Websocket连接推送识别结果到客户端。
需要注意的是,发送数据时,如果间隔时间太短,可能会导致引擎识别有误。并建议使用未压缩的PCM格式,每次发送音频间隔40ms,每次发送音频字节数1280B。整个会话时长最多持续60s,或者超过10s未发送数据,服务端会主动断开连接。
数据上传完毕,客户端需要上传一次数据结束标识表示会话已结束,详见下方data参数说明。
建立连接,并开始接收数据:
_host = ApiAuthorization.BuildAuthUrl(_settings);
await _ws.ConnectAsync(new Uri(_host), CancellationToken.None);
_receiveTask = StartReceiving(_ws);
接收数据方法放到一个while循环中,当收到数据后,解析收到的数据并将解析后的数据放到缓冲区。识别结果的构成可以查看讯飞的开发文档,更具开发文档来解析收到的数据。需要注意的是,收到最后一帧数据后( data.status 值为2),需要断开ws连接,并结束本次的识别。我的做法是将一个标识设置为结束,当发送数据时,检测到结束后主动断开连接。
在接收数据过程中,同时检测ws连接是否被断开,断开后停止接收数据。
private async Task StartReceiving(ClientWebSocket client)
{
if (_result != null)
{
_result.Clear();
}
while (true)
{
try
{
if (client.CloseStatus == WebSocketCloseStatus.EndpointUnavailable ||
client.CloseStatus == WebSocketCloseStatus.InternalServerError ||
client.CloseStatus == WebSocketCloseStatus.EndpointUnavailable)
{
return;
}
var array = new byte[4096];
var receive = await client.ReceiveAsync(new ArraySegment<byte>(array), CancellationToken.None);
if (receive.MessageType == WebSocketMessageType.Text)
{
if (receive.Count <= 0)
{
continue;
}
string msg = Encoding.UTF8.GetString(array, 0, receive.Count);
ASRResult result = JsonHelper.DeserializeJsonToObject<ASRResult>(msg);
if (result.Code != 0)
{
throw new Exception($"Result error({result.Code}): {result.Message}");
}
if (result.Data == null
|| result.Data.result == null
|| result.Data.result.ws == null)
{
return;
}
//分析数据
StringBuilder itemStringBuilder = new StringBuilder();
foreach (var item in result.Data.result.ws)
{
foreach (var child in item.cw)
{
if (string.IsNullOrEmpty(child.w))
{
continue;
}
itemStringBuilder.Append(child.w);
}
}
if (result.Data.result.pgs == "apd")
{
_result.Add(new ResultWPGSInfo()
{
sn = result.Data.result.sn,
data = itemStringBuilder.ToString()
});
}
else if (result.Data.result.pgs == "rpl")
{
if (result.Data.result.rg == null || result.Data.result.rg.Count != 2)
{
continue;
}
int first = result.Data.result.rg[0];
int end = result.Data.result.rg[1];
try
{
ResultWPGSInfo item = _result.Where(p => p.sn >= first && p.sn <= end).SingleOrDefault();
if (item == null)
{
continue;
}
else
{
item.sn = result.Data.result.sn;
item.data = itemStringBuilder.ToString();
}
}
catch
{
continue;
}
}
StringBuilder totalStringBuilder = new StringBuilder();
foreach (var item in _result)
{
totalStringBuilder.Append(item.data);
}
OnMessage?.Invoke(this, totalStringBuilder.ToString());
//最后一帧,结束
if (result.Data.status == 2)
{
return;
}
}
}
catch (WebSocketException)
{
return;
}
catch (Exception ex)
{
//服务器主动断开连接
if (!ex.Message.ToLower().Contains("unable to read data from the transport connection"))
{
OnError?.Invoke(this, new ErrorEventArgs()
{
Code = ResultCode.Error,
Message = ex.Message,
Exception = ex,
});
}
return;
}
}
}
然后是发送数据,发送音频数据分为三个阶段。分别是起始、中间、结束。发送第一帧为起始阶段,需要发送三个参数:common、business、data。三个参数均为object对象,具体内容可以查看讯飞开发者文档。中间和结果阶段只需要发送data参数即可,不同的是,结果阶段发送的data中的status值为2,告诉服务器这是最后一帧,识别完成之后将会退出。
起始阶段:
FirstFrameData firstFrame = new FirstFrameData
{
common = _common,
business = _business,
data = _data
};
firstFrame.data.status = FrameState.First;
firstFrame.data.audio = System.Convert.ToBase64String(buffer);
await _ws.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(JsonHelper.SerializeObject(firstFrame)))
, WebSocketMessageType.Text
, true
, CancellationToken.None);
_status = FrameState.Continue;
中间阶段:
ContinueFrameData continueFrame = new ContinueFrameData
{
data = _data
};
continueFrame.data.status = FrameState.Continue;
continueFrame.data.audio = System.Convert.ToBase64String(buffer);
await _ws.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(JsonHelper.SerializeObject(continueFrame)))
, WebSocketMessageType.Text
, true
, CancellationToken.None);
结束阶段:
LastFrameData lastFrame = new LastFrameData
{
data = _data
};
lastFrame.data.status = FrameState.Last;
lastFrame.data.audio = System.Convert.ToBase64String(buffer);
await _ws.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(JsonHelper.SerializeObject(lastFrame)))
, WebSocketMessageType.Text
, true
, CancellationToken.None);
在数据发送完之后,等待数据结束完成。当数据接收完成之后,会将本次识别标志设置为结束。此时,完成本次识别并断开连接。
while (_receiveTask.Status != TaskStatus.RanToCompletion)
{
await Task.Delay(10);
}
await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "NormalClosure", CancellationToken.None);
整个识别过程的核心便是这样。在编写程序时,可以通过事件,一边识别,一边返回识别结果。在流式识别过程中,我才用了队列的方式,构造一个生产者和消费者模型,说话是产生音频数据,识别后消费掉音频数据。具体代码可以自行下载代码查看。
二、使用
讯飞没有提供C#的SDK,所以我将上面的过程封装为SDK,以供直接调用使用。SDK下载链接在文首的参考链接当中。
调用SDK时,首先构造API。目前SDK支持语音识别和语音转写,调用时可以选择构造自己需要的API。
构造ASRApi:
ASRApi iat = new ApiBuilder()
.WithAppSettings(new AppSettings()
{
ApiKey = "7b845bf729c3eeb97be6de4d29e0b446",
ApiSecret = "50c591a9cde3b1ce14d201db9d793b01",
AppID = "5c56f257"
})
.UseError((sender, e) =>
{
Console.WriteLine("错误:" + e.Message);
})
.UseMessage((sender, e) =>
{
Console.WriteLine("实时结果:" + e);
})
.BuildASR();
发送要识别的音频流:
for (int i = 0; i < data.Length; i += frameSize)
{
//模拟说话暂停
await Task.Delay(100);
iat.Convert(SubArray(data, i, frameSize));
}
结束本次识别并等待退出:
//结束本次会话
iat.Stop();
//等待本次会话结束
while (iat.Status != ServiceStatus.Stopped)
{
await Task.Delay(10);
}
需要注意的是,调用stop方法后并不会马上退出。程序可能还在继续识别并结束数据。只有全部数据接收完成,ServiceStatus为Stopped时,本次会话才全部结束。
demo下载: https://github.com/withsalt/IflySdk
运行截图:
语音合成和语音识别大同小异,就不过多赘述,同时也提供了TTS的Demo。最后,欢迎点赞收藏star哦~
文章评论