UNPKG

@speechmatics/flow-client

Version:

Javascript client for the Speechmatics Flow API

352 lines (343 loc) 10.4 kB
import { TypedEventTarget } from 'typescript-event-target'; class AgentAudioEvent extends Event { constructor(data) { super("agentAudio"); this.data = data; } } class FlowIncomingMessageEvent extends Event { constructor(data) { super("message"); this.data = data; } } var __defProp$1 = Object.defineProperty; var __defNormalProp$1 = (obj, key, value) => key in obj ? __defProp$1(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField$1 = (obj, key, value) => __defNormalProp$1(obj, key + "" , value); const FLUSH = "flush"; class FlushEvent extends Event { constructor(data) { super(FLUSH); this.data = data; } } class JitterBuffer extends TypedEventTarget { constructor(maxByteLength) { super(); this.maxByteLength = maxByteLength; __publicField$1(this, "buffer", []); } get byteLength() { return this.buffer.reduce((sum, curr) => sum + curr.byteLength, 0); } enqueue(data) { this.buffer.push(data); if (this.byteLength >= this.maxByteLength) { this.flush(); } } flush() { this.dispatchTypedEvent(FLUSH, new FlushEvent(this.buffer)); this.buffer = []; } } var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); class FlowClient extends TypedEventTarget { constructor(server, { appId, audioBufferingMs = 10, websocketBinaryType = "blob" }) { super(); this.server = server; __publicField(this, "appId"); __publicField(this, "audioBufferingMs"); __publicField(this, "websocketBinaryType"); // active websocket __publicField(this, "ws", null); __publicField(this, "serverSeqNo", 0); // tracks sequence of server sent audio __publicField(this, "clientSeqNo", 0); // tracks sequence of client sent audio __publicField(this, "jitterBuffer", null); this.appId = appId; this.audioBufferingMs = audioBufferingMs; this.websocketBinaryType = websocketBinaryType; } get socketState() { if (!this.ws) return void 0; return { [WebSocket.CONNECTING]: "connecting", [WebSocket.OPEN]: "open", [WebSocket.CLOSING]: "closing", [WebSocket.CLOSED]: "closed" }[this.ws.readyState]; } async connect(jwt, timeoutMs = 1e4) { const socketState = this.socketState; if (socketState && socketState !== "closed") { throw new SpeechmaticsFlowError( "SocketNotClosed", `Cannot start connection while socket is ${socketState}` ); } const waitForConnect = new Promise((resolve, reject) => { const wsUrl = new URL("/v1/flow", this.server); wsUrl.searchParams.append("jwt", jwt); wsUrl.searchParams.append("sm-app", this.appId); this.ws = new WebSocket(wsUrl.toString()); this.ws.binaryType = this.websocketBinaryType; this.dispatchTypedEvent( "socketInitialized", new Event("socketInitialized") ); this.setupSocketEventListeners(); this.addEventListener("socketOpen", resolve, { once: true }); this.addEventListener( "socketError", (e) => { reject( new SpeechmaticsFlowError( "SocketError", "Error opening websocket", { cause: e } ) ); }, { once: true } ); }); const { timeout, cancelTimeout } = rejectAfter( timeoutMs, "websocket connect" ); await Promise.race([waitForConnect, timeout]); cancelTimeout(); } setupSocketEventListeners() { if (!this.ws) throw new SpeechmaticsFlowError("SocketError", "socket not initialized!"); this.ws.addEventListener("open", () => { this.dispatchTypedEvent("socketOpen", new Event("socketOpen")); }); this.ws.addEventListener( "close", () => this.dispatchTypedEvent("socketClose", new Event("socketClose")) ); this.ws.addEventListener( "error", (e) => this.dispatchTypedEvent("socketError", new Event("socketError", e)) ); this.ws.addEventListener("message", ({ data }) => { if (data instanceof Blob || data instanceof ArrayBuffer) { this.handleWebsocketAudio(data); } else if (typeof data === "string") { this.handleWebsocketMessage(data); } else { throw new SpeechmaticsFlowError( "UnexpectedMessage", `Unexpected message type: ${data}` ); } }); } handleWebsocketAudio(data) { this.sendWebsocketMessage({ message: "AudioReceived", seq_no: ++this.serverSeqNo, buffering: this.audioBufferingMs / 1e3 }); if (data instanceof Blob && this.websocketBinaryType === "blob") { data.arrayBuffer().then((b) => this.jitterBuffer?.enqueue(new Int16Array(b))).catch((e) => { throw new SpeechmaticsFlowError( "BadBinaryMessage", "Failed to decode array buffer", { cause: e } ); }); } else if (data instanceof ArrayBuffer && this.websocketBinaryType === "arraybuffer") { this.jitterBuffer?.enqueue(new Int16Array(data)); } else { throw new SpeechmaticsFlowError( "BadBinaryMessage", `Could not process audio: expecting audio to be ${this.websocketBinaryType} but got ${data.constructor.name}` ); } } handleWebsocketMessage(message) { let data; try { data = JSON.parse(message); } catch (e) { throw new SpeechmaticsFlowError( "UnexpectedMessage", "Failed to parse message as JSON", { cause: e } ); } if (data.message === "AudioAdded") { this.clientSeqNo = data.seq_no; } if (data.message === "ResponseCompleted" || data.message === "ResponseInterrupted") { this.jitterBuffer?.flush(); } this.dispatchTypedEvent("message", new FlowIncomingMessageEvent(data)); } sendWebsocketMessage(message) { if (this.socketState === "open") { this.ws?.send(JSON.stringify(message)); } } sendAudio(pcmData) { if (this.socketState !== "open") return; this.ws?.send(pcmData); } async startConversation(jwt, { config, audioFormat }) { await this.connect(jwt); const conversation_config = { ...config, template_variables: { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, ...config.template_variables } }; const startMessage = { message: "StartConversation", conversation_config, audio_format: audioFormat ?? DEFAULT_AUDIO_FORMAT }; const waitForConversationStarted = new Promise((resolve, reject) => { const client = this; this.addEventListener("message", function onStart({ data }) { if (data.message === "ConversationStarted") { resolve(); client.removeEventListener("message", onStart); } else if (data.message === "Error") { reject( new SpeechmaticsFlowError( "ServerError", "Error waiting for conversation start", { cause: data } ) ); } }); this.sendWebsocketMessage(startMessage); }); const rejectOnSocketClose = new Promise((_, reject) => { this.addEventListener( "socketClose", () => reject( new SpeechmaticsFlowError( "SocketClosedPrematurely", "Socket closed before conversation started" ) ), { once: true } ); }); const { timeout, cancelTimeout } = rejectAfter( 1e4, "conversation start" ); this.jitterBuffer = new JitterBuffer( TTS_BYTES_PER_MS * this.audioBufferingMs ); this.jitterBuffer.addEventListener("flush", ({ data }) => { for (const buffer of data) { this.dispatchTypedEvent("agentAudio", new AgentAudioEvent(buffer)); } }); try { await Promise.race([ waitForConversationStarted, rejectOnSocketClose, timeout ]); } finally { cancelTimeout(); } } endConversation() { this.sendWebsocketMessage({ message: "AudioEnded", last_seq_no: this.clientSeqNo }); this.disconnectSocket(); } disconnectSocket() { this.dispatchTypedEvent("socketClosing", new Event("socketClosing")); this.ws?.close(); } } function rejectAfter(timeoutMs, key) { let timeoutId = void 0; let resolve = void 0; const timeout = new Promise((_resolve, reject) => { resolve = _resolve; timeoutId = setTimeout( () => reject( new SpeechmaticsFlowError( "Timeout", `Timed out after ${timeoutMs}ms waiting for ${key}` ) ), timeoutMs ); }); const cancel = () => { if (typeof timeoutId !== "undefined") { clearTimeout(timeoutId); } resolve?.(); }; return { timeout, cancelTimeout: cancel }; } class SpeechmaticsFlowError extends Error { constructor(type, message, options) { super(message, options); this.type = type; this.name = "SpeechmaticsFlowError"; } } const DEFAULT_AUDIO_FORMAT = { type: "raw", encoding: "pcm_s16le", sample_rate: 16e3 }; const TTS_SAMPLE_RATE = 16e3; const TTS_BYTES_PER_SAMPLE = 2; const TTS_BYTES_PER_MS = TTS_SAMPLE_RATE * TTS_BYTES_PER_SAMPLE / 1e3; async function fetchPersonas() { const resp = await fetch( "https://flow.api.speechmatics.com/v1/discovery/templates" ); const json = await resp.json(); return json.templates; } const AddTranscriptMessageEnum = { AddTranscript: "AddTranscript" }; const RecognitionResultTypeEnum = { Word: "word", Punctuation: "punctuation" }; const RecognitionResultAttachesToEnum = { Next: "next", Previous: "previous", None: "none", Both: "both" }; const RecognitionDisplayDirectionEnum = { Ltr: "ltr", Rtl: "rtl" }; export { AddTranscriptMessageEnum, AgentAudioEvent, FlowClient, FlowIncomingMessageEvent, RecognitionDisplayDirectionEnum, RecognitionResultAttachesToEnum, RecognitionResultTypeEnum, SpeechmaticsFlowError, fetchPersonas }; //# sourceMappingURL=index.browser.js.map