最近做了一个录音转写的项目,其中有一些自认为有意思的技术点,在此记录一下。
实时通话中的录音推流 此次项目中的部分场景是,坐席与客户通话时,要将通话的内容在网页端进行展示。如果是通话完成后,将对话内容展示出来,可以直接参照 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" };
本文为个人原创 ,转载请署名且注明出处。