最近做了一个录音转写的项目,其中有一些自认为有意思的技术点,在此记录一下。

实时通话中的录音推流

此次项目中的部分场景是,坐席与客户通话时,要将通话的内容在网页端进行展示。如果是通话完成后,将对话内容展示出来,可以直接参照 Azure 的官方教程中的示例,链接。教程中,默认的方法就是将整个文件推送上去,无脑CV即可。

通话完成进行录音识别,语音转文字,可能会遇到的难点有:

录音文件与转写服务不在同一个服务器

此时,需要在自身的服务端实现一个录音下载的 stream 接口。转写服务的客户端,调用这个接口之后,可以选择将文件暂存到本地,或者直接将下载流转到 azure 的转写客户端。关键代码如下:

服务端接口:

1
2
3
4
5
6
7
8
9
10
11
12
// 获取录音文件
const getRecord = (req, res) => {
const { recordId } = req.query;
const recordPath = path.resolve(__dirname, 'record', `${recordId}.wav`);

res.writeHead(200, {
'Content-Type': 'audio/wav',
'Content-Length': fs.statSync(recordPath).size
});

fs.createReadStream(recordPath).pipe(res);
};

转写服务客户端,同时要考虑到链接会被重定向,主要使用以下两个函数:

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
function sendGetRequest(url, redirectTimes = 0) {
redirectTimes += 1;

return new Promise((resolve, reject) => {
if (redirectTimes > 10) {
return reject(new Error(`HTTP request Redirects exceeded, ${redirectTimes}`));
}

const req = http.get(url, { timeout: 3000 }, (res) => {
if (res.statusCode === 302) {
const redirectUrl = res.headers.location;
console.log('Received 302 Found, redirecting to:', redirectUrl);
return resolve(sendGetRequest(redirectUrl));
} else if (res.statusCode === 200) {
resolve(res);
} else {
reject(new Error(`HTTP request failed with status code ${res.statusCode}`));
}
});

req.on('error', (err) => {
reject(err);
});
});
}

async function getRemoteFile(file) {
let url = new URL(`/some-api`, host);
let filePath = path.join(__dirname, '暂存路径');
let writeStream = fs.createWriteStream(filePath);
let stream = await sendGetRequest(url.toString());

stream.pipe(writeStream);

return new Promise((resolve, reject) => {
writeStream.on('error', (err) => {
return reject(err);
});

writeStream.on('close', () => {
return resolve(filePath);
});
});
}

按照此链接中的方法,封装出一个接收文件输入流的转写函数 readStreamPush,将上面的文件流送入即可。

录音文件增量读取

因为是实时转写的,所以需要不断的读取在改变大小的文件。在node中,可以很方便的实现这一点。

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
let watcher = fs.watch(file, (eventType, filename) => {
if (eventType === 'change') {
this.readIncrementalData(file, (err, buffer, done) => {
if (err) {
this.emit('error', err);
}
if (buffer) {
readStreamPush(buffer);
}
if (err || done) {
this.emit('end');

watcher.close();
pushStream.close();
readStreamPush();

if (timeout) {
clearTimeout(timeout);
return;
}
}

if (timeout) {
clearTimeout(timeout);
}

timeout = setTimeout(() => {
watcher.close();
pushStream.close();
readStreamPush();
}, 60 * 1000);
});
}
});

let lastPosition = 0; // 记录上次读取的位置

function readIncrementalData(filePath, callback) {
fs.open(filePath, 'r', (err, fd) => {
if (err) {
return callback(err);
}

// 定位到上次读取的位置
fs.fstat(fd, (err, stats) => {
if (err) {
return callback(err);
}

const currentPosition = stats.size;
const bufferLength = currentPosition - lastPosition;

if (bufferLength > 0) {
const buffer = Buffer.alloc(bufferLength);
fs.read(fd, buffer, 0, bufferLength, lastPosition, (err, bytesRead, buffer) => {
if (err) {
return callback(err);
}
// 处理读取到的内容
callback(null, buffer);

lastPosition = currentPosition; // 更新上次读取的位置

fs.close(fd);
});
} else {
callback(null, null, true);
fs.close(fd);
}
});
});
}

这里加了一个定时,如果一分钟后文件大小不再改变,则视为文件读取完毕。

格式转换

此时还会遇到的问题是,转写结果不准确,转写结果偏移量也不准确。经排查,推送的录音文件格式与 azure 要求的不符。

要求是 WAV audio files, including 16-bit, mono PCM, 8 kHz, and 16 kHz.

那就还需要将上面的文件流进行格式转换,再送去推流。

关键代码如下:

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
const ffmpeg = require('fluent-ffmpeg');
const { Readable } = require('stream');

function createConversionStream(callback) {
let readStream = new Readable();
readStream._read = () => { };

let command = ffmpeg()
.input(readStream)
.audioFrequency(16000)
.audioChannels(1)
.audioCodec('pcm_s16le')
.on('error', (err) => {
callback(err);
})
.on('end', () => {
callback(null, null);
})
.format('wav');

command
.pipe()
.on('data', data => {
callback(null, data);
});

return function (buffer) {
if (buffer) {
readStream.push(buffer);
} else {
if (command && typeof command.kill === 'function') {
command.kill();
}
}
};
}

module.exports = {
createConversionStream,
}

上面的代码可视作返回输出流,直接送入封装的转写服务即可。

转写结果展示

在页面展示时,实现了一个类似于微信对话框的样式,可以直接用于展示转写结果,一左一右,坐席与客户。因为项目中有知识库,如果对话中涉及到了知识库设置的关键词,需要将关键词高亮显示,同时点击时要弹出知识库的内容。此处参考了类似于B站评论区的关键词的高亮样式。词语变色,右上角加了放大镜。

将转写结果存入 elasticsearch 中,可以很方便的实现这一点。

首先在前端实现一个自定义标签<search-keywords>,完成样式和点击的功能。

在实时转写的查询中,加入知识库的关键字作为匹配条件,使用 elasticsearch 的 match_phrase 查询。给匹配到的关键词加上标签,返回到前端时就能渲染了。

1
2
3
4
5
6
7
8
9
10
11
12
query.body.highlight = {
"fields": {
[highlightField]: {
"type": "unified",
"number_of_fragments": 0
}
},
"pre_tags": ["<search-keywords>"],
"post_tags": ["</search-keywords>"],
"require_field_match": true,
"encoder": "html"
};

本文为个人原创,转载请署名且注明出处。