UNPKG

@speechly/browser-client

Version:

JavaScript client for Speechly Streaming API

515 lines (444 loc) 17.5 kB
import { v4 as uuidv4 } from 'uuid' import { validateToken, fetchToken } from '../websocket/token' import { SegmentState, ErrAppIdChangeWithoutProjectLogin, Segment, SLUResults, WebsocketError } from '../speechly' import { APIClient, WebWorkerController, WebsocketResponse, WebsocketResponseType, TentativeTranscriptResponse, TranscriptResponse, TentativeEntitiesResponse, EntityResponse, IntentResponse, WorkerSignal, } from '../websocket' import { Storage, LocalStorage } from '../storage' import { DecoderState, EventCallbacks, ContextOptions, VadOptions, AudioProcessorParameters, StreamOptions, StreamDefaultOptions, ResolvedDecoderOptions } from './types' import { stateToString } from './state' import { parseTentativeTranscript, parseIntent, parseTranscript, parseTentativeEntities, parseEntity } from './parsers' const deviceIdStorageKey = 'speechly-device-id' const authTokenKey = 'speechly-auth-token' /** * A client for Speechly Spoken Language Understanding (SLU) API. The client handles initializing the websocket * connection to Speechly API, sending control events and audio stream. It reads and dispatches the responses * through a high-level API for interacting with so-called speech segments. * @internal */ export class CloudDecoder { private readonly debug: boolean private readonly logSegments: boolean private readonly projectId?: string private readonly appId?: string private readonly storage?: Storage private readonly apiClient: APIClient private readonly loginUrl: string private readonly deviceId: string private readonly apiUrl: string streamOptions: StreamOptions = StreamDefaultOptions private resolveStopStream?: any private activeContexts = 0 private readonly audioContexts = new Map<string, SLUResults>() private readonly maxReconnectAttemptCount = 10 private connectAttempt: number = 0 private connectPromise: Promise<void> | null = null private authToken?: string private readonly cbs: EventCallbacks[] = [] sampleRate: number state: DecoderState = DecoderState.Disconnected constructor(options: ResolvedDecoderOptions) { this.logSegments = options.logSegments this.appId = options.appId this.projectId = options.projectId this.sampleRate = options.sampleRate this.debug = options.debug if (this.appId !== undefined && this.projectId !== undefined) { throw Error('[Decoder] You cannot use both appId and projectId at the same time') } else if (this.appId === undefined && this.projectId === undefined) { throw Error('[Decoder] Either an appId or a projectId is required') } const apiUrl = options.apiUrl this.apiUrl = generateWsUrl(apiUrl.replace('http', 'ws') + '/ws/v1', this.sampleRate) this.loginUrl = `${apiUrl}/login` // Attempt to access local storage for caching settings. Do without if it's blocked by security settings try { this.storage = options.storage ?? new LocalStorage() this.deviceId = this.storage.getOrSet(deviceIdStorageKey, uuidv4) } catch (err) { this.deviceId = uuidv4() } this.apiClient = new WebWorkerController() this.apiClient.onResponse(this.handleWebsocketResponse) this.apiClient.onClose(this.handleWebsocketClosure) if (options.connect ?? true) { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.connect() } } private getReconnectDelayMs(attempt: number): number { return 2 ** attempt * 100 } private async sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)) } /** * Connect to Speechly backend. * This function will be called by initialize if not manually called earlier. * Calling connect() immediately after constructor and setting callbacks allows * prewarming the connection, resulting in less noticeable waits for the user. */ async connect(): Promise<void> { if (this.connectPromise === null) { this.connectPromise = (async () => { this.setState(DecoderState.Disconnected) // Get auth token from cache or renew it const storedToken = this.storage?.get(authTokenKey) if (!storedToken || !validateToken(storedToken, this.projectId, this.appId, this.deviceId)) { try { this.authToken = await fetchToken(this.loginUrl, this.projectId, this.appId, this.deviceId, fetch) // Cache the auth token in local storage for future use. if (this.storage) { this.storage.set(authTokenKey, this.authToken) } } catch (err) { this.connectPromise = null this.setState(DecoderState.Failed) throw err } } else { this.authToken = storedToken } // Establish websocket connection try { await this.apiClient.initialize(this.apiUrl, this.authToken, this.sampleRate, this.debug) } catch (err) { this.connectPromise = null if (!(err instanceof WebsocketError && err.code === 1000)) { this.setState(DecoderState.Failed) } throw err } this.advanceState(DecoderState.Connected) })() } await this.connectPromise } /** * Control audio processor parameters * @param ap - Audio processor parameters to adjust */ adjustAudioProcessor(ap: AudioProcessorParameters): void { this.apiClient.adjustAudioProcessor(ap) } /** * Closes the client by closing the API connection. */ async close(): Promise<void> { let error: string | undefined try { await this.apiClient.close() } catch (err) { // @ts-ignore error = err.message } this.audioContexts.clear() this.activeContexts = 0 this.connectPromise = null this.setState(DecoderState.Disconnected) if (error !== undefined) { throw Error(error) } } async startStream(streamOptions: StreamOptions): Promise<void> { if (this.debug) { console.log('[Decoder]', 'startStream') } this.streamOptions = streamOptions this.audioContexts.clear() this.activeContexts = 0 await this.apiClient.startStream(streamOptions) } async stopStream(): Promise<void> { if (this.debug) { console.log('[Decoder]', 'stopStream') } await this.apiClient.stopStream() await this.waitResults() } private async waitResults(): Promise<void> { // Wait for active contexts to finish if (this.activeContexts > 0) { const p = new Promise(resolve => { this.resolveStopStream = resolve }) await p } this.resolveStopStream = undefined } /** * Starts a new SLU context by sending a start context event to the API. */ async startContext(options?: ContextOptions): Promise<string> { if (this.state === DecoderState.Failed) { throw Error('[Decoder] startContext cannot be run in Failed state.') } else if (this.state < DecoderState.Connected) { await this.connect() } else if (this.state > DecoderState.Connected) { throw Error( '[Decoder] Unable to complete startContext: Expected Connected state, but was in ' + stateToString(this.state) + '.', ) } this.setState(DecoderState.Active) let contextId: string if (this.projectId != null) { if (options?.appId) { contextId = await this.apiClient.startContext(options) } else { throw new Error('options.appId is required with project login') } } else { if (options?.appId != null && this.appId !== options?.appId) { this.setState(DecoderState.Failed) throw ErrAppIdChangeWithoutProjectLogin } contextId = await this.apiClient.startContext(options) } // Ensure state has not been changed by await apiClient.startContext() due to websocket errors. // Due to apiClient.startContext implementation, they don't throw an error here, but call handleWebsocketClosure instead which changes to DecoderState.Disconnected if (this.state < DecoderState.Active) { throw Error('[Decoder] Unable to complete startContext: Problem acquiring contextId') } return contextId } /** * Send audio array. */ sendAudio(audio: Float32Array): void { this.apiClient.sendAudio(audio) } /** * Stops current SLU context by sending a stop context event to the API and muting the microphone * delayed by contextStopDelay = 250 ms */ async stopContext(stopDelayMs: number): Promise<string> { if (this.state === DecoderState.Failed) { throw Error('[Decoder] stopContext cannot be run in unrecovable error state.') } else if (this.state !== DecoderState.Active) { throw Error( '[Decoder] Unable to complete stopContext: Expected Active state, but was in ' + stateToString(this.state) + '.', ) } if (stopDelayMs > 0) { await this.sleep(stopDelayMs) } const contextId = await this.apiClient.stopContext() this.setState(DecoderState.Connected) return contextId } /** * Stops current context and immediately starts a new SLU context * by sending a start context event to the API and unmuting the microphone. * @param options - any custom options for the audio processing. */ async switchContext(options: ContextOptions): Promise<string> { if (this.state !== DecoderState.Active) { throw Error( '[Decoder] Unable to complete switchContext: Expected Active state, but was in ' + stateToString(this.state) + '.', ) } const contextId = await this.apiClient.switchContext(options) return contextId } registerListener(listener: EventCallbacks): void { this.cbs.push(listener) } async initAudioProcessor(sourceSampleRate: number, frameMillis: number, historyFrames: number, vadOptions?: VadOptions): Promise<void> { await this.apiClient.initAudioProcessor(sourceSampleRate, frameMillis, historyFrames, vadOptions) } useSharedArrayBuffers(controlSAB: any, dataSAB: any): void { this.apiClient.postMessage({ type: 'SET_SHARED_ARRAY_BUFFERS', controlSAB, dataSAB, }) } async setContextOptions(options: ContextOptions): Promise<void> { await this.apiClient.setContextOptions(options) } private readonly handleWebsocketResponse = (response: WebsocketResponse): void => { if (this.debug) { console.log('[Decoder]', 'Received response', response) } switch (response.type) { case WorkerSignal.VadSignalHigh: this.cbs.forEach(cb => cb.onVadStateChange.forEach(f => f(true))) break case WorkerSignal.VadSignalLow: this.cbs.forEach(cb => cb.onVadStateChange.forEach(f => f(false))) break case WorkerSignal.RequestContextStart: this.activeContexts++ break case WebsocketResponseType.Started: { const params = response.params this.audioContexts.set(response.audio_context, { segments: new Map(), audioStartTimeMillis: params?.audioStartTimeMillis ?? 0, }) this.cbs.forEach(cb => cb.contextStartedCbs.forEach(f => f(response.audio_context))) break } case WebsocketResponseType.Stopped: { this.activeContexts-- this.cbs.forEach(cb => cb.contextStoppedCbs.forEach(f => f(response.audio_context))) if (!this.streamOptions.preserveSegments) { this.audioContexts.delete(response.audio_context) } // Signal stopStream listeners that the final results are in, it's ok to resolve the await if (this.resolveStopStream !== undefined && this.activeContexts === 0) { this.resolveStopStream() } break } default: this.handleSegmentUpdate(response) break } } private readonly handleSegmentUpdate = (response: WebsocketResponse): void => { const { audio_context, segment_id, type } = response let { data } = response const context = this.audioContexts.get(audio_context) if (context === undefined) { console.warn('[Decoder]', 'Received response for non-existent context', audio_context) return } let segmentState = context.segments.get(segment_id) ?? new SegmentState(audio_context, segment_id) switch (type) { case WebsocketResponseType.TentativeTranscript: data = data as TentativeTranscriptResponse const words = parseTentativeTranscript(data, context.audioStartTimeMillis) const transcript = data.transcript this.cbs.forEach(cb => cb.tentativeTranscriptCbs.forEach(f => f(audio_context, segment_id, words, transcript))) segmentState = segmentState.updateTranscript(words) break case WebsocketResponseType.Transcript: data = data as TranscriptResponse const word = parseTranscript(data, context.audioStartTimeMillis) this.cbs.forEach(cb => cb.transcriptCbs.forEach(f => f(audio_context, segment_id, word))) segmentState = segmentState.updateTranscript([word]) break case WebsocketResponseType.TentativeEntities: data = data as TentativeEntitiesResponse const entities = parseTentativeEntities(data) this.cbs.forEach(cb => cb.tentativeEntityCbs.forEach(f => f(audio_context, segment_id, entities))) segmentState = segmentState.updateEntities(entities) break case WebsocketResponseType.Entity: data = data as EntityResponse const entity = parseEntity(data) this.cbs.forEach(cb => cb.entityCbs.forEach(f => f(audio_context, segment_id, entity))) segmentState = segmentState.updateEntities([entity]) break case WebsocketResponseType.TentativeIntent: data = data as IntentResponse const tentativeIntent = parseIntent(data, false) this.cbs.forEach(cb => cb.tentativeIntentCbs.forEach(f => f(audio_context, segment_id, tentativeIntent))) segmentState = segmentState.updateIntent(tentativeIntent) break case WebsocketResponseType.Intent: data = data as IntentResponse const intent = parseIntent(data, true) this.cbs.forEach(cb => cb.intentCbs.forEach(f => f(audio_context, segment_id, intent))) segmentState = segmentState.updateIntent(intent) break case WebsocketResponseType.SegmentEnd: segmentState = segmentState.finalize() break default: // TODO: handle unexpected response types. } // Update the segment in current context. context.segments.set(segment_id, segmentState) // Update current contexts. this.audioContexts.set(audio_context, context) // Log segment to console if (this.logSegments) { console.info(segmentState.toString()) } // Fire segment change event. this.cbs.forEach(cb => cb.segmentChangeCbs.forEach(f => f(segmentState.toSegment()))) } // eslint-disable-next-line @typescript-eslint/member-delimiter-style private readonly handleWebsocketClosure = (err: WebsocketError): void => { if (err.code === 1000) { if (this.debug) { console.log('[Decoder]', 'Websocket closed', err) } } else { console.error('[Decoder]', 'Websocket closed due to error', err) // If for some reason deviceId is missing, there's nothing else we can do but fail completely. if (this.deviceId === undefined) { this.setState(DecoderState.Failed) console.error('[Decoder]', 'No deviceId. Giving up reconnecting.') return } this.setState(DecoderState.Disconnected) this.activeContexts = 0 this.audioContexts.clear() // eslint-disable-next-line @typescript-eslint/no-floating-promises this.reconnect() } } private async reconnect(): Promise<void> { console.log('Speechly reconnecting') this.connectPromise = null if (this.connectAttempt < this.maxReconnectAttemptCount) { await this.sleep(this.getReconnectDelayMs(this.connectAttempt++)) // eslint-disable-next-line @typescript-eslint/no-floating-promises await this.connect() } else { console.error('[Decoder] Maximum reconnect count reached, giving up automatic reconnect.') } } private advanceState(newState: DecoderState): void { if (this.state >= newState) { return } this.setState(newState) } private setState(newState: DecoderState): void { if (this.state === newState) { return } if (this.debug) { console.log('[Decoder]', stateToString(this.state), '->', stateToString(newState)) } this.state = newState this.cbs.forEach(cb => cb.stateChangeCbs?.forEach(f => f(newState))) } /** * @returns Array of Segments since last startStream if preserveSegment options was used */ getSegments(): Segment[] { const result: Segment[] = [] this.audioContexts.forEach((audioContext, _) => { audioContext.segments.forEach((segment, _) => { const deepCopy = JSON.parse(JSON.stringify(segment)) result.push(deepCopy) }) }) return result } } function generateWsUrl(baseUrl: string, sampleRate: number): string { const params = new URLSearchParams() params.append('sampleRate', sampleRate.toString()) return `${baseUrl}?${params.toString()}` }