UNPKG

@speechly/browser-client

Version:

JavaScript client for Speechly Streaming API

404 lines (341 loc) 13.4 kB
import AudioProcessor from '../audioprocessing/AudioProcessor' import EnergyThresholdVAD from '../audioprocessing/EnergyThresholdVAD' import AudioTools from '../audioprocessing/AudioTools' import { ControllerSignal, WebsocketResponse, WebsocketResponseType, WorkerSignal, StartContextParams } from './types' import { AudioProcessorParameters, ContextOptions, StreamOptions, VadOptions } from '../client' const CONTROL = { WRITE_INDEX: 0, FRAMES_AVAILABLE: 1, LOCK: 2, } /** * Web worker to handle streaming audio to cloud and receiving speech processing results. * Also handles audio processing like maintaining history ringbuffer and running the VAD * @internal */ class WebsocketClient { private readonly workerCtx: Worker private targetSampleRate: number = 16000 private isContextStarted: boolean = false private audioContextStartTimes: number[] = [] private websocket?: WebSocket private audioProcessor?: AudioProcessor private controlSAB?: Int32Array private dataSAB?: Float32Array private immediateMode = false private readonly frameMillis = 30 private readonly outputAudioFrame: Int16Array = new Int16Array(this.frameMillis * this.targetSampleRate / 1000) private debug: boolean = false private defaultContextOptions?: ContextOptions constructor(ctx: Worker) { this.workerCtx = ctx } connect(apiUrl: string, authToken: string, targetSampleRate: number, debug: boolean): void { this.debug = debug if (this.debug) { console.log('[WebSocketClient]', 'connecting to ', apiUrl) } this.targetSampleRate = targetSampleRate this.isContextStarted = false this.websocket = new WebSocket(apiUrl, authToken) this.websocket.addEventListener('open', this.onWebsocketOpen) this.websocket.addEventListener('message', this.onWebsocketMessage) this.websocket.addEventListener('error', this.onWebsocketError) this.websocket.addEventListener('close', this.onWebsocketClose) } initAudioProcessor(sourceSampleRate: number, frameMillis: number, historyFrames: number, vadOptions?: VadOptions): void { this.audioProcessor = new AudioProcessor(sourceSampleRate, this.targetSampleRate, frameMillis, historyFrames) if (vadOptions) { this.audioProcessor.vad = new EnergyThresholdVAD(frameMillis, vadOptions) this.audioProcessor.onVadStateChange = (isSignalDetected: boolean) => { const currentVadOptions = this.audioProcessor?.vad?.vadOptions if (!currentVadOptions) return if (isSignalDetected) { if (!this.immediateMode) { this.workerCtx.postMessage({ type: WorkerSignal.VadSignalHigh }) } else if (currentVadOptions.controlListening) { this.startContext(this.defaultContextOptions) } } if (!isSignalDetected) { if (!this.immediateMode) { this.workerCtx.postMessage({ type: WorkerSignal.VadSignalLow }) } else if (currentVadOptions.controlListening) { this.stopContext() } } } } this.audioProcessor.onSendAudio = (floats: Float32Array, startIndex: number, length: number) => { AudioTools.convertFloatToInt16(floats, this.outputAudioFrame, startIndex, length) this.send(this.outputAudioFrame) } if (this.workerCtx === undefined) return this.workerCtx.postMessage({ type: WorkerSignal.AudioProcessorReady }) } /** * Control audio processor parameters * @param ap - Audio processor parameters to adjust */ adjustAudioProcessor(ap: AudioProcessorParameters): void { if (this.audioProcessor && ap.vad) { if (!this.audioProcessor.vad) { throw new Error('No VAD in AudioProcessor. Did you define `vad` in BrowserClient constructor parameters?') } this.audioProcessor.vad.adjustVadOptions(ap.vad) } } setSharedArrayBuffers(controlSAB: number, dataSAB: number): void { this.controlSAB = new Int32Array(controlSAB) this.dataSAB = new Float32Array(dataSAB) const audioHandleInterval = this.dataSAB.length / 32 // ms if (this.debug) { console.log('[WebSocketClient]', 'Audio handle interval', audioHandleInterval, 'ms') } setInterval(this.processAudioSAB.bind(this), audioHandleInterval) } startStream(streamOptions: StreamOptions): void { if (!this.audioProcessor) { throw new Error('No AudioProcessor') } this.immediateMode = streamOptions.immediate this.audioProcessor.reset(streamOptions.sampleRate) this.audioContextStartTimes = [] } stopStream(): void { if (!this.audioProcessor) { throw new Error('No AudioProcessor') } // Send EOS. Ensure VAD will go off at end of stream and stopContext is called in immediate mode this.audioProcessor.eos() } /** * Processes and sends audio * @param audioChunk - audio data to process */ processAudio(audioChunk: Float32Array): void { if (!this.audioProcessor) { throw new Error('No AudioProcessor') } this.audioProcessor.processAudio(audioChunk) } processAudioSAB(): void { if (!this.controlSAB || !this.dataSAB) { throw new Error('No SharedArrayBuffers') } const framesAvailable = this.controlSAB[CONTROL.FRAMES_AVAILABLE] const lock = this.controlSAB[CONTROL.LOCK] if (lock === 0 && framesAvailable > 0) { const data = this.dataSAB.subarray(0, framesAvailable) this.controlSAB[CONTROL.FRAMES_AVAILABLE] = 0 this.controlSAB[CONTROL.WRITE_INDEX] = 0 if (data.length > 0) { this.processAudio(data) } } } startContext(contextOptions?: ContextOptions): void { if (!this.audioProcessor) { throw Error('No AudioProcessor') } if (this.isContextStarted) { console.error('[WebSocketClient]', "can't start context: active context exists") return } this.audioProcessor.setSendAudio(true) this.isContextStarted = true this.audioContextStartTimes.push(this.audioProcessor.getStreamPosition()) this.workerCtx.postMessage({ type: WorkerSignal.RequestContextStart }) let options: ContextOptions = this.defaultContextOptions ?? {} if (contextOptions !== undefined) { options = { ...options, ...contextOptions } } const message = contextOptionsToMsg(options) message.event = 'start' this.send(JSON.stringify(message)) } stopContext(): void { if (!this.audioProcessor) { throw Error('No AudioProcessor') } if (!this.isContextStarted) { console.error('[WebSocketClient]', "can't stop context: no active context") return } this.audioProcessor.setSendAudio(false) this.isContextStarted = false const StopEventJSON = JSON.stringify({ event: 'stop' }) this.send(StopEventJSON) } switchContext(contextOptions?: ContextOptions): void { if (!this.websocket) { throw Error('WebSocket is undefined') } if (!this.isContextStarted) { console.error('[WebSocketClient]', "can't switch context: no active context") return } if (contextOptions?.appId === undefined) { console.error('[WebSocketClient]', "can't switch context: new app id is undefined") return } const StopEventJSON = JSON.stringify({ event: 'stop' }) this.send(StopEventJSON) const message = contextOptionsToMsg(contextOptions) message.event = 'start' this.send(JSON.stringify(message)) } closeWebsocket(code: number = 1005, reason: string = 'No Status Received', wasClean: boolean = true, userInitiated: boolean = true): void { if (!this.websocket) { console.warn('WebSocket already closed') return } else if (this.debug) { console.log('[WebSocketClient]', userInitiated ? 'Websocket close requested' : 'Websocket closed') } // Reset audioprocessor so it won't try to send audio the first thing when reconnect happens. This will lead to a reconnect loop. this.audioProcessor?.reset() // We don't want any more messages from the closing websocket this.websocket.removeEventListener('open', this.onWebsocketOpen) this.websocket.removeEventListener('message', this.onWebsocketMessage) this.websocket.removeEventListener('error', this.onWebsocketError) this.websocket.removeEventListener('close', this.onWebsocketClose) // If we're here due to a call to onWebSocket if (userInitiated) { this.websocket.close(code, reason) } this.websocket = undefined this.workerCtx.postMessage({ type: WorkerSignal.Closed, code, reason, wasClean, }) } // WebSocket's close handler, called when encountering a non user-initiated close, e.g. // - network unreachable or unable to (re)connect (code 1006) // List of CloseEvent.code values: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code private readonly onWebsocketClose = (event: CloseEvent): void => { if (this.debug) { console.log('[WebSocketClient]', 'onWebsocketClose') } this.closeWebsocket(event.code, event.reason, event.wasClean, false) } private readonly onWebsocketOpen = (_event: Event): void => { if (this.debug) { console.log('[WebSocketClient]', 'websocket opened') } this.workerCtx.postMessage({ type: WorkerSignal.Opened }) } private readonly onWebsocketError = (_event: Event): void => { if (this.debug) { console.log('[WebSocketClient]', 'websocket error') } } private readonly onWebsocketMessage = (event: MessageEvent): void => { let response: WebsocketResponse try { response = JSON.parse(event.data) } catch (e) { console.error('[WebSocketClient]', 'error parsing response from the server:', e) return } if (response.type === WebsocketResponseType.Started) { // Append client-side metadata to the backend message let audioContextStartTime = this.audioContextStartTimes.shift() if (audioContextStartTime === undefined) { console.warn('No valid value for contextStartMillis') audioContextStartTime = 0 } const startContextParams: StartContextParams = { audioStartTimeMillis: audioContextStartTime, } response.params = startContextParams } this.workerCtx.postMessage(response) } send(data: string | Int16Array): void { if (!this.websocket) { throw new Error('No Websocket') } if (this.websocket.readyState !== this.websocket.OPEN) { console.warn(`Expected OPEN Websocket state, but got ${this.websocket.readyState}`) return } try { this.websocket.send(data) } catch (error) { console.log('[WebSocketClient]', 'server connection error', error) } } setContextOptions(options: ContextOptions): void { this.defaultContextOptions = options } } const ctx: Worker = self as any const websocketClient = new WebsocketClient(ctx) ctx.onmessage = function (e) { switch (e.data.type) { case ControllerSignal.connect: websocketClient.connect(e.data.apiUrl, e.data.authToken, e.data.targetSampleRate, e.data.debug) break case ControllerSignal.initAudioProcessor: websocketClient.initAudioProcessor(e.data.sourceSampleRate, e.data.frameMillis, e.data.historyFrames, e.data.vadOptions) break case ControllerSignal.adjustAudioProcessor: websocketClient.adjustAudioProcessor(e.data.params) break case ControllerSignal.SET_SHARED_ARRAY_BUFFERS: websocketClient.setSharedArrayBuffers(e.data.controlSAB, e.data.dataSAB) break case ControllerSignal.CLOSE: websocketClient.closeWebsocket(1000, 'Close requested by client', true, true) break case ControllerSignal.startStream: websocketClient.startStream(e.data.streamOptions) break case ControllerSignal.stopStream: websocketClient.stopStream() break case ControllerSignal.START_CONTEXT: websocketClient.startContext(e.data.options) break case ControllerSignal.SWITCH_CONTEXT: websocketClient.switchContext(e.data.options) break case ControllerSignal.STOP_CONTEXT: websocketClient.stopContext() break case ControllerSignal.AUDIO: websocketClient.processAudio(e.data.payload) break case ControllerSignal.setContextOptions: websocketClient.setContextOptions(e.data.options) break default: console.log('WORKER', e) } } export function contextOptionsToMsg(contextOptions?: ContextOptions): Record<string, any> { const message: Record<string, any> = { options: { timezone: [Intl.DateTimeFormat().resolvedOptions().timeZone], }, } if (contextOptions === undefined) return message message.options.vocabulary = contextOptions.vocabulary message.options.vocabulary_bias = contextOptions.vocabularyBias message.options.silence_triggered_segmentation = contextOptions.silenceTriggeredSegmentation if (contextOptions.nonStreamingNlu) { message.options.non_streaming_nlu = ['yes'] } else { message.options.non_streaming_nlu = ['no'] } if (contextOptions?.timezone !== undefined) { message.options.timezone = contextOptions?.timezone // override browser timezone } if (contextOptions.appId !== undefined) { message.appId = contextOptions.appId } return message } export default WebsocketClient