我该如何通过socket.io从浏览器中实时流式传输音频到Google Cloud Speech?

14
我有一个React应用程序的情况,其中有一个输入框,我希望它可以语音输入。我只需要兼容Chrome和Firefox浏览器,所以我想使用getUserMedia。我知道我将使用Google Cloud的Speech to Text API。但是,我有一些注意事项:
  1. 我希望这可以实时流式传输我的音频数据,而不仅仅是在录制完成后。这意味着许多我找到的解决方案都不太适用,因为仅保存文件然后将其发送给Google Cloud Speech是不够的。
  2. 我不信任我的前端与我的Google Cloud API信息。相反,我已经在后端运行了一个服务,该服务拥有我的凭据,并且我想将音频(实时)流式传输到该后端,然后从该后端流式传输到Google Cloud,然后在收到更新时将其返回到前端。
  3. 我已经使用socket.io连接到该后端服务,我想完全通过sockets来管理此操作,而无需使用Binary.js或类似的内容。
目前没有好的教程可以指导我如何做到这一点。我该怎么办?
1个回答

21
首先,要给vin-ni的Google-Cloud-Speech-Node-Socket-Playground项目点赞,我的解决方案很大一部分是参考了他的项目。但我还需要对其进行一些改进以适应我的React应用程序,因此我分享了我所做的一些更改。
我的解决方案由四个部分组成,其中两个在前端,另外两个在后端。
我的前端解决方案包括以下两个部分:
  1. 一个实用程序文件,用于访问我的麦克风,将音频流传输到后端,从后端检索数据,每当从后端接收到数据时运行回调函数,然后在完成流式传输或后端抛出错误时清理自身。
  2. 一个包装了我的React功能的麦克风组件。
我的后端解决方案包括以下两个部分:
  1. 一个处理实际语音识别流的实用程序文件
  2. 我的main.js文件
(这些部分不需要被严格分开;我们的main.js文件已经足够庞大了。)
大部分代码只是选录,但我的实用程序将完整展示,因为我在所有涉及到的阶段都遇到了很多问题。我的前端实用程序文件如下:
// Stream Audio
let bufferSize = 2048,
    AudioContext,
    context,
    processor,
    input,
    globalStream;

//audioStream constraints
const constraints = {
    audio: true,
    video: false
};

let AudioStreamer = {
    /**
     * @param {function} onData Callback to run on data each time it's received
     * @param {function} onError Callback to run on an error if one is emitted.
     */
    initRecording: function(onData, onError) {
        socket.emit('startGoogleCloudStream', {
            config: {
                encoding: 'LINEAR16',
                sampleRateHertz: 16000,
                languageCode: 'en-US',
                profanityFilter: false,
                enableWordTimeOffsets: true
            },
            interimResults: true // If you want interim results, set this to true
        }); //init socket Google Speech Connection
        AudioContext = window.AudioContext || window.webkitAudioContext;
        context = new AudioContext();
        processor = context.createScriptProcessor(bufferSize, 1, 1);
        processor.connect(context.destination);
        context.resume();

        var handleSuccess = function (stream) {
            globalStream = stream;
            input = context.createMediaStreamSource(stream);
            input.connect(processor);

            processor.onaudioprocess = function (e) {
                microphoneProcess(e);
            };
        };

        navigator.mediaDevices.getUserMedia(constraints)
            .then(handleSuccess);

        // Bind the data handler callback
        if(onData) {
            socket.on('speechData', (data) => {
                onData(data);
            });
        }

        socket.on('googleCloudStreamError', (error) => {
            if(onError) {
                onError('error');
            }
            // We don't want to emit another end stream event
            closeAll();
        });
    },

    stopRecording: function() {
        socket.emit('endGoogleCloudStream', '');
        closeAll();
    }
}

export default AudioStreamer;

// Helper functions
/**
 * Processes microphone data into a data stream
 * 
 * @param {object} e Input from the microphone
 */
function microphoneProcess(e) {
    var left = e.inputBuffer.getChannelData(0);
    var left16 = convertFloat32ToInt16(left);
    socket.emit('binaryAudioData', left16);
}

/**
 * Converts a buffer from float32 to int16. Necessary for streaming.
 * sampleRateHertz of 1600.
 * 
 * @param {object} buffer Buffer being converted
 */
function convertFloat32ToInt16(buffer) {
    let l = buffer.length;
    let buf = new Int16Array(l / 3);

    while (l--) {
        if (l % 3 === 0) {
            buf[l / 3] = buffer[l] * 0xFFFF;
        }
    }
    return buf.buffer
}

/**
 * Stops recording and closes everything down. Runs on error or on stop.
 */
function closeAll() {
    // Clear the listeners (prevents issue if opening and closing repeatedly)
    socket.off('speechData');
    socket.off('googleCloudStreamError');
    let tracks = globalStream ? globalStream.getTracks() : null; 
        let track = tracks ? tracks[0] : null;
        if(track) {
            track.stop();
        }

        if(processor) {
            if(input) {
                try {
                    input.disconnect(processor);
                } catch(error) {
                    console.warn('Attempt to disconnect input failed.')
                }
            }
            processor.disconnect(context.destination);
        }
        if(context) {
            context.close().then(function () {
                input = null;
                processor = null;
                context = null;
                AudioContext = null;
            });
        }
}

除了getUserMedia配置(本身有点棘手)之外,此代码的主要亮点是处理器的onaudioprocess回调通过将数据转换为Int16后向套接字发出speechData事件。我在这里对我的链接参考进行了一些主要更改,包括用回调函数(由我的React组件使用)替换所有实际更新DOM的功能,并添加了一些源中未包含的错误处理。
然后,我只需使用以下内容在我的React组件中访问它:
onStart() {
    this.setState({
        recording: true
    });
    if(this.props.onStart) {
        this.props.onStart();
    }
    speechToTextUtils.initRecording((data) => {
        if(this.props.onUpdate) {
            this.props.onUpdate(data);
        }   
    }, (error) => {
        console.error('Error when recording', error);
        this.setState({recording: false});
        // No further action needed, as this already closes itself on error
    });
}

onStop() {
    this.setState({recording: false});
    speechToTextUtils.stopRecording();
    if(this.props.onStop) {
        this.props.onStop();
    }
}

我将我的实际数据处理程序作为属性传递给了这个组件。

然后在后端,我的服务处理了main.js中的三个主要事件:

// Start the stream
            socket.on('startGoogleCloudStream', function(request) {
                speechToTextUtils.startRecognitionStream(socket, GCSServiceAccount, request);
            });
            // Receive audio data
            socket.on('binaryAudioData', function(data) {
                speechToTextUtils.receiveData(data);
            });

            // End the audio stream
            socket.on('endGoogleCloudStream', function() {
                speechToTextUtils.stopRecognitionStream();
            });

我的 speechToTextUtils 看起来像这样:

// Google Cloud
const speech = require('@google-cloud/speech');
let speechClient = null;

let recognizeStream = null;

module.exports = {
    /**
     * @param {object} client A socket client on which to emit events
     * @param {object} GCSServiceAccount The credentials for our google cloud API access
     * @param {object} request A request object of the form expected by streamingRecognize. Variable keys and setup.
     */
    startRecognitionStream: function (client, GCSServiceAccount, request) {
        if(!speechClient) {
            speechClient = new speech.SpeechClient({
                projectId: 'Insert your project ID here',
                credentials: GCSServiceAccount
            }); // Creates a client
        }
        recognizeStream = speechClient.streamingRecognize(request)
            .on('error', (err) => {
                console.error('Error when processing audio: ' + (err && err.code ? 'Code: ' + err.code + ' ' : '') + (err && err.details ? err.details : ''));
                client.emit('googleCloudStreamError', err);
                this.stopRecognitionStream();
            })
            .on('data', (data) => {
                client.emit('speechData', data);

                // if end of utterance, let's restart stream
                // this is a small hack. After 65 seconds of silence, the stream will still throw an error for speech length limit
                if (data.results[0] && data.results[0].isFinal) {
                    this.stopRecognitionStream();
                    this.startRecognitionStream(client, GCSServiceAccount, request);
                    // console.log('restarted stream serverside');
                }
            });
    },
    /**
     * Closes the recognize stream and wipes it
     */
    stopRecognitionStream: function () {
        if (recognizeStream) {
            recognizeStream.end();
        }
        recognizeStream = null;
    },
    /**
     * Receives streaming data and writes it to the recognizeStream for transcription
     * 
     * @param {Buffer} data A section of audio data
     */
    receiveData: function (data) {
        if (recognizeStream) {
            recognizeStream.write(data);
        }
    }
};

再次说明,您并不一定需要这个工具文件,根据您获取凭据的方式,您可以将speechClient作为常量放在文件顶部。这只是我实现的方式。

最后,以上内容应该足以让您开始使用了。我鼓励您在重用或修改代码之前尽力理解它,因为它可能对您来说并不是“开箱即用”的,但与我找到的所有其他来源不同,这应该至少让您开始涉及项目的所有阶段。我希望这个答案能够防止其他人像我一样遭受痛苦。


@vinni 哈哈,我没意识到你已经在SO上有了自己的问题/答案。我可能仍然会发布我的答案,只是为了澄清关于基于React使用的细节(例如,如果您的组件可以重复卸载/重新挂载,则删除侦听器非常重要),但我很惊讶它没有出现在我的谷歌搜索中。再次感谢你的工作 - 如果没有它,我真的无法启动这个项目! - Amber B.
太好了!我很困惑的是,像Google Cloud和IBM Watson这样的语音识别提供商有适用于SAFARI的演示。我尝试过反向工程IBM和Google的演示网站,但由于代码庞大而没有成功。你的解决方案也适用于Safari吗? - Josh
@Sisir 通常这是某个地方内存不足的迹象,经常但并非总是因为某个地方有无限循环。我正在使用Nodejs,所以除了我这里所需的特殊配置外,我不知道是否需要任何特殊配置。我建议你在一个新问题中发布你的实现细节,你可能有一个内存问题或者无限循环的问题。 - Amber B.
我最近实现了类似的功能,但是“实时”处理的时间太慢了,实际上录制整个流并发送会更快。你有这样的经历吗? - FrenchMajesty
我们最近并没有广泛使用这个功能,但在所有的测试中,响应时间都是合理的。虽然谷歌的情况可能发生了变化,但我首先倾向于查看您的设置是否在某种程度上导致了这种延迟。 - Amber B.
显示剩余5条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接