科大讯飞实时语音转换

工作需要,调研了科大讯飞和 Google 的语音转写服务,Google 的服务由于限制诸多(你懂得),因此选择使用科大讯飞的服务,可以通过 Demo 试用,但是科大讯飞并没有直接提供实时转写的中间代码,而中间代码提供最重要的转码功能,只提供了上传 PCM 文件并识别语音结果的的 Demo,因此就 hack 了官网的代码,可以实现实时语音转写。

PCM文件是模拟音频信号经模数转换(A/D变换)直接形成的二进制序列,该文件没有附加的文件头和文件结束标志,处理这种格式要用到大量 Web Audio API,其中涉及到一些音频处理相关的知识,有兴趣的可以了解一下。

效果图:

首先是页面代码,很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>xunfei audio</title>
</head>
<body>
<h2>60秒限制性语音识别转换</h2>
<input type="button" value="开始录音" id="startAudio">
<input type="button" value="结束录音" id="stopAudio">
<div id="">时长: <b id="remainingTime">0</b> ms</div>
<div>识别结果:</div>
<div><b id="result_output"></b></div>
</body>
</html>

下面是主要代码:

由于我这里使用 Electron,因此会出现引入 npm 包的代码。

代码中的 appid、apikey 等被打码了,去科大讯飞官网注册一个账号可以得到 24 小时的免费使用时长。

如果是前端同学使用,请使用原生的 WebSocket 替换掉 ws 包。

更多代码细节请去看参考科大讯飞开发者文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
const CryptoJS = require('crypto-js')
const WebSocket = require('ws')

// 数据帧定义
const FRAME = {
STATUS_FIRST_FRAME: 0,
STATUS_CONTINUE_FRAME: 1,
STATUS_LAST_FRAME: 2
}
const highWaterMark = 1280

const sleep = async t => new Promise(resolve => setTimeout(resolve, t))

window.addEventListener('DOMContentLoaded', DOMContentLoadedFn)

async function DOMContentLoadedFn() {
const startAudio = document.getElementById('startAudio')
const stopAudio = document.getElementById('stopAudio')
const remainingTime = document.getElementById('remainingTime')

startAudio.addEventListener('click', async () => {
startAudio.setAttribute('disabled', true)
remainingTime.innerText = 0
result_output.innerText = ''

// 计时器
let t = 0

// 开启设备音频权限并收集处理音频数据
const medisStream = await openAudio()
const audioBuffer = []
// 拿到返回的函数,用于停止音频设备
const closeAudio = buildJsHandleAudio(medisStream, (pcmData) => {
// 对原始 pcm 数据进行转换
// 不明白这是用的什么算法
const data = to16BitPCM(to16kHz(pcmData))
audioBuffer.push(..._toConsumableArray(data))
})

// 建立科大讯飞 ws 连接,返回 send 方法用于数据发送
// 接收一个回调,在发生 close 事件或者 error 事件时调用
const sendData = await xunfei60(() => {
startAudio.removeAttribute('disabled')
clearInterval(t)
closeAudio()
stopAudio.removeEventListener('click', stopEventFn)
})

// 停止按钮
function stopEventFn (){
sendData('', FRAME.STATUS_LAST_FRAME)
clearInterval(t)
closeAudio()
stopAudio.removeEventListener('click', stopEventFn)
}
stopAudio.addEventListener('click', stopEventFn)

// 防止发送数据太快,音频数据不足
await sleep(1000)

// 发送开始帧
sendData(audioBuffer.splice(0, highWaterMark), FRAME.STATUS_FIRST_FRAME)
// 按照科大讯飞网站推荐的频率,每 40ms 发送 1280 字节的数据
t = setInterval(() => {
if (!audioBuffer.length) return
// 发送音频帧
let frame = audioBuffer.splice(0, highWaterMark)
sendData(frame, FRAME.STATUS_CONTINUE_FRAME)
// 计时
remainingTime.innerText = Number.parseInt(remainingTime.innerText) + 40
}, 40)
})
}

// https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Audio_API
// 构建音频处理流水线
function buildJsHandleAudio(medisStream, cb) {
const context = new AudioContext()
// 可以通过 js 直接处理音频的节点
// 这个接口已经不推荐使用,新的标准是 audio worker,但是还没有浏览器实现这个功能
const recorder = context.createScriptProcessor(0, 1, 1)
// 创建一个 MediaStreamAudioSourceNode 接口来关联可能来自本地计算机麦克风或其他来源的音频流 MediaStream
const ms = context.createMediaStreamSource(medisStream)
// 每当新的音频数据被放入输入缓冲区,就会产生一个AudioProcessingEvent事件,触发onaudioprocess回调
recorder.onaudioprocess = e => cb(e.inputBuffer.getChannelData(0))
ms.connect(recorder)
// destination 表示当前 audio context 中所有节点的最终节点,一般表示音频渲染设备
// 必须添加下面最终节点才会触发 onaudioprocess 事件
recorder.connect(context.destination)
return () => {
ms.disconnect(recorder)
recorder.disconnect(context.destination)
closeMedia(medisStream)
}
}

// 请求音频输入设备权限
async function openAudio() {
return navigator.mediaDevices.getUserMedia({
audio: true,
video: true
})
}

// 关闭媒体流
function closeMedia(medisStream) {
medisStream.getTracks().forEach(track => track.stop())
}

// 鉴权签名
function getAuthStr(date, config) {
let signatureOrigin = `host: ${config.host}\ndate: ${date}\nGET ${config.uri} HTTP/1.1`
let signatureSha = CryptoJS.HmacSHA256(signatureOrigin, config.apiSecret)
let signature = CryptoJS.enc.Base64.stringify(signatureSha)
let authorizationOrigin = `api_key="${config.apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`
let authStr = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(authorizationOrigin))
return authStr
}

// 将经过编码的 ArrayBuffer 转为 Base64
function ArrayBufferToBase64(buffer) {
var binary = ''
var bytes = new Uint8Array(buffer)
var len = bytes.byteLength
for (var i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i])
}
// 创建一个 base-64 编码的字符串
return window.btoa(binary)
}

function to16kHz(buffer) {
var data = new Float32Array(buffer)
// 取到 44khz,采样压缩 16k
var fitCount = Math.round(data.length * (16000 / 44100))
var newData = new Float32Array(fitCount)
var springFactor = (data.length - 1) / (fitCount - 1)
newData[0] = data[0]
for (var i = 1; i < fitCount - 1; i++) {
var tmp = i * springFactor
var before = Math.floor(tmp).toFixed()
var after = Math.ceil(tmp).toFixed()
var atPoint = tmp - before
newData[i] = data[before] + (data[after] - data[before]) * atPoint
}
newData[fitCount - 1] = data[data.length - 1]
return newData
}

function to16BitPCM(input) {
var dataLength = input.length * (16 / 8)
var dataBuffer = new ArrayBuffer(dataLength)
var dataView = new DataView(dataBuffer)
var offset = 0
for (var i = 0; i < input.length; i++, offset += 2) {
var s = Math.max(-1, Math.min(1, input[i]))
dataView.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true)
}
return Array.from(new Int8Array(dataView.buffer))
}

// 建立科大讯飞 ws 连接
function xunfei60(closeCallback) {
return new Promise((resolve, reject) => {
// 连接配置
const config = {
hostUrl: "wss://iat-api.xfyun.cn/v2/iat",
uri: "/v2/iat",
host: "iat-api.xfyun.cn",
appid: "5dad1c13",
apiSecret: "27236883328363967717625fbbf6bd2c",
apiKey: "0e606eafda5fa80a56e6a8fdf4aac4c3",
}

// 拼接 ws 连接地址
let date = (new Date().toUTCString())
let wssUrl = `${config.hostUrl}?authorization=${getAuthStr(date, config)}&date=${date}&host=${config.host}`
let ws = new WebSocket(wssUrl)

// 连接建立完毕
ws.on('open', (event) => {
console.log("websocket connect!")
resolve(send)
})

// 得到识别结果后进行处理
ws.on('message', (data, err) => {
if (err) {
console.log(`err:${err}`)
return
}
let res = JSON.parse(data)
res.data && res.data.result && haneldResult(res.data.result)
})

// 资源释放
ws.on('close', () => {
console.log('connect close!')
closeCallback()
})

// 建连错误
ws.on('error', (err) => {
console.log("websocket connect err: " + err)
closeCallback()
reject(err)
})

// 初始数据帧状态
let status = FRAME.STATUS_FIRST_FRAME
// 传输数据
// 参数:需要传输的经过编码的 PCM 数据、当前数据帧的传输状态
function send(data, outStatus) {
if (outStatus !== undefined) {
status = outStatus
}
let frame = ""
let frameDataSection = {
"status": status,
"format": "audio/L16;rate=16000",
"audio": ArrayBufferToBase64(data),
"encoding": "raw"
}
switch (status) {
case FRAME.STATUS_FIRST_FRAME:
// 初始数据帧,用于设置语音识别引擎
frame = {
common: {
app_id: config.appid
},
business: {
language: "cn",
domain: "iat",
// accent: "mandarin",
dwa: "wpgs", // 可选参数,动态修正
sample_rate: "16000",
vad_eos: 60000, // 端点检测的静默时间
ptt: 1, // 标点符号添加
nunum: 0
},
data: frameDataSection
}
// 第一个开始帧传输完成后,修改帧状态为中间态,然后不断发送PCM数据
status = FRAME.STATUS_CONTINUE_FRAME
break
case FRAME.STATUS_CONTINUE_FRAME:
case FRAME.STATUS_LAST_FRAME:
frame = {
data: frameDataSection
}
break
}
ws.send(JSON.stringify(frame))
}
})
}

// 处理返回结果打印在页面上
function haneldResult(data) {
let result_output = document.getElementById('result_output')

var str = ''
var resultStr = ''
var ws = data.ws || []
for (var i = 0; i < ws.length; i++) {
str = str + ws[i].cw[0].w
}
// 开启wpgs会有此字段
// 取值为 "apd"时表示该片结果是追加到前面的最终结果;取值为"rpl" 时表示替换前面的部分结果,替换范围为rg字段
if (!data.pgs || data.pgs === 'apd') {
resultText = result_output.innerText
}
resultStr = resultText + str
result_output.innerText = resultStr
}

function _toConsumableArray(arr) {
if (Array.isArray(arr)) {
for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) {
arr2[i] = arr[i];
}
return arr2;
} else {
return Array.from(arr);
}
}
◀        
        ▶