simli-client
Advanced tools
| import { SimliClientEvents } from './Events'; | ||
| import { Logger, LogLevel } from './Logger'; | ||
| interface SimliSessionRequest { | ||
| faceId: string; | ||
| handleSilence: boolean; | ||
| maxSessionLength: number; | ||
| maxIdleTime: number; | ||
| model?: "fasttalk" | "artalk"; | ||
| } | ||
| interface TokenRequestData { | ||
| config: SimliSessionRequest; | ||
| apiKey: string; | ||
| } | ||
| interface SimliSessionToken { | ||
| session_token: string; | ||
| } | ||
| type TransportMode = "livekit" | "p2p"; | ||
| type SignalingMode = "websockets"; | ||
| type session_token = string; | ||
| declare function generateSimliSessionToken(request: TokenRequestData, SimliURL?: string): Promise<SimliSessionToken>; | ||
| declare function generateIceServers(apiKey: string, SimliURL?: string): Promise<RTCIceServer[]>; | ||
| declare class SimliClient { | ||
| session_token: string; | ||
| transport: TransportMode; | ||
| signaling: SignalingMode; | ||
| videoElement: HTMLVideoElement; | ||
| audioElement: HTMLAudioElement; | ||
| audioBufferSize: number; | ||
| private connection; | ||
| private connectionTimeout; | ||
| private connectionResolve; | ||
| private connectionReject; | ||
| private connectionPromise; | ||
| private sourceNode; | ||
| private audioWorklet; | ||
| private readonly MAX_RETRY_ATTEMPTS; | ||
| private RETRY_DELAY; | ||
| private readonly CONNECTION_TIMEOUT_MS; | ||
| private retryAttempt; | ||
| private SimliWSURL; | ||
| private audioContext; | ||
| private logger; | ||
| private iceServers; | ||
| private persistent_events; | ||
| private failReason; | ||
| private shouldStop; | ||
| on<K extends keyof SimliClientEvents>(event: K, callback: SimliClientEvents[K]): void; | ||
| off<K extends keyof SimliClientEvents>(event: K, callback: SimliClientEvents[K]): void; | ||
| constructor(session_token: session_token, videoElement: HTMLVideoElement, audioElement: HTMLAudioElement, iceServers: RTCIceServer[] | null, logLevel?: LogLevel, transport_mode?: TransportMode, signaling?: SignalingMode, SimliWSURL?: string, audioBufferSize?: number); | ||
| private resetConnections; | ||
| start(): Promise<void>; | ||
| stop(): Promise<void>; | ||
| listenToMediastreamTrack(stream: MediaStreamTrack): void; | ||
| private initializeAudioWorklet; | ||
| ClearBuffer: () => void; | ||
| sendAudioData(audioData: Uint8Array): void; | ||
| sendAudioDataImmediate(audioData: Uint8Array): void; | ||
| } | ||
| export { SimliClient, generateSimliSessionToken, generateIceServers, Logger, LogLevel }; | ||
| export type { SimliSessionRequest }; |
+281
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.LogLevel = exports.Logger = exports.SimliClient = void 0; | ||
| exports.generateSimliSessionToken = generateSimliSessionToken; | ||
| exports.generateIceServers = generateIceServers; | ||
| const LivekitTransport_1 = require("./Transports/LivekitTransport"); | ||
| const P2PTransport_1 = require("./Transports/P2PTransport"); | ||
| const Logger_1 = require("./Logger"); | ||
| Object.defineProperty(exports, "Logger", { enumerable: true, get: function () { return Logger_1.Logger; } }); | ||
| Object.defineProperty(exports, "LogLevel", { enumerable: true, get: function () { return Logger_1.LogLevel; } }); | ||
| const AudioProcessor = (buffer) => { | ||
| if (buffer <= 0) { | ||
| throw "Invalid Buffer Size, Can't be negative"; | ||
| } | ||
| if (Math.floor(buffer) - buffer != 0) { | ||
| throw "Invalid Buffer Size, Can't be a float"; | ||
| } | ||
| return ` | ||
| class AudioProcessor extends AudioWorkletProcessor { | ||
| constructor() { | ||
| super(); | ||
| this.buffer = new Int16Array(${buffer}); | ||
| this.bufferIndex = 0; | ||
| } | ||
| process(inputs, outputs, parameters) { | ||
| const input = inputs[0]; | ||
| const inputChannel = input[0]; | ||
| if (inputChannel) { | ||
| for (let i = 0; i < inputChannel.length; i++) { | ||
| this.buffer[this.bufferIndex] = Math.max(-32768, Math.min(32767, Math.round(inputChannel[i] * 32767))); | ||
| this.bufferIndex++; | ||
| if (this.bufferIndex === this.buffer.length){ | ||
| this.port.postMessage({type: 'audioData', data: this.buffer.slice(0, this.bufferIndex)}); | ||
| this.bufferIndex = 0; | ||
| } | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
| } | ||
| registerProcessor('audio-processor', AudioProcessor); | ||
| `; | ||
| }; | ||
| async function generateSimliSessionToken(request, SimliURL = "https://api.simli.ai") { | ||
| const url = `${SimliURL}/compose/token`; | ||
| const response = await fetch(url, { | ||
| method: "POST", | ||
| body: JSON.stringify(request.config), | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| "x-simli-api-key": request.apiKey | ||
| }, | ||
| }); | ||
| if (!response.ok) { | ||
| const errorText = await response.text(); | ||
| throw errorText; | ||
| } | ||
| const resJSON = await response.json(); | ||
| return resJSON; | ||
| } | ||
| async function generateIceServers(apiKey, SimliURL = "https://api.simli.ai") { | ||
| try { | ||
| const url = `${SimliURL}/compose/ice`; | ||
| const response = await fetch(url, { | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| "x-simli-api-key": apiKey, | ||
| }, | ||
| method: "GET", | ||
| }); | ||
| if (!response.ok) { | ||
| throw new Error(`SIMLI: HTTP error! status: ${response.status}`); | ||
| } | ||
| const iceServers = await response.json(); | ||
| if (!iceServers || iceServers.length === 0) { | ||
| throw new Error("SIMLI: No ICE servers returned"); | ||
| } | ||
| return iceServers; | ||
| } | ||
| catch (error) { | ||
| return [{ urls: ["stun:stun.l.google.com:19302"] }]; | ||
| } | ||
| } | ||
| class SimliClient { | ||
| session_token; | ||
| transport = "livekit"; | ||
| signaling = "websockets"; | ||
| videoElement; | ||
| audioElement; | ||
| audioBufferSize = 3000; | ||
| connection; | ||
| connectionTimeout; | ||
| connectionResolve; | ||
| connectionReject; | ||
| connectionPromise; | ||
| sourceNode = null; | ||
| audioWorklet = null; | ||
| MAX_RETRY_ATTEMPTS = 10; | ||
| RETRY_DELAY = 2000; | ||
| CONNECTION_TIMEOUT_MS = 15000; | ||
| retryAttempt = 0; | ||
| SimliWSURL = "wss://api.simli.ai"; | ||
| audioContext = new (window.AudioContext || | ||
| window.webkitAudioContext)({ | ||
| sampleRate: 16000, | ||
| }); | ||
| logger; | ||
| iceServers; | ||
| persistent_events; | ||
| failReason = null; | ||
| shouldStop = false; | ||
| // Type-safe event methods | ||
| on(event, callback) { | ||
| if (!this.persistent_events.has(event)) { | ||
| this.persistent_events.set(event, new Set()); | ||
| } | ||
| this.persistent_events.get(event)?.add(callback); | ||
| this.logger.debug("Registered Callback for Event: " + event); | ||
| this.connection.on(event, callback); | ||
| } | ||
| off(event, callback) { | ||
| if (!this.persistent_events.has(event)) { | ||
| throw "Event Not Regsitered"; | ||
| } | ||
| this.persistent_events.get(event)?.delete(callback); | ||
| this.connection.off(event, callback); | ||
| } | ||
| constructor(session_token, videoElement, audioElement, iceServers, logLevel = Logger_1.LogLevel.DEBUG, transport_mode = "p2p", signaling = "websockets", SimliWSURL = "wss://api.simli.ai", audioBufferSize = 3000) { | ||
| if (audioBufferSize <= 0) { | ||
| throw "Invalid Buffer Size, Can't be negative"; | ||
| } | ||
| if (Math.floor(audioBufferSize) - audioBufferSize != 0) { | ||
| throw "Invalid Buffer Size, Can't be a float"; | ||
| } | ||
| if (!(SimliWSURL.startsWith("ws://") || SimliWSURL.startsWith("wss://")) || SimliWSURL.endsWith("/")) { | ||
| throw "Invalid Simli WS URL"; | ||
| } | ||
| this.session_token = session_token; | ||
| this.transport = transport_mode; | ||
| this.signaling = signaling; | ||
| this.SimliWSURL = SimliWSURL; | ||
| this.videoElement = videoElement; | ||
| this.audioElement = audioElement; | ||
| this.iceServers = iceServers; | ||
| this.logger = new Logger_1.Logger(logLevel); | ||
| let resolveFn; | ||
| let rejectFn; | ||
| this.connectionPromise = new Promise((resolve, reject) => { | ||
| resolveFn = resolve; | ||
| rejectFn = reject; | ||
| }); | ||
| this.connectionResolve = resolveFn; | ||
| this.connectionReject = rejectFn; | ||
| this.persistent_events = new Map(); | ||
| this.connectionTimeout = setTimeout(() => this.connectionReject("CONNECTION TIMED OUT"), this.CONNECTION_TIMEOUT_MS); | ||
| switch (this.transport) { | ||
| case "livekit": | ||
| this.connection = new LivekitTransport_1.LivekitTransport(this.SimliWSURL, this.session_token, videoElement, audioElement, this.logger, this.connectionReject); | ||
| break; | ||
| case "p2p": | ||
| if (!iceServers || iceServers.length == 0) { | ||
| throw "Ice Servers Required for P2P Mode"; | ||
| } | ||
| this.connection = new P2PTransport_1.P2PTransport(this.SimliWSURL, this.session_token, true, iceServers, videoElement, audioElement, this.logger, this.connectionReject); | ||
| break; | ||
| default: | ||
| throw new Error("Not Implemented Yet"); | ||
| } | ||
| this.connection.on("start", () => { | ||
| this.connectionResolve(); | ||
| clearTimeout(this.connectionTimeout); | ||
| }); | ||
| this.connection.on("unknown", (message) => this.logger.debug("UNKOWN MESSAGE FROM SERVER: " + message)); | ||
| this.connection.on("error", (message) => { this.failReason = message; this.connectionReject(message); }); | ||
| } | ||
| resetConnections(videoElement, audioElement, iceServers) { | ||
| this.failReason = null; | ||
| let resolveFn; | ||
| let rejectFn; | ||
| this.connectionPromise = new Promise((resolve, reject) => { | ||
| resolveFn = resolve; | ||
| rejectFn = reject; | ||
| }); | ||
| this.connectionResolve = resolveFn; | ||
| this.connectionReject = rejectFn; | ||
| this.connectionTimeout = setTimeout(() => this.connectionReject("Connection Timed Out"), this.CONNECTION_TIMEOUT_MS); | ||
| switch (this.transport) { | ||
| case "livekit": | ||
| this.connection = new LivekitTransport_1.LivekitTransport(this.SimliWSURL, this.session_token, videoElement, audioElement, this.logger, this.connectionReject); | ||
| break; | ||
| case "p2p": | ||
| if (!iceServers || iceServers.length == 0) { | ||
| throw "Ice Servers Required for P2P Mode"; | ||
| } | ||
| this.connection = new P2PTransport_1.P2PTransport(this.SimliWSURL, this.session_token, true, iceServers, videoElement, audioElement, this.logger, this.connectionReject); | ||
| break; | ||
| default: | ||
| throw new Error("Not Implemented Yet"); | ||
| } | ||
| this.connection.on("start", () => { | ||
| this.connectionResolve(); | ||
| clearTimeout(this.connectionTimeout); | ||
| }); | ||
| this.connection.on("error", (message) => { this.retryAttempt = this.MAX_RETRY_ATTEMPTS; this.connectionReject(message); }); | ||
| this.connection.on("unknown", (message) => this.logger.debug("UNKOWN MESSAGE FROM SERVER: " + message)); | ||
| // Re-register all user event handlers on the new connection | ||
| this.persistent_events.forEach((callbacks, event) => { | ||
| callbacks.forEach((callback) => { | ||
| this.connection.on(event, callback); | ||
| }); | ||
| }); | ||
| } | ||
| async start() { | ||
| if (this.shouldStop) { | ||
| throw new Error("Disconnect Already Called, Can't reuse same SimliClient multiple times create a new SimliClient Object"); | ||
| } | ||
| try { | ||
| await this.connection.connect(); | ||
| await this.connectionPromise; | ||
| this.retryAttempt = 0; | ||
| } | ||
| catch (error) { | ||
| if (this.failReason) { | ||
| throw error; | ||
| } | ||
| if (this.retryAttempt >= this.MAX_RETRY_ATTEMPTS) | ||
| throw new Error("Too Many Retry Attempts Failed to connect"); | ||
| if (this.shouldStop) { | ||
| this.shouldStop = false; | ||
| throw new Error("Called Disconnect Before A Connecction succeeded"); | ||
| } | ||
| this.logger.error("FAILED: " + error); | ||
| await this.connection.disconnect(); | ||
| await new Promise(resolve => setTimeout(resolve, this.RETRY_DELAY)); | ||
| this.retryAttempt += 1; | ||
| if (this.retryAttempt > 2) | ||
| this.transport = "livekit"; | ||
| this.resetConnections(this.videoElement, this.audioElement, this.iceServers); | ||
| await this.start(); | ||
| } | ||
| } | ||
| async stop() { | ||
| this.shouldStop = true; | ||
| await this.connection.disconnect(); | ||
| } | ||
| listenToMediastreamTrack(stream) { | ||
| this.initializeAudioWorklet(this.audioContext, stream); | ||
| } | ||
| initializeAudioWorklet(audioContext, stream) { | ||
| audioContext.audioWorklet | ||
| .addModule(URL.createObjectURL(new Blob([AudioProcessor(this.audioBufferSize)], { | ||
| type: "application/javascript", | ||
| }))) | ||
| .then(() => { | ||
| this.audioWorklet = new AudioWorkletNode(audioContext, "audio-processor"); | ||
| this.sourceNode = audioContext.createMediaStreamSource(new MediaStream([stream])); | ||
| if (this.audioWorklet === null) { | ||
| throw new Error("SIMLI: AudioWorklet not initialized"); | ||
| } | ||
| this.sourceNode.connect(this.audioWorklet); | ||
| this.audioWorklet.port.onmessage = (event) => { | ||
| if (event.data.type === "audioData") { | ||
| this.connection.signalingConnection.sendAudioData(new Uint8Array(event.data.data.buffer)); | ||
| } | ||
| }; | ||
| }); | ||
| } | ||
| ClearBuffer = () => { | ||
| this.connection.signalingConnection.sendSignal("SKIP"); | ||
| }; | ||
| sendAudioData(audioData) { | ||
| this.connection.signalingConnection.sendAudioData(audioData); | ||
| } | ||
| sendAudioDataImmediate(audioData) { | ||
| this.connection.signalingConnection.sendAudioDataImmediate(audioData); | ||
| } | ||
| } | ||
| exports.SimliClient = SimliClient; |
| interface SimliClientConfig { | ||
| faceID: string; | ||
| handleSilence: boolean; | ||
| maxSessionLength: number; | ||
| maxIdleTime: number; | ||
| enableSFU: boolean; | ||
| model: "fasttalk" | "artalk"; | ||
| } | ||
| export type { SimliClientConfig }; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); |
| interface SimliClientEvents { | ||
| start: () => void; | ||
| stop: () => void; | ||
| error: (detail: string) => void; | ||
| ack: () => void; | ||
| connection_info: (serialized_info: string) => void; | ||
| video_info: (serialized_info: string) => void; | ||
| destination: (serialized_info: string) => void; | ||
| speaking: () => void; | ||
| silent: () => void; | ||
| unknown: (message: string) => void; | ||
| startup_error: (message: string) => void; | ||
| } | ||
| export type { SimliClientEvents }; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); |
| export declare enum LogLevel { | ||
| DEBUG = 0, | ||
| INFO = 1, | ||
| ERROR = 2, | ||
| CRITICAL = 3 | ||
| } | ||
| export declare class Logger { | ||
| private currentLevel; | ||
| destination: string | null; | ||
| session_id: string | null; | ||
| constructor(level?: LogLevel); | ||
| private formatMessage; | ||
| private log; | ||
| debug(message: string, ...args: any[]): void; | ||
| info(message: string, ...args: any[]): void; | ||
| error(message: string, ...args: any[]): void; | ||
| critical(message: string, ...args: any[]): void; | ||
| setLevel(level: LogLevel): void; | ||
| getLevel(): LogLevel; | ||
| } |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.Logger = exports.LogLevel = void 0; | ||
| var LogLevel; | ||
| (function (LogLevel) { | ||
| LogLevel[LogLevel["DEBUG"] = 0] = "DEBUG"; | ||
| LogLevel[LogLevel["INFO"] = 1] = "INFO"; | ||
| LogLevel[LogLevel["ERROR"] = 2] = "ERROR"; | ||
| LogLevel[LogLevel["CRITICAL"] = 3] = "CRITICAL"; | ||
| })(LogLevel || (exports.LogLevel = LogLevel = {})); | ||
| class Logger { | ||
| currentLevel; | ||
| destination; | ||
| session_id; | ||
| constructor(level = LogLevel.INFO) { | ||
| this.currentLevel = level; | ||
| this.destination = null; | ||
| this.session_id = null; | ||
| } | ||
| formatMessage(level, message) { | ||
| const timestamp = new Date().toISOString(); | ||
| const destination = this.destination ?? 'not_received'; | ||
| const sessionId = this.session_id ?? 'not_received'; | ||
| return `SimliClient | ${timestamp} | ${level} | ${destination}/${sessionId} | ${message}`; | ||
| } | ||
| log(level, levelName, message, ...args) { | ||
| if (level < this.currentLevel) { | ||
| return; | ||
| } | ||
| const formattedMessage = this.formatMessage(levelName, message); | ||
| switch (level) { | ||
| case LogLevel.DEBUG: | ||
| case LogLevel.INFO: | ||
| console.log(formattedMessage, ...args); | ||
| break; | ||
| case LogLevel.ERROR: | ||
| case LogLevel.CRITICAL: | ||
| console.error(formattedMessage, ...args); | ||
| break; | ||
| } | ||
| } | ||
| debug(message, ...args) { | ||
| this.log(LogLevel.DEBUG, 'DEBUG', message, ...args); | ||
| } | ||
| info(message, ...args) { | ||
| this.log(LogLevel.INFO, 'INFO', message, ...args); | ||
| } | ||
| error(message, ...args) { | ||
| this.log(LogLevel.ERROR, 'ERROR', message, ...args); | ||
| } | ||
| critical(message, ...args) { | ||
| this.log(LogLevel.CRITICAL, 'CRITICAL', message, ...args); | ||
| } | ||
| setLevel(level) { | ||
| this.currentLevel = level; | ||
| } | ||
| getLevel() { | ||
| return this.currentLevel; | ||
| } | ||
| } | ||
| exports.Logger = Logger; |
| import { Logger } from "../Logger"; | ||
| type ClientSignals = "DONE" | "SKIP"; | ||
| interface BaseSignaling { | ||
| logger: Logger; | ||
| connect(connected: () => void): Promise<void>; | ||
| disconnect(): void; | ||
| sendSignal(data: ClientSignals): void; | ||
| sendAudioData(audioData: Uint8Array): void; | ||
| sendAudioDataImmediate(audioData: Uint8Array): void; | ||
| } | ||
| export type { BaseSignaling, ClientSignals }; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); |
| import { Logger } from "../Logger"; | ||
| import { BaseSignaling, ClientSignals } from "./BaseSignaling"; | ||
| declare class WebSocketSignaling implements BaseSignaling { | ||
| wsURL: string | URL; | ||
| wsConnection: WebSocket; | ||
| logger: Logger; | ||
| constructor(wsURL: string | URL, logger: Logger); | ||
| connect(connected: () => void): Promise<void>; | ||
| disconnect(): void; | ||
| private send; | ||
| sendOffer(offer: RTCSessionDescription): void; | ||
| sendSignal(data: ClientSignals): void; | ||
| sendAudioData(audioData: Uint8Array): void; | ||
| sendAudioDataImmediate(audioData: Uint8Array): void; | ||
| } | ||
| export { WebSocketSignaling }; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.WebSocketSignaling = void 0; | ||
| const Logger_1 = require("../Logger"); | ||
| class WebSocketSignaling { | ||
| wsURL; | ||
| wsConnection; | ||
| logger; | ||
| constructor(wsURL, logger) { | ||
| this.wsURL = wsURL; | ||
| this.wsConnection = new WebSocket(this.wsURL); | ||
| this.wsConnection.addEventListener("message", (message) => (this.logger.debug(message.data))); | ||
| this.logger = logger; | ||
| } | ||
| async connect(connected) { | ||
| this.wsConnection.onopen = connected; | ||
| } | ||
| disconnect() { | ||
| this.wsConnection.close(); | ||
| } | ||
| send(data) { | ||
| if (this.wsConnection.readyState != WebSocket.OPEN) { | ||
| throw `Invalid State, WS Connection ${this.wsConnection.readyState.toString()}`; | ||
| } | ||
| this.wsConnection.send(data); | ||
| } | ||
| sendOffer(offer) { | ||
| this.send(JSON.stringify(offer)); | ||
| } | ||
| sendSignal(data) { | ||
| this.send(data); | ||
| } | ||
| sendAudioData(audioData) { | ||
| if (this.logger.getLevel() === Logger_1.LogLevel.DEBUG) | ||
| this.logger.debug("Sent Audio of length: " + (audioData.length / 32000).toString()); | ||
| this.send(audioData); | ||
| } | ||
| sendAudioDataImmediate(audioData) { | ||
| if (this.logger.getLevel() === Logger_1.LogLevel.DEBUG) | ||
| this.logger.debug("Sent Audio of length for immediate playback: " + (audioData.length / 32000).toString()); | ||
| const asciiStr = "PLAY_IMMEDIATE"; | ||
| const encoder = new TextEncoder(); // Default is utf-8 | ||
| const strBytes = encoder.encode(asciiStr); // Uint8Array of " World!" | ||
| const buffer = new Uint8Array(strBytes.length + audioData.length); | ||
| buffer.set(strBytes, 0); | ||
| buffer.set(audioData, strBytes.length); | ||
| this.send(buffer); | ||
| } | ||
| } | ||
| exports.WebSocketSignaling = WebSocketSignaling; |
| import { SimliClientEvents } from "../Events"; | ||
| import { Logger } from "../Logger"; | ||
| import { BaseSignaling } from "../Signaling/BaseSignaling"; | ||
| type EventCallback = (...args: any[]) => void; | ||
| type EventMap = Map<string, Set<EventCallback>>; | ||
| interface BaseTransport { | ||
| videoElementAnchor: HTMLVideoElement; | ||
| audioElementAnchor: HTMLAudioElement; | ||
| signalingConnection: BaseSignaling; | ||
| session_token: string; | ||
| events: EventMap; | ||
| logger: Logger; | ||
| connect(): Promise<void>; | ||
| disconnect(): void; | ||
| on<K extends keyof SimliClientEvents>(event: K, callback: SimliClientEvents[K]): void; | ||
| off<K extends keyof SimliClientEvents>(event: K, callback: SimliClientEvents[K]): void; | ||
| emit<K extends keyof SimliClientEvents>(event: K, ...args: Parameters<SimliClientEvents[K]>): void; | ||
| } | ||
| declare function register_destination(logger: Logger, serialized_info: string): void; | ||
| declare function handleMessage(transport: BaseTransport, message: MessageEvent): Promise<void>; | ||
| export { handleMessage, register_destination }; | ||
| export type { BaseTransport, EventCallback, EventMap }; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.handleMessage = handleMessage; | ||
| exports.register_destination = register_destination; | ||
| function register_destination(logger, serialized_info) { | ||
| const parsed = JSON.parse(serialized_info); | ||
| logger.destination = parsed.destination; | ||
| logger.session_id = parsed.session_id; | ||
| } | ||
| async function handleMessage(transport, message) { | ||
| const firstToken = message.data.toUpperCase().split(" ")[0]; | ||
| switch (firstToken) { | ||
| case "START": { | ||
| // SOFT IGNORE | ||
| break; | ||
| } | ||
| case "ACK": { | ||
| transport.emit("ack"); | ||
| break; | ||
| } | ||
| case "STOP": { | ||
| transport.disconnect(); | ||
| transport.emit("stop"); | ||
| break; | ||
| } | ||
| case "CLOSING": | ||
| case "RATE": | ||
| case "ERROR": | ||
| case "ERROR:": { | ||
| transport.disconnect(); | ||
| transport.emit("error", message.data); | ||
| } | ||
| case "SPEAK": { | ||
| transport.emit("speaking"); | ||
| break; | ||
| } | ||
| case "SILENT": { | ||
| transport.emit("silent"); | ||
| break; | ||
| } | ||
| default: { | ||
| if (firstToken.includes("SDP") || firstToken.includes("LIVEKIT")) { | ||
| transport.emit("connection_info", message.data); | ||
| } | ||
| else if (firstToken.includes("VIDEO_METADATA")) { | ||
| transport.emit("video_info", message.data); | ||
| } | ||
| else if (firstToken.includes("ENDFRAME")) { | ||
| transport.disconnect(); | ||
| } | ||
| else if (firstToken.includes("DESTINATION")) { | ||
| transport.emit("destination", message.data); | ||
| } | ||
| else { | ||
| transport.emit("unknown", message.data); | ||
| } | ||
| } | ||
| } | ||
| } |
| import { Room } from "livekit-client"; | ||
| import { WebSocketSignaling } from "../Signaling/WebSocketSignaling"; | ||
| import { SimliClientEvents } from "../Events"; | ||
| import { BaseTransport, EventMap } from "./BaseTransport"; | ||
| import { Logger } from "../Logger"; | ||
| declare class LivekitTransport implements BaseTransport { | ||
| videoElementAnchor: HTMLVideoElement; | ||
| audioElementAnchor: HTMLAudioElement; | ||
| signalingConnection: WebSocketSignaling; | ||
| session_token: string; | ||
| pc: Room; | ||
| logger: Logger; | ||
| events: EventMap; | ||
| private websocketPromise; | ||
| private websocketReject; | ||
| constructor(simliBaseWSURL: string, session_token: string, videoElementAnchor: HTMLVideoElement, audioElementAnchor: HTMLAudioElement, logger: Logger, failSignal: (message: string) => void); | ||
| on<K extends keyof SimliClientEvents>(event: K, callback: SimliClientEvents[K]): void; | ||
| off<K extends keyof SimliClientEvents>(event: K, callback: SimliClientEvents[K]): void; | ||
| emit<K extends keyof SimliClientEvents>(event: K, ...args: Parameters<SimliClientEvents[K]>): void; | ||
| connect(): Promise<void>; | ||
| disconnect(): Promise<void>; | ||
| private join_lk_room; | ||
| private setupConnectionStateHandler; | ||
| } | ||
| export { LivekitTransport }; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.LivekitTransport = void 0; | ||
| const livekit_client_1 = require("livekit-client"); | ||
| const WebSocketSignaling_1 = require("../Signaling/WebSocketSignaling"); | ||
| const BaseTransport_1 = require("./BaseTransport"); | ||
| class LivekitTransport { | ||
| videoElementAnchor; | ||
| audioElementAnchor; | ||
| signalingConnection; | ||
| session_token; | ||
| pc; | ||
| logger; | ||
| events = new Map(); | ||
| websocketPromise; | ||
| websocketReject = null; | ||
| constructor(simliBaseWSURL, session_token, videoElementAnchor, audioElementAnchor, logger, failSignal) { | ||
| this.logger = logger; | ||
| this.on("startup_error", failSignal); | ||
| this.session_token = session_token; | ||
| const wsURL = new URL(simliBaseWSURL + "/compose/webrtc/livekit"); | ||
| wsURL.searchParams.set("session_token", session_token); | ||
| this.signalingConnection = new WebSocketSignaling_1.WebSocketSignaling(wsURL, this.logger); | ||
| this.on("destination", (serilized_info) => (0, BaseTransport_1.register_destination)(this.logger, serilized_info)); | ||
| this.websocketPromise = new Promise((resolve, reject) => { | ||
| this.websocketReject = reject; | ||
| this.signalingConnection.connect(() => { | ||
| resolve("success"); | ||
| this.logger.debug("LK WebSocket Connected"); | ||
| }); | ||
| }); | ||
| this.signalingConnection.wsConnection.onmessage = (message) => { (0, BaseTransport_1.handleMessage)(this, message); }; | ||
| this.signalingConnection.wsConnection.onerror = (evt) => { | ||
| this.emit("startup_error", "Websocket Failed"); | ||
| if (this.websocketReject) { | ||
| this.websocketReject("Websocket Failed"); | ||
| this.websocketReject = null; // Prevent multiple rejections | ||
| } | ||
| }; | ||
| const options = { adaptiveStream: true, dynacast: true }; | ||
| this.pc = new livekit_client_1.Room(options); | ||
| this.on("connection_info", (serialized_info) => this.join_lk_room(serialized_info)); | ||
| this.videoElementAnchor = videoElementAnchor; | ||
| this.audioElementAnchor = audioElementAnchor; | ||
| } | ||
| on(event, callback) { | ||
| if (!this.events.has(event)) { | ||
| this.events.set(event, new Set()); | ||
| } | ||
| this.events.get(event)?.add(callback); | ||
| this.logger.debug("Registered Callback for Event: " + event); | ||
| } | ||
| off(event, callback) { | ||
| if (!this.events.has(event)) { | ||
| throw "Event Not Regsitered"; | ||
| } | ||
| this.events.get(event)?.delete(callback); | ||
| } | ||
| emit(event, ...args) { | ||
| this.logger.debug("Event: " + event); | ||
| this.events.get(event)?.forEach((callback) => { | ||
| callback(...args); | ||
| }); | ||
| } | ||
| async connect() { | ||
| this.logger.info("Connecting"); | ||
| this.setupConnectionStateHandler(); | ||
| await this.websocketPromise; | ||
| } | ||
| async disconnect() { | ||
| this.logger.info("Disconnecting"); | ||
| try { | ||
| this.signalingConnection.sendSignal("DONE"); | ||
| } | ||
| catch { | ||
| this.logger.error("FAILED TO SEND FINAL MESSAGE"); | ||
| } | ||
| try { | ||
| this.signalingConnection.disconnect(); | ||
| } | ||
| catch { | ||
| this.logger.error("SIGNALING ALREADY DISCONNECTED"); | ||
| } | ||
| try { | ||
| await this.pc.disconnect(); | ||
| } | ||
| catch { | ||
| this.logger.error("LOCAL PEER ALREADY CLOSED"); | ||
| } | ||
| } | ||
| async join_lk_room(serialized_info) { | ||
| const info = JSON.parse(serialized_info); | ||
| this.logger.debug(info); | ||
| if (info.livekit_url && info.livekit_token) { | ||
| await this.pc.connect(info.livekit_url, info.livekit_token); | ||
| } | ||
| else { | ||
| this.disconnect(); | ||
| this.emit("error", "Invalid Join Info, Contact Simli For Support"); | ||
| } | ||
| } | ||
| setupConnectionStateHandler() { | ||
| this.pc.on(livekit_client_1.RoomEvent.Disconnected, () => { | ||
| this.disconnect(); | ||
| }); | ||
| this.pc.on(livekit_client_1.RoomEvent.Connected, () => { | ||
| }); | ||
| this.pc.on(livekit_client_1.RoomEvent.TrackSubscribed, (track, publication, participant) => { | ||
| this.logger.debug("Track Received: " + track.kind); | ||
| if (track.kind === livekit_client_1.Track.Kind.Video) { | ||
| track.attach(this.videoElementAnchor); | ||
| this.videoElementAnchor.requestVideoFrameCallback(() => { | ||
| this.emit("start"); | ||
| }); | ||
| } | ||
| else if (track.kind === livekit_client_1.Track.Kind.Audio) { | ||
| track.attach(this.audioElementAnchor); | ||
| } | ||
| }); | ||
| } | ||
| ; | ||
| } | ||
| exports.LivekitTransport = LivekitTransport; |
| import { WebSocketSignaling } from "../Signaling/WebSocketSignaling"; | ||
| import { SimliClientEvents } from "../Events"; | ||
| import { BaseTransport, EventMap } from "./BaseTransport"; | ||
| import { Logger } from "../Logger"; | ||
| declare class P2PTransport implements BaseTransport { | ||
| videoElementAnchor: HTMLVideoElement; | ||
| audioElementAnchor: HTMLAudioElement; | ||
| signalingConnection: WebSocketSignaling; | ||
| session_token: string; | ||
| pc: RTCPeerConnection; | ||
| events: EventMap; | ||
| logger: Logger; | ||
| private iceCandidateCount; | ||
| private previousIceCandidateCount; | ||
| private iceTimeout; | ||
| private websocketPromise; | ||
| private websocketReject; | ||
| constructor(simliBaseWSURL: string, session_token: string, enableSFU: boolean, iceServers: RTCIceServer[], videoElementAnchor: HTMLVideoElement, audioElementAnchor: HTMLAudioElement, logger: Logger, failSignal: (message: string) => void); | ||
| on<K extends keyof SimliClientEvents>(event: K, callback: SimliClientEvents[K]): void; | ||
| off<K extends keyof SimliClientEvents>(event: K, callback: SimliClientEvents[K]): void; | ||
| emit<K extends keyof SimliClientEvents>(event: K, ...args: Parameters<SimliClientEvents[K]>): void; | ||
| connect(): Promise<void>; | ||
| disconnect(): Promise<void>; | ||
| private registerPeerInfo; | ||
| private waitForIceGathering; | ||
| private setupPeerConnectionListeners; | ||
| } | ||
| export { P2PTransport }; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.P2PTransport = void 0; | ||
| const WebSocketSignaling_1 = require("../Signaling/WebSocketSignaling"); | ||
| const BaseTransport_1 = require("./BaseTransport"); | ||
| class P2PTransport { | ||
| videoElementAnchor; | ||
| audioElementAnchor; | ||
| signalingConnection; | ||
| session_token; | ||
| pc; | ||
| events = new Map(); | ||
| logger; | ||
| iceCandidateCount; | ||
| previousIceCandidateCount; | ||
| iceTimeout = null; | ||
| websocketPromise; | ||
| websocketReject = null; | ||
| constructor(simliBaseWSURL, session_token, enableSFU, iceServers, videoElementAnchor, audioElementAnchor, logger, failSignal) { | ||
| this.logger = logger; | ||
| this.on("startup_error", failSignal); | ||
| this.session_token = session_token; | ||
| const wsURL = new URL(simliBaseWSURL + "/compose/webrtc/p2p"); | ||
| wsURL.searchParams.set("session_token", session_token); | ||
| wsURL.searchParams.set("enableSFU", String(enableSFU)); | ||
| this.on("destination", (serilized_info) => (0, BaseTransport_1.register_destination)(this.logger, serilized_info)); | ||
| this.signalingConnection = new WebSocketSignaling_1.WebSocketSignaling(wsURL, this.logger); | ||
| this.websocketPromise = new Promise((resolve, reject) => { | ||
| this.websocketReject = reject; | ||
| this.signalingConnection.connect(() => { | ||
| resolve("success"); | ||
| this.logger.debug("P2P WebSocket Connected"); | ||
| }); | ||
| }); | ||
| this.signalingConnection.wsConnection.onmessage = (message) => { (0, BaseTransport_1.handleMessage)(this, message); }; | ||
| this.signalingConnection.wsConnection.onerror = (evt) => { | ||
| this.emit("startup_error", "Websocket Failed"); | ||
| if (this.websocketReject) { | ||
| this.websocketReject("Websocket Failed"); | ||
| this.websocketReject = null; // Prevent multiple rejections | ||
| } | ||
| }; | ||
| this.on("connection_info", (serialized_info) => this.registerPeerInfo(serialized_info)); | ||
| this.videoElementAnchor = videoElementAnchor; | ||
| this.audioElementAnchor = audioElementAnchor; | ||
| this.iceCandidateCount = 0; | ||
| this.previousIceCandidateCount = 0; | ||
| const config = { | ||
| sdpSemantics: "unified-plan", | ||
| iceServers: iceServers, | ||
| }; | ||
| this.pc = new window.RTCPeerConnection(config); | ||
| this.pc.addTransceiver("audio", { | ||
| direction: "recvonly", | ||
| }); | ||
| this.pc.addTransceiver("video", { | ||
| direction: "recvonly", | ||
| }); | ||
| } | ||
| on(event, callback) { | ||
| if (!this.events.has(event)) { | ||
| this.events.set(event, new Set()); | ||
| } | ||
| this.events.get(event)?.add(callback); | ||
| } | ||
| off(event, callback) { | ||
| this.events.get(event)?.delete(callback); | ||
| } | ||
| emit(event, ...args) { | ||
| this.events.get(event)?.forEach((callback) => { | ||
| try { | ||
| callback(...args); | ||
| } | ||
| catch { | ||
| this.logger.error("CALLBACK FAILED: " + callback.name); | ||
| } | ||
| }); | ||
| } | ||
| async connect() { | ||
| const offer = await this.pc.createOffer(); | ||
| await this.pc.setLocalDescription(offer); | ||
| await this.waitForIceGathering(); | ||
| this.setupPeerConnectionListeners(); | ||
| await this.websocketPromise; | ||
| if (this.pc.localDescription) { | ||
| this.signalingConnection.sendOffer(this.pc.localDescription); | ||
| } | ||
| } | ||
| async disconnect() { | ||
| this.logger.info("Disconnecting"); | ||
| try { | ||
| this.signalingConnection.sendSignal("DONE"); | ||
| } | ||
| catch { | ||
| this.logger.error("FAILED TO SEND FINAL MESSAGE"); | ||
| } | ||
| try { | ||
| this.signalingConnection.disconnect(); | ||
| } | ||
| catch { | ||
| this.logger.error("SIGNALING ALREADY DISCONNECTED"); | ||
| } | ||
| try { | ||
| this.pc.close(); | ||
| } | ||
| catch { | ||
| this.logger.error("LOCAL PEER ALREADY CLOSED"); | ||
| } | ||
| } | ||
| async registerPeerInfo(serialized_info) { | ||
| const info = JSON.parse(serialized_info); | ||
| if (info.sdp && info.type == "answer") { | ||
| await this.pc.setRemoteDescription(new RTCSessionDescription(info)); | ||
| } | ||
| else { | ||
| this.disconnect(); | ||
| this.emit("error", "Invalid Join Info, Contact Simli For Support"); | ||
| } | ||
| } | ||
| async waitForIceGathering() { | ||
| this.iceCandidateCount = 0; | ||
| this.previousIceCandidateCount = 0; | ||
| if (this.pc.iceGatheringState === "complete") { | ||
| return; | ||
| } | ||
| return new Promise((resolve, reject) => { | ||
| if (!this.iceTimeout) { | ||
| this.iceTimeout = setTimeout(() => { | ||
| reject(new Error("ICE gathering timeout")); | ||
| }, 10000); | ||
| } | ||
| const checkIceCandidates = () => { | ||
| if (this.pc.iceGatheringState === "complete" || | ||
| this.iceCandidateCount === this.previousIceCandidateCount) { | ||
| if (this.iceTimeout) { | ||
| clearTimeout(this.iceTimeout); | ||
| } | ||
| resolve(); | ||
| } | ||
| else { | ||
| this.previousIceCandidateCount = this.iceCandidateCount; | ||
| setTimeout(checkIceCandidates, 150); | ||
| } | ||
| }; | ||
| checkIceCandidates(); | ||
| }); | ||
| } | ||
| setupPeerConnectionListeners() { | ||
| this.pc.addEventListener("track", (evt) => { | ||
| if (evt.track.kind === "video") { | ||
| this.videoElementAnchor.srcObject = evt.streams[0]; | ||
| this.videoElementAnchor.requestVideoFrameCallback(() => { | ||
| this.emit("start"); | ||
| }); | ||
| } | ||
| else if (evt.track.kind === "audio" && this.audioElementAnchor) { | ||
| this.audioElementAnchor.srcObject = evt.streams[0]; | ||
| } | ||
| }); | ||
| this.pc.onicecandidate = (event) => { | ||
| if (event.candidate !== null) { | ||
| this.iceCandidateCount += 1; | ||
| } | ||
| }; | ||
| } | ||
| } | ||
| exports.P2PTransport = P2PTransport; |
+361
| // src/index.ts | ||
| import { SimliClientEvents } from './Events'; | ||
| import { BaseTransport, EventCallback, EventMap } from './Transports/BaseTransport'; | ||
| import { LivekitTransport } from './Transports/LivekitTransport'; | ||
| import { P2PTransport } from './Transports/P2PTransport'; | ||
| import { Logger, LogLevel } from './Logger'; | ||
| const AudioProcessor = (buffer: number) => { | ||
| if (buffer <= 0) { | ||
| throw "Invalid Buffer Size, Can't be negative" | ||
| } | ||
| if (Math.floor(buffer) - buffer != 0) { | ||
| throw "Invalid Buffer Size, Can't be a float" | ||
| } | ||
| return ` | ||
| class AudioProcessor extends AudioWorkletProcessor { | ||
| constructor() { | ||
| super(); | ||
| this.buffer = new Int16Array(${buffer}); | ||
| this.bufferIndex = 0; | ||
| } | ||
| process(inputs, outputs, parameters) { | ||
| const input = inputs[0]; | ||
| const inputChannel = input[0]; | ||
| if (inputChannel) { | ||
| for (let i = 0; i < inputChannel.length; i++) { | ||
| this.buffer[this.bufferIndex] = Math.max(-32768, Math.min(32767, Math.round(inputChannel[i] * 32767))); | ||
| this.bufferIndex++; | ||
| if (this.bufferIndex === this.buffer.length){ | ||
| this.port.postMessage({type: 'audioData', data: this.buffer.slice(0, this.bufferIndex)}); | ||
| this.bufferIndex = 0; | ||
| } | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
| } | ||
| registerProcessor('audio-processor', AudioProcessor); | ||
| `}; | ||
| // Custom event handler types | ||
| interface SimliSessionRequest { | ||
| faceId: string; | ||
| handleSilence: boolean; | ||
| maxSessionLength: number; | ||
| maxIdleTime: number; | ||
| model?: "fasttalk" | "artalk"; | ||
| } | ||
| interface TokenRequestData { | ||
| config: SimliSessionRequest | ||
| apiKey: string | ||
| } | ||
| interface SimliSessionToken { | ||
| session_token: string; | ||
| } | ||
| type TransportMode = "livekit" | "p2p" | ||
| type SignalingMode = "websockets" | ||
| type session_token = string | ||
| async function generateSimliSessionToken( | ||
| request: TokenRequestData, | ||
| SimliURL: string = "https://api.simli.ai", | ||
| ): Promise<SimliSessionToken> { | ||
| const url = `${SimliURL}/compose/token`; | ||
| const response = await fetch(url, { | ||
| method: "POST", | ||
| body: JSON.stringify(request.config), | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| "x-simli-api-key": request.apiKey | ||
| }, | ||
| }); | ||
| if (!response.ok) { | ||
| const errorText = await response.text(); | ||
| throw errorText; | ||
| } | ||
| const resJSON = await response.json(); | ||
| return resJSON; | ||
| } | ||
| async function generateIceServers( | ||
| apiKey: string, | ||
| SimliURL: string = "https://api.simli.ai", | ||
| ): Promise<RTCIceServer[]> { | ||
| try { | ||
| const url = `${SimliURL}/compose/ice`; | ||
| const response: any = await fetch(url, { | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| "x-simli-api-key": apiKey, | ||
| }, | ||
| method: "GET", | ||
| }) | ||
| if (!response.ok) { | ||
| throw new Error(`SIMLI: HTTP error! status: ${response.status}`); | ||
| } | ||
| const iceServers = await response.json(); | ||
| if (!iceServers || iceServers.length === 0) { | ||
| throw new Error("SIMLI: No ICE servers returned"); | ||
| } | ||
| return iceServers; | ||
| } catch (error) { | ||
| return [{ urls: ["stun:stun.l.google.com:19302"] }]; | ||
| } | ||
| } | ||
| class SimliClient { | ||
| session_token: string; | ||
| transport: TransportMode = "livekit"; | ||
| signaling: SignalingMode = "websockets" | ||
| videoElement: HTMLVideoElement | ||
| audioElement: HTMLAudioElement | ||
| audioBufferSize: number = 3000 | ||
| private connection: BaseTransport | ||
| private connectionTimeout: NodeJS.Timeout | ||
| private connectionResolve: () => void | ||
| private connectionReject: (message: string) => void | ||
| private connectionPromise: Promise<void> | ||
| private sourceNode: MediaStreamAudioSourceNode | null = null; | ||
| private audioWorklet: AudioWorkletNode | null = null; | ||
| private readonly MAX_RETRY_ATTEMPTS = 10; | ||
| private RETRY_DELAY = 2000; | ||
| private readonly CONNECTION_TIMEOUT_MS = 15000; | ||
| private retryAttempt: number = 0; | ||
| private SimliWSURL: string = "wss://api.simli.ai"; | ||
| private audioContext: AudioContext = new (window.AudioContext || | ||
| (window as any).webkitAudioContext)({ | ||
| sampleRate: 16000, | ||
| }); | ||
| private logger: Logger; | ||
| private iceServers: RTCIceServer[] | null; | ||
| private persistent_events: EventMap; | ||
| private failReason: string | null = null; | ||
| private shouldStop: boolean = false; | ||
| // Type-safe event methods | ||
| public on<K extends keyof SimliClientEvents>( | ||
| event: K, | ||
| callback: SimliClientEvents[K] | ||
| ): void { | ||
| if (!this.persistent_events.has(event)) { | ||
| this.persistent_events.set(event, new Set()); | ||
| } | ||
| this.persistent_events.get(event)?.add(callback as EventCallback); | ||
| this.logger.debug("Registered Callback for Event: " + event) | ||
| this.connection.on(event, callback) | ||
| } | ||
| public off<K extends keyof SimliClientEvents>( | ||
| event: K, | ||
| callback: SimliClientEvents[K], | ||
| ): void { | ||
| if (!this.persistent_events.has(event)) { | ||
| throw "Event Not Regsitered" | ||
| } | ||
| this.persistent_events.get(event)?.delete(callback as EventCallback); | ||
| this.connection.off(event, callback) | ||
| } | ||
| constructor( | ||
| session_token: session_token, | ||
| videoElement: HTMLVideoElement, | ||
| audioElement: HTMLAudioElement, | ||
| iceServers: RTCIceServer[] | null, | ||
| logLevel: LogLevel = LogLevel.DEBUG, | ||
| transport_mode: TransportMode = "p2p", | ||
| signaling: SignalingMode = "websockets", | ||
| SimliWSURL: string = "wss://api.simli.ai", | ||
| audioBufferSize: number = 3000, | ||
| ) { | ||
| if (audioBufferSize <= 0) { | ||
| throw "Invalid Buffer Size, Can't be negative" | ||
| } | ||
| if (Math.floor(audioBufferSize) - audioBufferSize != 0) { | ||
| throw "Invalid Buffer Size, Can't be a float" | ||
| } | ||
| if (!(SimliWSURL.startsWith("ws://") || SimliWSURL.startsWith("wss://")) || SimliWSURL.endsWith("/")) { | ||
| throw "Invalid Simli WS URL" | ||
| } | ||
| this.session_token = session_token | ||
| this.transport = transport_mode | ||
| this.signaling = signaling | ||
| this.SimliWSURL = SimliWSURL | ||
| this.videoElement = videoElement | ||
| this.audioElement = audioElement | ||
| this.iceServers = iceServers | ||
| this.logger = new Logger(logLevel); | ||
| let resolveFn: () => void; | ||
| let rejectFn: () => void; | ||
| this.connectionPromise = new Promise<void>((resolve, reject) => { | ||
| resolveFn = resolve; | ||
| rejectFn = reject; | ||
| }); | ||
| this.connectionResolve = resolveFn!; | ||
| this.connectionReject = rejectFn!; | ||
| this.persistent_events = new Map() | ||
| this.connectionTimeout = setTimeout(() => this.connectionReject("CONNECTION TIMED OUT"), this.CONNECTION_TIMEOUT_MS) | ||
| switch (this.transport) { | ||
| case "livekit": | ||
| this.connection = new LivekitTransport(this.SimliWSURL, this.session_token, videoElement, audioElement, this.logger, this.connectionReject) | ||
| break; | ||
| case "p2p": | ||
| if (!iceServers || iceServers.length == 0) { | ||
| throw "Ice Servers Required for P2P Mode" | ||
| } | ||
| this.connection = new P2PTransport(this.SimliWSURL, this.session_token, true, iceServers, videoElement, audioElement, this.logger, this.connectionReject) | ||
| break | ||
| default: | ||
| throw new Error("Not Implemented Yet") | ||
| } | ||
| this.connection.on("start", () => { | ||
| this.connectionResolve() | ||
| clearTimeout(this.connectionTimeout) | ||
| }) | ||
| this.connection.on("unknown", (message) => this.logger.debug("UNKOWN MESSAGE FROM SERVER: " + message)) | ||
| this.connection.on("error", (message) => { this.failReason = message; this.connectionReject(message) }) | ||
| } | ||
| private resetConnections(videoElement: HTMLVideoElement, audioElement: HTMLAudioElement, iceServers: RTCIceServer[] | null) { | ||
| this.failReason = null | ||
| let resolveFn: () => void; | ||
| let rejectFn: () => void; | ||
| this.connectionPromise = new Promise<void>((resolve, reject) => { | ||
| resolveFn = resolve; | ||
| rejectFn = reject; | ||
| }); | ||
| this.connectionResolve = resolveFn!; | ||
| this.connectionReject = rejectFn!; | ||
| this.connectionTimeout = setTimeout(() => this.connectionReject("Connection Timed Out"), this.CONNECTION_TIMEOUT_MS) | ||
| switch (this.transport) { | ||
| case "livekit": | ||
| this.connection = new LivekitTransport(this.SimliWSURL, this.session_token, videoElement, audioElement, this.logger, this.connectionReject) | ||
| break; | ||
| case "p2p": | ||
| if (!iceServers || iceServers.length == 0) { | ||
| throw "Ice Servers Required for P2P Mode" | ||
| } | ||
| this.connection = new P2PTransport(this.SimliWSURL, this.session_token, true, iceServers, videoElement, audioElement, this.logger, this.connectionReject) | ||
| break | ||
| default: | ||
| throw new Error("Not Implemented Yet") | ||
| } | ||
| this.connection.on("start", () => { | ||
| this.connectionResolve() | ||
| clearTimeout(this.connectionTimeout) | ||
| }) | ||
| this.connection.on("error", (message) => { this.retryAttempt = this.MAX_RETRY_ATTEMPTS; this.connectionReject(message) }) | ||
| this.connection.on("unknown", (message) => this.logger.debug("UNKOWN MESSAGE FROM SERVER: " + message)) | ||
| // Re-register all user event handlers on the new connection | ||
| this.persistent_events.forEach((callbacks, event) => { | ||
| callbacks.forEach((callback) => { | ||
| this.connection.on(event as keyof SimliClientEvents, callback); | ||
| }); | ||
| }); | ||
| } | ||
| async start(): Promise<void> { | ||
| if (this.shouldStop) { | ||
| throw new Error("Disconnect Already Called, Can't reuse same SimliClient multiple times create a new SimliClient Object") | ||
| } | ||
| try { | ||
| await this.connection.connect() | ||
| await this.connectionPromise | ||
| this.retryAttempt = 0 | ||
| } catch (error) { | ||
| if (this.failReason) { | ||
| throw error | ||
| } | ||
| if (this.retryAttempt >= this.MAX_RETRY_ATTEMPTS) | ||
| throw new Error("Too Many Retry Attempts Failed to connect") | ||
| if (this.shouldStop) { | ||
| this.shouldStop = false | ||
| throw new Error("Called Disconnect Before A Connecction succeeded") | ||
| } | ||
| this.logger.error("FAILED: " + error) | ||
| await this.connection.disconnect() | ||
| await new Promise(resolve => setTimeout(resolve, this.RETRY_DELAY)); | ||
| this.retryAttempt += 1 | ||
| if (this.retryAttempt > 2) | ||
| this.transport = "livekit" | ||
| this.resetConnections(this.videoElement, this.audioElement, this.iceServers) | ||
| await this.start() | ||
| } | ||
| } | ||
| async stop() { | ||
| this.shouldStop = true | ||
| await this.connection.disconnect() | ||
| } | ||
| listenToMediastreamTrack(stream: MediaStreamTrack) { | ||
| this.initializeAudioWorklet(this.audioContext, stream); | ||
| } | ||
| private initializeAudioWorklet( | ||
| audioContext: AudioContext, | ||
| stream: MediaStreamTrack, | ||
| ) { | ||
| audioContext.audioWorklet | ||
| .addModule( | ||
| URL.createObjectURL( | ||
| new Blob([AudioProcessor(this.audioBufferSize)], { | ||
| type: "application/javascript", | ||
| }), | ||
| ), | ||
| ) | ||
| .then(() => { | ||
| this.audioWorklet = new AudioWorkletNode( | ||
| audioContext, | ||
| "audio-processor", | ||
| ); | ||
| this.sourceNode = audioContext.createMediaStreamSource( | ||
| new MediaStream([stream]), | ||
| ); | ||
| if (this.audioWorklet === null) { | ||
| throw new Error("SIMLI: AudioWorklet not initialized"); | ||
| } | ||
| this.sourceNode.connect(this.audioWorklet); | ||
| this.audioWorklet.port.onmessage = (event) => { | ||
| if (event.data.type === "audioData") { | ||
| this.connection.signalingConnection.sendAudioData(new Uint8Array(event.data.data.buffer)); | ||
| } | ||
| }; | ||
| }) | ||
| } | ||
| public ClearBuffer = () => { | ||
| this.connection.signalingConnection.sendSignal("SKIP"); | ||
| }; | ||
| sendAudioData(audioData: Uint8Array) { | ||
| this.connection.signalingConnection.sendAudioData(audioData); | ||
| } | ||
| sendAudioDataImmediate(audioData: Uint8Array) { | ||
| this.connection.signalingConnection.sendAudioDataImmediate(audioData); | ||
| } | ||
| } | ||
| export { SimliClient, generateSimliSessionToken, generateIceServers, Logger, LogLevel } | ||
| export type { SimliSessionRequest }; |
| interface SimliClientConfig { | ||
| faceID: string; | ||
| handleSilence: boolean; | ||
| maxSessionLength: number; | ||
| maxIdleTime: number; | ||
| enableSFU: boolean; | ||
| model: "fasttalk" | "artalk"; | ||
| } | ||
| export type { SimliClientConfig } |
| interface SimliClientEvents { | ||
| start: () => void; | ||
| stop: () => void; | ||
| error: (detail: string) => void; | ||
| ack: () => void; | ||
| connection_info: (serialized_info: string) => void; | ||
| video_info: (serialized_info: string) => void; | ||
| destination: (serialized_info: string) => void; | ||
| speaking: () => void; | ||
| silent: () => void; | ||
| unknown: (message: string) => void; | ||
| startup_error: (message: string) => void | ||
| } | ||
| export type { SimliClientEvents } |
| export enum LogLevel { | ||
| DEBUG = 0, | ||
| INFO = 1, | ||
| ERROR = 2, | ||
| CRITICAL = 3 | ||
| } | ||
| export class Logger { | ||
| private currentLevel: LogLevel; | ||
| destination: string | null; | ||
| session_id: string | null; | ||
| constructor( | ||
| level: LogLevel = LogLevel.INFO, | ||
| ) { | ||
| this.currentLevel = level; | ||
| this.destination = null; | ||
| this.session_id = null; | ||
| } | ||
| private formatMessage(level: string, message: string): string { | ||
| const timestamp = new Date().toISOString(); | ||
| const destination = this.destination ?? 'not_received'; | ||
| const sessionId = this.session_id ?? 'not_received'; | ||
| return `SimliClient | ${timestamp} | ${level} | ${destination}/${sessionId} | ${message}`; | ||
| } | ||
| private log(level: LogLevel, levelName: string, message: string, ...args: any[]): void { | ||
| if (level < this.currentLevel) { | ||
| return; | ||
| } | ||
| const formattedMessage = this.formatMessage(levelName, message); | ||
| switch (level) { | ||
| case LogLevel.DEBUG: | ||
| case LogLevel.INFO: | ||
| console.log(formattedMessage, ...args); | ||
| break; | ||
| case LogLevel.ERROR: | ||
| case LogLevel.CRITICAL: | ||
| console.error(formattedMessage, ...args); | ||
| break; | ||
| } | ||
| } | ||
| public debug(message: string, ...args: any[]): void { | ||
| this.log(LogLevel.DEBUG, 'DEBUG', message, ...args); | ||
| } | ||
| public info(message: string, ...args: any[]): void { | ||
| this.log(LogLevel.INFO, 'INFO', message, ...args); | ||
| } | ||
| public error(message: string, ...args: any[]): void { | ||
| this.log(LogLevel.ERROR, 'ERROR', message, ...args); | ||
| } | ||
| public critical(message: string, ...args: any[]): void { | ||
| this.log(LogLevel.CRITICAL, 'CRITICAL', message, ...args); | ||
| } | ||
| public setLevel(level: LogLevel): void { | ||
| this.currentLevel = level; | ||
| } | ||
| public getLevel(): LogLevel { | ||
| return this.currentLevel; | ||
| } | ||
| } |
| import { Logger } from "../Logger"; | ||
| type ClientSignals = "DONE" | "SKIP" | ||
| interface BaseSignaling { | ||
| logger: Logger | ||
| connect(connected: () => void): Promise<void>; | ||
| disconnect(): void; | ||
| sendSignal(data: ClientSignals): void; | ||
| sendAudioData(audioData: Uint8Array): void; | ||
| sendAudioDataImmediate(audioData: Uint8Array): void; | ||
| } | ||
| export type { BaseSignaling, ClientSignals } |
| import { Logger, LogLevel } from "../Logger" | ||
| import { BaseSignaling, ClientSignals } from "./BaseSignaling" | ||
| class WebSocketSignaling implements BaseSignaling { | ||
| wsURL: string | URL | ||
| wsConnection: WebSocket | ||
| logger: Logger | ||
| constructor(wsURL: string | URL, logger: Logger) { | ||
| this.wsURL = wsURL | ||
| this.wsConnection = new WebSocket(this.wsURL) | ||
| this.wsConnection.addEventListener("message", (message) => (this.logger.debug(message.data))) | ||
| this.logger = logger | ||
| } | ||
| async connect(connected: () => void): Promise<void> { | ||
| this.wsConnection.onopen = connected | ||
| } | ||
| disconnect(): void { | ||
| this.wsConnection.close() | ||
| } | ||
| private send(data: Uint8Array | string) { | ||
| if (this.wsConnection.readyState != WebSocket.OPEN) { | ||
| throw `Invalid State, WS Connection ${this.wsConnection.readyState.toString()}` | ||
| } | ||
| this.wsConnection.send(data) | ||
| } | ||
| sendOffer(offer: RTCSessionDescription): void { | ||
| this.send(JSON.stringify(offer)) | ||
| } | ||
| sendSignal(data: ClientSignals): void { | ||
| this.send(data) | ||
| } | ||
| sendAudioData(audioData: Uint8Array) { | ||
| if (this.logger.getLevel() === LogLevel.DEBUG) | ||
| this.logger.debug("Sent Audio of length: " + (audioData.length / 32000).toString()) | ||
| this.send(audioData); | ||
| } | ||
| sendAudioDataImmediate(audioData: Uint8Array) { | ||
| if (this.logger.getLevel() === LogLevel.DEBUG) | ||
| this.logger.debug("Sent Audio of length for immediate playback: " + (audioData.length / 32000).toString()) | ||
| const asciiStr = "PLAY_IMMEDIATE"; | ||
| const encoder = new TextEncoder(); // Default is utf-8 | ||
| const strBytes = encoder.encode(asciiStr); // Uint8Array of " World!" | ||
| const buffer = new Uint8Array(strBytes.length + audioData.length); | ||
| buffer.set(strBytes, 0); | ||
| buffer.set(audioData, strBytes.length); | ||
| this.send(buffer); | ||
| } | ||
| } | ||
| export { WebSocketSignaling } |
| import { SimliClientEvents } from "../Events"; | ||
| import { Logger } from "../Logger"; | ||
| import { BaseSignaling } from "../Signaling/BaseSignaling"; | ||
| type EventCallback = (...args: any[]) => void; | ||
| type EventMap = Map<string, Set<EventCallback>>; | ||
| interface BaseTransport { | ||
| videoElementAnchor: HTMLVideoElement | ||
| audioElementAnchor: HTMLAudioElement | ||
| signalingConnection: BaseSignaling | ||
| session_token: string | ||
| events: EventMap; | ||
| logger: Logger; | ||
| connect(): Promise<void>; | ||
| disconnect(): void; | ||
| on<K extends keyof SimliClientEvents>( | ||
| event: K, | ||
| callback: SimliClientEvents[K] | ||
| ): void; | ||
| off<K extends keyof SimliClientEvents>( | ||
| event: K, | ||
| callback: SimliClientEvents[K], | ||
| ): void; | ||
| emit<K extends keyof SimliClientEvents>( | ||
| event: K, | ||
| ...args: Parameters<SimliClientEvents[K]> | ||
| ): void; | ||
| } | ||
| function register_destination(logger: Logger, serialized_info: string) { | ||
| const parsed = JSON.parse(serialized_info) | ||
| logger.destination = parsed.destination | ||
| logger.session_id = parsed.session_id | ||
| } | ||
| async function handleMessage(transport: BaseTransport, message: MessageEvent): Promise<void> { | ||
| const firstToken = (message.data as string).toUpperCase().split(" ")[0] | ||
| switch (firstToken) { | ||
| case "START": { | ||
| // SOFT IGNORE | ||
| break | ||
| } | ||
| case "ACK": { | ||
| transport.emit("ack") | ||
| break; | ||
| } | ||
| case "STOP": { | ||
| transport.disconnect(); | ||
| transport.emit("stop") | ||
| break; | ||
| } | ||
| case "CLOSING": | ||
| case "RATE": | ||
| case "ERROR": | ||
| case "ERROR:": { | ||
| transport.disconnect() | ||
| transport.emit("error", message.data as string) | ||
| } | ||
| case "SPEAK": { | ||
| transport.emit("speaking"); | ||
| break | ||
| } | ||
| case "SILENT": { | ||
| transport.emit("silent"); | ||
| break | ||
| } | ||
| default: { | ||
| if (firstToken.includes("SDP") || firstToken.includes("LIVEKIT")) { | ||
| transport.emit("connection_info", message.data) | ||
| } else if (firstToken.includes("VIDEO_METADATA")) { | ||
| transport.emit("video_info", message.data) | ||
| } else if (firstToken.includes("ENDFRAME")) { | ||
| transport.disconnect() | ||
| } else if (firstToken.includes("DESTINATION")) { | ||
| transport.emit("destination", message.data) | ||
| } else { | ||
| transport.emit("unknown", message.data) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| export { handleMessage, register_destination }; | ||
| export type { | ||
| BaseTransport, EventCallback, EventMap | ||
| }; |
| import { RemoteParticipant, RemoteTrack, RemoteTrackPublication, Room, RoomEvent, RoomOptions, Track } from "livekit-client"; | ||
| import { WebSocketSignaling } from "../Signaling/WebSocketSignaling"; | ||
| import { SimliClientEvents } from "../Events"; | ||
| import { BaseTransport, EventCallback, EventMap, handleMessage, register_destination } from "./BaseTransport"; | ||
| import { Logger } from "../Logger"; | ||
| class LivekitTransport implements BaseTransport { | ||
| videoElementAnchor: HTMLVideoElement | ||
| audioElementAnchor: HTMLAudioElement | ||
| signalingConnection: WebSocketSignaling; | ||
| session_token: string; | ||
| pc: Room | ||
| logger: Logger | ||
| events: EventMap = new Map() | ||
| private websocketPromise: Promise<unknown>; | ||
| private websocketReject: ((reason: string) => void) | null = null; | ||
| constructor( | ||
| simliBaseWSURL: string, | ||
| session_token: string, | ||
| videoElementAnchor: HTMLVideoElement, | ||
| audioElementAnchor: HTMLAudioElement, | ||
| logger: Logger, | ||
| failSignal: (message: string) => void | ||
| ) { | ||
| this.logger = logger | ||
| this.on("startup_error", failSignal) | ||
| this.session_token = session_token | ||
| const wsURL = new URL(simliBaseWSURL + "/compose/webrtc/livekit") | ||
| wsURL.searchParams.set("session_token", session_token) | ||
| this.signalingConnection = new WebSocketSignaling(wsURL, this.logger) | ||
| this.on("destination", (serilized_info) => register_destination(this.logger, serilized_info)) | ||
| this.websocketPromise = new Promise( | ||
| (resolve, reject) => { | ||
| this.websocketReject = reject; | ||
| this.signalingConnection.connect(() => { | ||
| resolve("success") | ||
| this.logger.debug("LK WebSocket Connected") | ||
| }) | ||
| } | ||
| ) | ||
| this.signalingConnection.wsConnection.onmessage = (message) => { handleMessage(this, message) } | ||
| this.signalingConnection.wsConnection.onerror = (evt) => { | ||
| this.emit("startup_error", "Websocket Failed"); | ||
| if (this.websocketReject) { | ||
| this.websocketReject("Websocket Failed"); | ||
| this.websocketReject = null; // Prevent multiple rejections | ||
| } | ||
| } | ||
| const options: RoomOptions = { adaptiveStream: true, dynacast: true } | ||
| this.pc = new Room(options); | ||
| this.on("connection_info", (serialized_info) => this.join_lk_room(serialized_info)) | ||
| this.videoElementAnchor = videoElementAnchor | ||
| this.audioElementAnchor = audioElementAnchor | ||
| } | ||
| public on<K extends keyof SimliClientEvents>( | ||
| event: K, | ||
| callback: SimliClientEvents[K] | ||
| ): void { | ||
| if (!this.events.has(event)) { | ||
| this.events.set(event, new Set()); | ||
| } | ||
| this.events.get(event)?.add(callback as EventCallback); | ||
| this.logger.debug("Registered Callback for Event: " + event) | ||
| } | ||
| public off<K extends keyof SimliClientEvents>( | ||
| event: K, | ||
| callback: SimliClientEvents[K], | ||
| ): void { | ||
| if (!this.events.has(event)) { | ||
| throw "Event Not Regsitered" | ||
| } | ||
| this.events.get(event)?.delete(callback as EventCallback); | ||
| } | ||
| emit<K extends keyof SimliClientEvents>( | ||
| event: K, | ||
| ...args: Parameters<SimliClientEvents[K]> | ||
| ): void { | ||
| this.logger.debug("Event: " + event) | ||
| this.events.get(event)?.forEach((callback) => { | ||
| callback(...args); | ||
| }); | ||
| } | ||
| async connect() { | ||
| this.logger.info("Connecting") | ||
| this.setupConnectionStateHandler() | ||
| await this.websocketPromise | ||
| } | ||
| async disconnect() { | ||
| this.logger.info("Disconnecting") | ||
| try { | ||
| this.signalingConnection.sendSignal("DONE") | ||
| } | ||
| catch { | ||
| this.logger.error("FAILED TO SEND FINAL MESSAGE") | ||
| } | ||
| try { | ||
| this.signalingConnection.disconnect() | ||
| } catch { | ||
| this.logger.error("SIGNALING ALREADY DISCONNECTED") | ||
| } | ||
| try { | ||
| await this.pc.disconnect() | ||
| } catch { | ||
| this.logger.error("LOCAL PEER ALREADY CLOSED") | ||
| } | ||
| } | ||
| private async join_lk_room(serialized_info: string) { | ||
| const info = JSON.parse(serialized_info) | ||
| this.logger.debug(info) | ||
| if (info.livekit_url && info.livekit_token) { | ||
| await this.pc.connect(info.livekit_url, info.livekit_token); | ||
| } else { | ||
| this.disconnect() | ||
| this.emit("error", "Invalid Join Info, Contact Simli For Support") | ||
| } | ||
| } | ||
| private setupConnectionStateHandler() { | ||
| this.pc.on(RoomEvent.Disconnected, () => { | ||
| this.disconnect(); | ||
| }) | ||
| this.pc.on(RoomEvent.Connected, () => { | ||
| }) | ||
| this.pc.on(RoomEvent.TrackSubscribed, (track: RemoteTrack, | ||
| publication: RemoteTrackPublication, | ||
| participant: RemoteParticipant, | ||
| ) => { | ||
| this.logger.debug("Track Received: " + track.kind) | ||
| if (track.kind === Track.Kind.Video) { | ||
| track.attach(this.videoElementAnchor) | ||
| this.videoElementAnchor.requestVideoFrameCallback(() => { | ||
| this.emit("start"); | ||
| }); | ||
| } else if (track.kind === Track.Kind.Audio) { | ||
| track.attach(this.audioElementAnchor) | ||
| } | ||
| }) | ||
| }; | ||
| } | ||
| export { LivekitTransport } |
| import { WebSocketSignaling } from "../Signaling/WebSocketSignaling"; | ||
| import { SimliClientEvents } from "../Events"; | ||
| import { BaseTransport, EventCallback, EventMap, handleMessage, register_destination } from "./BaseTransport"; | ||
| import { Logger } from "../Logger"; | ||
| class P2PTransport implements BaseTransport { | ||
| videoElementAnchor: HTMLVideoElement | ||
| audioElementAnchor: HTMLAudioElement | ||
| signalingConnection: WebSocketSignaling; | ||
| session_token: string; | ||
| pc: RTCPeerConnection; | ||
| events: EventMap = new Map() | ||
| logger: Logger; | ||
| private iceCandidateCount: number; | ||
| private previousIceCandidateCount: number; | ||
| private iceTimeout: NodeJS.Timeout | null = null; | ||
| private websocketPromise: Promise<unknown>; | ||
| private websocketReject: ((reason: string) => void) | null = null; | ||
| constructor( | ||
| simliBaseWSURL: string, | ||
| session_token: string, | ||
| enableSFU: boolean, | ||
| iceServers: RTCIceServer[], | ||
| videoElementAnchor: HTMLVideoElement, | ||
| audioElementAnchor: HTMLAudioElement, | ||
| logger: Logger, | ||
| failSignal: (message: string) => void, | ||
| ) { | ||
| this.logger = logger | ||
| this.on("startup_error", failSignal) | ||
| this.session_token = session_token | ||
| const wsURL = new URL(simliBaseWSURL + "/compose/webrtc/p2p") | ||
| wsURL.searchParams.set("session_token", session_token) | ||
| wsURL.searchParams.set("enableSFU", String(enableSFU)) | ||
| this.on("destination", (serilized_info) => register_destination(this.logger, serilized_info)) | ||
| this.signalingConnection = new WebSocketSignaling(wsURL, this.logger) | ||
| this.websocketPromise = new Promise( | ||
| (resolve, reject) => { | ||
| this.websocketReject = reject; | ||
| this.signalingConnection.connect(() => { | ||
| resolve("success") | ||
| this.logger.debug("P2P WebSocket Connected") | ||
| }) | ||
| } | ||
| ) | ||
| this.signalingConnection.wsConnection.onmessage = (message) => { handleMessage(this, message) } | ||
| this.signalingConnection.wsConnection.onerror = (evt) => { | ||
| this.emit("startup_error", "Websocket Failed"); | ||
| if (this.websocketReject) { | ||
| this.websocketReject("Websocket Failed"); | ||
| this.websocketReject = null; // Prevent multiple rejections | ||
| } | ||
| } | ||
| this.on("connection_info", (serialized_info) => this.registerPeerInfo(serialized_info)) | ||
| this.videoElementAnchor = videoElementAnchor | ||
| this.audioElementAnchor = audioElementAnchor | ||
| this.iceCandidateCount = 0 | ||
| this.previousIceCandidateCount = 0 | ||
| const config = { | ||
| sdpSemantics: "unified-plan", | ||
| iceServers: iceServers, | ||
| }; | ||
| this.pc = new window.RTCPeerConnection(config); | ||
| this.pc.addTransceiver("audio", { | ||
| direction: "recvonly", | ||
| }); | ||
| this.pc.addTransceiver("video", { | ||
| direction: "recvonly", | ||
| }) | ||
| } | ||
| public on<K extends keyof SimliClientEvents>( | ||
| event: K, | ||
| callback: SimliClientEvents[K] | ||
| ): void { | ||
| if (!this.events.has(event)) { | ||
| this.events.set(event, new Set()); | ||
| } | ||
| this.events.get(event)?.add(callback as EventCallback); | ||
| } | ||
| public off<K extends keyof SimliClientEvents>( | ||
| event: K, | ||
| callback: SimliClientEvents[K], | ||
| ): void { | ||
| this.events.get(event)?.delete(callback as EventCallback); | ||
| } | ||
| emit<K extends keyof SimliClientEvents>( | ||
| event: K, | ||
| ...args: Parameters<SimliClientEvents[K]> | ||
| ): void { | ||
| this.events.get(event)?.forEach((callback) => { | ||
| try { | ||
| callback(...args); | ||
| } | ||
| catch { | ||
| this.logger.error("CALLBACK FAILED: " + callback.name) | ||
| } | ||
| }); | ||
| } | ||
| async connect() { | ||
| const offer = await this.pc.createOffer(); | ||
| await this.pc.setLocalDescription(offer); | ||
| await this.waitForIceGathering(); | ||
| this.setupPeerConnectionListeners() | ||
| await this.websocketPromise | ||
| if (this.pc.localDescription) { | ||
| this.signalingConnection.sendOffer(this.pc.localDescription) | ||
| } | ||
| } | ||
| async disconnect() { | ||
| this.logger.info("Disconnecting") | ||
| try { | ||
| this.signalingConnection.sendSignal("DONE") | ||
| } | ||
| catch { | ||
| this.logger.error("FAILED TO SEND FINAL MESSAGE") | ||
| } | ||
| try { | ||
| this.signalingConnection.disconnect() | ||
| } catch { | ||
| this.logger.error("SIGNALING ALREADY DISCONNECTED") | ||
| } | ||
| try { | ||
| this.pc.close() | ||
| } catch { | ||
| this.logger.error("LOCAL PEER ALREADY CLOSED") | ||
| } | ||
| } | ||
| private async registerPeerInfo(serialized_info: string) { | ||
| const info = JSON.parse(serialized_info) | ||
| if (info.sdp && info.type == "answer") { | ||
| await this.pc.setRemoteDescription(new RTCSessionDescription(info)); | ||
| } else { | ||
| this.disconnect() | ||
| this.emit("error", "Invalid Join Info, Contact Simli For Support") | ||
| } | ||
| } | ||
| private async waitForIceGathering(): Promise<void> { | ||
| this.iceCandidateCount = 0; | ||
| this.previousIceCandidateCount = 0; | ||
| if (this.pc.iceGatheringState === "complete") { | ||
| return; | ||
| } | ||
| return new Promise<void>((resolve, reject) => { | ||
| if (!this.iceTimeout) { | ||
| this.iceTimeout = setTimeout(() => { | ||
| reject(new Error("ICE gathering timeout")); | ||
| }, 10000); | ||
| } | ||
| const checkIceCandidates = () => { | ||
| if ( | ||
| this.pc.iceGatheringState === "complete" || | ||
| this.iceCandidateCount === this.previousIceCandidateCount | ||
| ) { | ||
| if (this.iceTimeout) { | ||
| clearTimeout(this.iceTimeout); | ||
| } | ||
| resolve(); | ||
| } else { | ||
| this.previousIceCandidateCount = this.iceCandidateCount; | ||
| setTimeout(checkIceCandidates, 150); | ||
| } | ||
| }; | ||
| checkIceCandidates(); | ||
| }); | ||
| } | ||
| private setupPeerConnectionListeners() { | ||
| this.pc.addEventListener("track", (evt) => { | ||
| if (evt.track.kind === "video") { | ||
| this.videoElementAnchor.srcObject = evt.streams[0]; | ||
| this.videoElementAnchor.requestVideoFrameCallback(() => { | ||
| this.emit("start") | ||
| }); | ||
| } else if (evt.track.kind === "audio" && this.audioElementAnchor) { | ||
| this.audioElementAnchor.srcObject = evt.streams[0]; | ||
| } | ||
| }); | ||
| this.pc.onicecandidate = (event) => { | ||
| if (event.candidate !== null) { | ||
| this.iceCandidateCount += 1; | ||
| } | ||
| }; | ||
| } | ||
| } | ||
| export { P2PTransport } |
+2
-1
@@ -1,1 +0,2 @@ | ||
| export { SimliClient, SimliClientConfig, SimliClientEvents } from './SimliClient'; | ||
| export { SimliClient, generateSimliSessionToken, generateIceServers, LogLevel } from './Client'; | ||
| export type { SimliSessionRequest, } from './Client'; |
+6
-3
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.SimliClient = void 0; | ||
| var SimliClient_1 = require("./SimliClient"); | ||
| Object.defineProperty(exports, "SimliClient", { enumerable: true, get: function () { return SimliClient_1.SimliClient; } }); | ||
| exports.LogLevel = exports.generateIceServers = exports.generateSimliSessionToken = exports.SimliClient = void 0; | ||
| var Client_1 = require("./Client"); | ||
| Object.defineProperty(exports, "SimliClient", { enumerable: true, get: function () { return Client_1.SimliClient; } }); | ||
| Object.defineProperty(exports, "generateSimliSessionToken", { enumerable: true, get: function () { return Client_1.generateSimliSessionToken; } }); | ||
| Object.defineProperty(exports, "generateIceServers", { enumerable: true, get: function () { return Client_1.generateIceServers; } }); | ||
| Object.defineProperty(exports, "LogLevel", { enumerable: true, get: function () { return Client_1.LogLevel; } }); |
+7
-1
@@ -1,1 +0,7 @@ | ||
| export {SimliClient, SimliClientConfig, SimliClientEvents} from './SimliClient'; | ||
| export { | ||
| SimliClient, generateSimliSessionToken, generateIceServers, LogLevel | ||
| } from './Client'; | ||
| export type { | ||
| SimliSessionRequest, | ||
| } from './Client'; | ||
+1
-1
| { | ||
| "name": "simli-client", | ||
| "version": "2.0.0", | ||
| "version": "3.0.0", | ||
| "description": "Simli WebRTC Client", | ||
@@ -5,0 +5,0 @@ "main": "dist/index.js", |
| import { ConnectionState } from 'livekit-client'; | ||
| interface SimliClientConfig { | ||
| apiKey: string | ""; | ||
| faceID: string; | ||
| handleSilence: boolean; | ||
| maxSessionLength: number; | ||
| maxIdleTime: number; | ||
| session_token: string | ""; | ||
| videoRef: HTMLVideoElement; | ||
| audioRef: HTMLAudioElement; | ||
| enableConsoleLogs?: boolean; | ||
| SimliURL: string | ""; | ||
| maxRetryAttempts: number | 100; | ||
| retryDelay_ms: number | 2000; | ||
| videoReceivedTimeout: number | 15000; | ||
| enableSFU: boolean | true; | ||
| model: "fasttalk" | "artalk" | ""; | ||
| } | ||
| interface SimliSessionRequest { | ||
| faceId: string; | ||
| isJPG: boolean; | ||
| apiKey: string; | ||
| syncAudio: boolean; | ||
| handleSilence: boolean; | ||
| maxSessionLength: number; | ||
| maxIdleTime: number; | ||
| model: "fasttalk" | "artalk"; | ||
| } | ||
| interface SimliSessionToken { | ||
| session_token: string; | ||
| } | ||
| interface SimliClientEvents { | ||
| connected: () => void; | ||
| disconnected: () => void; | ||
| failed: (reason: string) => void; | ||
| speaking: () => void; | ||
| silent: () => void; | ||
| } | ||
| declare class SimliClient { | ||
| private pc; | ||
| private apiKey; | ||
| private session_token; | ||
| private faceID; | ||
| private handleSilence; | ||
| private videoRef; | ||
| private audioRef; | ||
| private errorReason; | ||
| private sessionInitialized; | ||
| private inputStreamTrack; | ||
| private sourceNode; | ||
| private audioWorklet; | ||
| private audioBuffer; | ||
| private maxSessionLength; | ||
| private maxIdleTime; | ||
| private model; | ||
| private webSocket; | ||
| private lastSendTime; | ||
| private MAX_RETRY_ATTEMPTS; | ||
| private RETRY_DELAY; | ||
| private connectionTimeout; | ||
| private readonly CONNECTION_TIMEOUT_MS; | ||
| isAvatarSpeaking: boolean; | ||
| enableConsoleLogs: boolean; | ||
| private events; | ||
| private retryAttempt; | ||
| private inputIceServers; | ||
| private videoReceived; | ||
| config: SimliClientConfig | null; | ||
| private SimliURL; | ||
| private SimliWSURL; | ||
| private audioContext; | ||
| private start_stamp; | ||
| on<K extends keyof SimliClientEvents>(event: K, callback: SimliClientEvents[K]): void; | ||
| off<K extends keyof SimliClientEvents>(event: K, callback: SimliClientEvents[K]): void; | ||
| private emit; | ||
| Initialize(config: SimliClientConfig): void; | ||
| private setupConnectionStateHandler; | ||
| start(retryAttempt?: number): Promise<void>; | ||
| private sendPingMessage; | ||
| createSessionToken(SimliURL: string, metadata: SimliSessionRequest): Promise<SimliSessionToken>; | ||
| private sendSessionToken; | ||
| private handleConnectionFailure; | ||
| private handleConnectionTimeout; | ||
| private handleDisconnection; | ||
| private cleanup; | ||
| private clearTimeouts; | ||
| listenToMediastreamTrack(stream: MediaStreamTrack): void; | ||
| private initializeAudioWorklet; | ||
| sendAudioData(audioData: Uint8Array): void; | ||
| sendAudioDataImmediate(audioData: Uint8Array): void; | ||
| close(): void; | ||
| ClearBuffer: () => void; | ||
| isConnected(): boolean; | ||
| getConnectionStatus(): { | ||
| sessionInitialized: boolean; | ||
| webSocketState: number | null; | ||
| peerConnectionState: ConnectionState | null; | ||
| errorReason: string | null; | ||
| }; | ||
| private setupWebSocketListeners; | ||
| } | ||
| export { SimliClient, SimliClientConfig, SimliClientEvents }; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.SimliClient = void 0; | ||
| // src/index.ts | ||
| const livekit_client_1 = require("livekit-client"); | ||
| const AudioProcessor = ` | ||
| class AudioProcessor extends AudioWorkletProcessor { | ||
| constructor() { | ||
| super(); | ||
| this.buffer = new Int16Array(${3000}); | ||
| this.bufferIndex = 0; | ||
| } | ||
| process(inputs, outputs, parameters) { | ||
| const input = inputs[0]; | ||
| const inputChannel = input[0]; | ||
| if (inputChannel) { | ||
| for (let i = 0; i < inputChannel.length; i++) { | ||
| this.buffer[this.bufferIndex] = Math.max(-32768, Math.min(32767, Math.round(inputChannel[i] * 32767))); | ||
| this.bufferIndex++; | ||
| if (this.bufferIndex === this.buffer.length){ | ||
| this.port.postMessage({type: 'audioData', data: this.buffer.slice(0, this.bufferIndex)}); | ||
| this.bufferIndex = 0; | ||
| } | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
| } | ||
| registerProcessor('audio-processor', AudioProcessor); | ||
| `; | ||
| class SimliClient { | ||
| pc = null; | ||
| apiKey = ""; | ||
| session_token = ""; | ||
| faceID = ""; | ||
| handleSilence = true; | ||
| videoRef = null; | ||
| audioRef = null; | ||
| errorReason = null; | ||
| sessionInitialized = false; | ||
| inputStreamTrack = null; | ||
| sourceNode = null; | ||
| audioWorklet = null; | ||
| audioBuffer = null; | ||
| maxSessionLength = 3600; | ||
| maxIdleTime = 600; | ||
| model = "fasttalk"; | ||
| webSocket = null; | ||
| lastSendTime = 0; | ||
| MAX_RETRY_ATTEMPTS = 100; | ||
| RETRY_DELAY = 2000; | ||
| connectionTimeout = null; | ||
| CONNECTION_TIMEOUT_MS = 15000; | ||
| isAvatarSpeaking = false; | ||
| enableConsoleLogs = false; | ||
| // Event handling | ||
| events = new Map(); | ||
| retryAttempt = 1; | ||
| inputIceServers = []; | ||
| videoReceived = false; | ||
| config = null; | ||
| SimliURL = ""; | ||
| SimliWSURL = null; | ||
| audioContext = null; | ||
| start_stamp = 0; | ||
| // Type-safe event methods | ||
| on(event, callback) { | ||
| if (!this.events.has(event)) { | ||
| this.events.set(event, new Set()); | ||
| } | ||
| this.events.get(event)?.add(callback); | ||
| } | ||
| off(event, callback) { | ||
| this.events.get(event)?.delete(callback); | ||
| } | ||
| emit(event, ...args) { | ||
| this.events.get(event)?.forEach((callback) => { | ||
| callback(...args); | ||
| }); | ||
| } | ||
| Initialize(config) { | ||
| if ((!config.apiKey || config.apiKey === "") && | ||
| (!config.session_token || config.session_token === "")) { | ||
| console.error("SIMLI: apiKey or session_token is required in config"); | ||
| throw new Error("apiKey or session_token is required in config"); | ||
| } | ||
| this.config = config; | ||
| this.apiKey = config.apiKey; | ||
| this.faceID = config.faceID; | ||
| this.handleSilence = config.handleSilence; | ||
| this.maxSessionLength = config.maxSessionLength; | ||
| this.maxIdleTime = config.maxIdleTime; | ||
| this.enableConsoleLogs = config.enableConsoleLogs ?? false; | ||
| this.session_token = config.session_token; | ||
| this.MAX_RETRY_ATTEMPTS = | ||
| config.maxRetryAttempts ?? this.MAX_RETRY_ATTEMPTS; | ||
| this.RETRY_DELAY = config.retryDelay_ms ?? this.RETRY_DELAY; | ||
| if (config.model !== "") { | ||
| this.model = config.model; | ||
| } | ||
| if (!config.SimliURL || config.SimliURL === "") { | ||
| this.SimliURL = "https://api.simli.ai"; | ||
| } | ||
| else { | ||
| this.SimliURL = config.SimliURL; | ||
| } | ||
| this.SimliWSURL = this.SimliURL.replace("http", "ws"); | ||
| if (typeof window !== "undefined") { | ||
| this.videoRef = config.videoRef; | ||
| this.audioRef = config.audioRef; | ||
| if (!(this.videoRef instanceof HTMLVideoElement)) { | ||
| console.error("SIMLI: videoRef is required in config as HTMLVideoElement"); | ||
| } | ||
| if (!(this.audioRef instanceof HTMLAudioElement)) { | ||
| console.error("SIMLI: audioRef is required in config as HTMLAudioElement"); | ||
| } | ||
| console.log("SIMLI: simli-client@2.0.0 initialized"); | ||
| } | ||
| else { | ||
| console.warn("SIMLI: Running in Node.js environment. Some features may not be available."); | ||
| } | ||
| } | ||
| setupConnectionStateHandler(connectionSuccessResolve) { | ||
| if (!this.pc) | ||
| return; | ||
| this.pc.on(livekit_client_1.RoomEvent.Disconnected, () => { | ||
| if (this.videoReceived) { | ||
| this.emit("disconnected"); | ||
| this.handleDisconnection(); | ||
| } | ||
| }); | ||
| this.pc.on(livekit_client_1.RoomEvent.Connected, () => { | ||
| this.emit("connected"); | ||
| this.clearTimeouts(); | ||
| }); | ||
| this.pc.on(livekit_client_1.RoomEvent.TrackSubscribed, (track, publication, participant) => { | ||
| if (track.kind === livekit_client_1.Track.Kind.Video && this.videoRef) { | ||
| track.attach(this.videoRef); | ||
| this.videoRef.requestVideoFrameCallback(() => { | ||
| console.log("Connection Time:", new Date().getTime() - this.start_stamp); | ||
| connectionSuccessResolve(); | ||
| }); | ||
| } | ||
| else if (track.kind === livekit_client_1.Track.Kind.Audio && this.audioRef) { | ||
| track.attach(this.audioRef); | ||
| } | ||
| }); | ||
| } | ||
| ; | ||
| async start(retryAttempt = 0) { | ||
| try { | ||
| this.start_stamp = new Date().getTime(); | ||
| await this.cleanup(); | ||
| if (this.config) { | ||
| this.Initialize(this.config); | ||
| } | ||
| this.clearTimeouts(); | ||
| // Set overall connection timeout | ||
| if (!this.session_token) { | ||
| const metadata = { | ||
| faceId: this.faceID, | ||
| isJPG: false, | ||
| apiKey: this.apiKey, | ||
| syncAudio: true, | ||
| handleSilence: this.handleSilence, | ||
| maxSessionLength: this.maxSessionLength, | ||
| maxIdleTime: this.maxIdleTime, | ||
| model: this.model, | ||
| }; | ||
| const sessionRunData = await this.createSessionToken(this.SimliURL, metadata); | ||
| this.session_token = sessionRunData.session_token; | ||
| } | ||
| const url = `${this.SimliWSURL}/StartWebRTCSessionLivekit`; | ||
| const ws = new WebSocket(url); | ||
| this.webSocket = ws; | ||
| const wsConnectPromise = new Promise((resolve, reject) => { | ||
| if (this.webSocket) { | ||
| this.setupWebSocketListeners(this.webSocket, resolve); | ||
| } | ||
| this.connectionTimeout = setTimeout(() => { | ||
| this.handleConnectionTimeout(reject); | ||
| }, this.CONNECTION_TIMEOUT_MS); | ||
| }); | ||
| await Promise.race([ | ||
| wsConnectPromise, | ||
| this.connectionTimeout, | ||
| ]); | ||
| this.videoReceived = true; | ||
| console.log("CONNECTED"); | ||
| // Clear timeout if connection successful | ||
| this.clearTimeouts(); | ||
| } | ||
| catch (error) { | ||
| if (this.enableConsoleLogs) | ||
| console.error(`SIMLI: Connection attempt ${retryAttempt} failed:`, error); | ||
| this.clearTimeouts(); | ||
| if (this.retryAttempt < this.MAX_RETRY_ATTEMPTS) { | ||
| if (this.enableConsoleLogs) | ||
| console.log(`SIMLI: Retrying connection... Attempt ${retryAttempt + 1}`); | ||
| await new Promise((resolve) => setTimeout(resolve, this.RETRY_DELAY)); | ||
| this.retryAttempt += 1; | ||
| return this.start(this.retryAttempt); | ||
| } | ||
| this.emit("failed", `Failed to connect after ${this.MAX_RETRY_ATTEMPTS} attempts`); | ||
| throw error; | ||
| } | ||
| } | ||
| sendPingMessage() { | ||
| if (this.webSocket && this.webSocket.readyState === this.webSocket.OPEN) { | ||
| const message = "ping " + Date.now(); | ||
| try { | ||
| this.webSocket?.send(message); | ||
| } | ||
| catch (error) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Failed to send message:", error); | ||
| this.handleConnectionFailure("Failed to send ping message"); | ||
| } | ||
| } | ||
| else { | ||
| if (this.enableConsoleLogs) | ||
| console.warn("SIMLI: WebSocket is not open. Current state:", this.webSocket?.readyState); | ||
| if (this.errorReason !== null) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Error Reason: ", this.errorReason); | ||
| } | ||
| } | ||
| } | ||
| async createSessionToken(SimliURL, metadata) { | ||
| if (this.session_token && this.session_token !== "") { | ||
| return { session_token: this.session_token }; | ||
| } | ||
| try { | ||
| const url = `${SimliURL}/startAudioToVideoSession`; | ||
| const response = await fetch(url, { | ||
| method: "POST", | ||
| body: JSON.stringify(metadata), | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| }, | ||
| }); | ||
| if (!response.ok) { | ||
| const errorText = await response.text(); | ||
| throw new Error(`${errorText}`); | ||
| } | ||
| const resJSON = await response.json(); | ||
| return resJSON; | ||
| } | ||
| catch (error) { | ||
| this.handleConnectionFailure(`Session initialization failed: ${error}`); | ||
| throw error; | ||
| } | ||
| } | ||
| async sendSessionToken(sessionToken) { | ||
| try { | ||
| if (this.webSocket && this.webSocket.readyState === this.webSocket.OPEN) { | ||
| this.webSocket?.send(sessionToken); | ||
| } | ||
| else { | ||
| throw new Error("WebSocket not open when trying to send session token"); | ||
| } | ||
| } | ||
| catch (error) { | ||
| this.handleConnectionFailure(`Session initialization failed: ${error}`); | ||
| throw error; | ||
| } | ||
| } | ||
| handleConnectionFailure(reason) { | ||
| this.errorReason = reason; | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: connection failure:", reason); | ||
| if (this.retryAttempt >= this.MAX_RETRY_ATTEMPTS) { | ||
| this.emit("failed", reason); | ||
| } | ||
| this.cleanup(); | ||
| } | ||
| handleConnectionTimeout(reject) { | ||
| this.handleConnectionFailure("Connection timed out"); | ||
| reject(new Error("Connection timed out")); | ||
| } | ||
| handleDisconnection() { | ||
| if (!this.sessionInitialized) { | ||
| if (this.enableConsoleLogs) | ||
| console.log("SIMLI: Connection lost while being esablished, attempting to reconnect..."); | ||
| this.start(this.retryAttempt) | ||
| .catch((error) => { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Reconnection failed:", error); | ||
| if (this.retryAttempt >= this.MAX_RETRY_ATTEMPTS) { | ||
| this.emit("failed", "Reconnection failed"); | ||
| } | ||
| }); | ||
| } | ||
| } | ||
| async cleanup() { | ||
| if (this.videoRef) | ||
| this.videoRef.srcObject = null; | ||
| if (this.audioRef) | ||
| this.audioRef.srcObject = null; | ||
| if (this.webSocket) { | ||
| this.webSocket.close(); | ||
| this.webSocket = null; | ||
| } | ||
| if (this.pc) { | ||
| await this.pc.disconnect(); | ||
| this.pc = null; | ||
| } | ||
| if (this.audioWorklet) { | ||
| this.audioWorklet.disconnect(); | ||
| this.audioWorklet = null; | ||
| } | ||
| if (this.sourceNode) { | ||
| this.sourceNode.disconnect(); | ||
| this.sourceNode = null; | ||
| } | ||
| this.sessionInitialized = false; | ||
| // Event handling | ||
| this.clearTimeouts(); | ||
| } | ||
| clearTimeouts() { | ||
| if (this.connectionTimeout) { | ||
| clearTimeout(this.connectionTimeout); | ||
| this.connectionTimeout = null; | ||
| } | ||
| } | ||
| listenToMediastreamTrack(stream) { | ||
| try { | ||
| this.inputStreamTrack = stream; | ||
| this.audioContext = new (window.AudioContext || | ||
| window.webkitAudioContext)({ | ||
| sampleRate: 16000, | ||
| }); | ||
| this.initializeAudioWorklet(this.audioContext, stream); | ||
| } | ||
| catch (error) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Failed to initialize audio stream:", error); | ||
| if (this.retryAttempt >= this.MAX_RETRY_ATTEMPTS) { | ||
| this.emit("failed", "Audio initialization failed"); | ||
| } | ||
| } | ||
| } | ||
| initializeAudioWorklet(audioContext, stream) { | ||
| audioContext.audioWorklet | ||
| .addModule(URL.createObjectURL(new Blob([AudioProcessor], { | ||
| type: "application/javascript", | ||
| }))) | ||
| .then(() => { | ||
| this.audioWorklet = new AudioWorkletNode(audioContext, "audio-processor"); | ||
| this.sourceNode = audioContext.createMediaStreamSource(new MediaStream([stream])); | ||
| if (this.audioWorklet === null) { | ||
| throw new Error("SIMLI: AudioWorklet not initialized"); | ||
| } | ||
| this.sourceNode.connect(this.audioWorklet); | ||
| this.audioWorklet.port.onmessage = (event) => { | ||
| if (event.data.type === "audioData") { | ||
| this.sendAudioData(new Uint8Array(event.data.data.buffer)); | ||
| } | ||
| }; | ||
| }) | ||
| .catch((error) => { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Failed to initialize AudioWorklet:", error); | ||
| if (this.retryAttempt >= this.MAX_RETRY_ATTEMPTS) { | ||
| this.emit("failed", "AudioWorklet initialization failed"); | ||
| } | ||
| }); | ||
| } | ||
| sendAudioData(audioData) { | ||
| if (!this.sessionInitialized) { | ||
| if (this.enableConsoleLogs) | ||
| console.log("SIMLI: Session not initialized. Ignoring audio data."); | ||
| return; | ||
| } | ||
| if (this.webSocket?.readyState !== WebSocket.OPEN) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: WebSocket is not open. Current state:", this.webSocket?.readyState, "Error Reason:", this.errorReason); | ||
| return; | ||
| } | ||
| try { | ||
| this.webSocket.send(audioData); | ||
| const currentTime = Date.now(); | ||
| if (this.lastSendTime !== 0) { | ||
| const timeBetweenSends = currentTime - this.lastSendTime; | ||
| if (timeBetweenSends > 100) { | ||
| // Log only if significant delay | ||
| if (this.enableConsoleLogs) | ||
| console.log("SIMLI: Time between sends:", timeBetweenSends); | ||
| } | ||
| } | ||
| this.lastSendTime = currentTime; | ||
| } | ||
| catch (error) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Failed to send audio data:", error); | ||
| this.handleConnectionFailure("Failed to send audio data"); | ||
| } | ||
| } | ||
| sendAudioDataImmediate(audioData) { | ||
| if (!this.sessionInitialized) { | ||
| if (this.enableConsoleLogs) | ||
| console.log("SIMLI: Session not initialized. Ignoring audio data."); | ||
| return; | ||
| } | ||
| if (this.webSocket?.readyState !== WebSocket.OPEN) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: WebSocket is not open. Current state:", this.webSocket?.readyState, "Error Reason:", this.errorReason); | ||
| return; | ||
| } | ||
| try { | ||
| const asciiStr = "PLAY_IMMEDIATE"; | ||
| const encoder = new TextEncoder(); // Default is utf-8 | ||
| const strBytes = encoder.encode(asciiStr); // Uint8Array of " World!" | ||
| const buffer = new Uint8Array(strBytes.length + audioData.length); | ||
| buffer.set(strBytes, 0); | ||
| buffer.set(audioData, strBytes.length); | ||
| this.webSocket.send(buffer); | ||
| const currentTime = Date.now(); | ||
| if (this.lastSendTime !== 0) { | ||
| const timeBetweenSends = currentTime - this.lastSendTime; | ||
| if (timeBetweenSends > 100) { | ||
| // Log only if significant delay | ||
| if (this.enableConsoleLogs) | ||
| console.log("SIMLI: Time between sends:", timeBetweenSends); | ||
| } | ||
| } | ||
| this.lastSendTime = currentTime; | ||
| } | ||
| catch (error) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Failed to send audio data:", error); | ||
| this.handleConnectionFailure("Failed to send audio data"); | ||
| } | ||
| } | ||
| close() { | ||
| if (this.enableConsoleLogs) | ||
| console.log("SIMLI: Closing SimliClient connection"); | ||
| if (this.webSocket) | ||
| this.webSocket.send("DONE"); | ||
| this.emit("disconnected"); | ||
| try { | ||
| this.cleanup(); | ||
| } | ||
| catch (error) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Error during cleanup:", error); | ||
| } | ||
| } | ||
| ClearBuffer = () => { | ||
| if (this.webSocket?.readyState === WebSocket.OPEN) { | ||
| try { | ||
| this.webSocket.send("SKIP"); | ||
| } | ||
| catch (error) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Failed to clear buffer:", error); | ||
| } | ||
| } | ||
| else { | ||
| if (this.enableConsoleLogs) | ||
| console.warn("SIMLI: Cannot clear buffer: WebSocket not open"); | ||
| } | ||
| }; | ||
| // Utility method to check connection status | ||
| isConnected() { | ||
| return (this.sessionInitialized && | ||
| this.webSocket?.readyState === WebSocket.OPEN && | ||
| this.pc?.state === livekit_client_1.ConnectionState.Connected); | ||
| } | ||
| // Method to get current connection status details | ||
| getConnectionStatus() { | ||
| return { | ||
| sessionInitialized: this.sessionInitialized, | ||
| webSocketState: this.webSocket?.readyState ?? null, | ||
| peerConnectionState: this.pc?.state ?? null, | ||
| errorReason: this.errorReason, | ||
| }; | ||
| } | ||
| setupWebSocketListeners(ws, connectionSuccessResolve) { | ||
| ws.addEventListener("open", async () => { | ||
| connectionSuccessResolve(); | ||
| if (!this.session_token || this.session_token === "") { | ||
| const metadata = { | ||
| faceId: this.faceID, | ||
| isJPG: false, | ||
| apiKey: this.apiKey, | ||
| syncAudio: true, | ||
| handleSilence: this.handleSilence, | ||
| maxSessionLength: this.maxSessionLength, | ||
| maxIdleTime: this.maxIdleTime, | ||
| model: this.model, | ||
| }; | ||
| await this.sendSessionToken((await this.createSessionToken(this.SimliURL, metadata)) | ||
| .session_token); | ||
| } | ||
| else { | ||
| await this.sendSessionToken(this.session_token); | ||
| } | ||
| }); | ||
| ws.addEventListener("message", async (evt) => { | ||
| if (this.enableConsoleLogs) | ||
| console.log("SIMLI: Received message: ", evt.data); | ||
| try { | ||
| if (evt.data === "START") { | ||
| this.sessionInitialized = true; | ||
| this.sendAudioData(new Uint8Array(6000)); | ||
| this.emit("connected"); | ||
| console.log("START"); | ||
| } | ||
| else if (evt.data === "STOP") { | ||
| this.close(); | ||
| } | ||
| else if (evt.data.startsWith("pong") || evt.data === "ACK") { | ||
| // if (this.enableConsoleLogs) console.log("SIMLI: Received ACK"); | ||
| } | ||
| else if (evt.data === "SPEAK") { | ||
| this.emit("speaking"); | ||
| this.isAvatarSpeaking = true; | ||
| } | ||
| else if (evt.data === "SILENT") { | ||
| this.emit("silent"); | ||
| this.isAvatarSpeaking = false; | ||
| } | ||
| else if (evt.data === "MISSING_SESSION_TOKEN") { | ||
| if (!this.session_token || this.session_token === "") { | ||
| const metadata = { | ||
| faceId: this.faceID, | ||
| isJPG: false, | ||
| apiKey: this.apiKey, | ||
| syncAudio: true, | ||
| handleSilence: this.handleSilence, | ||
| maxSessionLength: this.maxSessionLength, | ||
| maxIdleTime: this.maxIdleTime, | ||
| model: this.model, | ||
| }; | ||
| await this.sendSessionToken((await this.createSessionToken(this.SimliURL, metadata)) | ||
| .session_token); | ||
| } | ||
| else { | ||
| await this.sendSessionToken(this.session_token); | ||
| } | ||
| } | ||
| else { | ||
| const message = JSON.parse(evt.data); | ||
| if (message.livekit_url) { | ||
| const options = { adaptiveStream: true, dynacast: true }; | ||
| this.pc = new livekit_client_1.Room(options); | ||
| this.setupConnectionStateHandler(connectionSuccessResolve); | ||
| await this.pc.connect(message.livekit_url, message.livekit_token); | ||
| } | ||
| } | ||
| } | ||
| catch (e) { | ||
| if (this.enableConsoleLogs) | ||
| console.warn("SIMLI: Error processing WebSocket message:", e); | ||
| } | ||
| }); | ||
| ws.addEventListener("error", (error) => { | ||
| if (!this.videoReceived) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: WebSocket error:", error); | ||
| this.emit("disconnected"); | ||
| this.handleConnectionFailure("WebSocket error"); | ||
| } | ||
| else { | ||
| this.cleanup() | ||
| .then(() => this.start(this.retryAttempt)) | ||
| .catch((error) => { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Reconnection failed:", error); | ||
| if (this.retryAttempt >= this.MAX_RETRY_ATTEMPTS) { | ||
| this.emit("failed", "Reconnection failed"); | ||
| } | ||
| }); | ||
| } | ||
| }); | ||
| ws.addEventListener("close", () => { | ||
| if (this.enableConsoleLogs) | ||
| console.warn("SIMLI: WebSocket closed"); | ||
| this.emit("disconnected"); | ||
| }); | ||
| } | ||
| } | ||
| exports.SimliClient = SimliClient; |
| // src/index.ts | ||
| import { | ||
| ConnectionState, | ||
| RemoteParticipant, | ||
| RemoteTrack, | ||
| RemoteTrackPublication, | ||
| Room, | ||
| RoomEvent, | ||
| RoomOptions, | ||
| Track, | ||
| } from 'livekit-client'; | ||
| const AudioProcessor = ` | ||
| class AudioProcessor extends AudioWorkletProcessor { | ||
| constructor() { | ||
| super(); | ||
| this.buffer = new Int16Array(${3000}); | ||
| this.bufferIndex = 0; | ||
| } | ||
| process(inputs, outputs, parameters) { | ||
| const input = inputs[0]; | ||
| const inputChannel = input[0]; | ||
| if (inputChannel) { | ||
| for (let i = 0; i < inputChannel.length; i++) { | ||
| this.buffer[this.bufferIndex] = Math.max(-32768, Math.min(32767, Math.round(inputChannel[i] * 32767))); | ||
| this.bufferIndex++; | ||
| if (this.bufferIndex === this.buffer.length){ | ||
| this.port.postMessage({type: 'audioData', data: this.buffer.slice(0, this.bufferIndex)}); | ||
| this.bufferIndex = 0; | ||
| } | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
| } | ||
| registerProcessor('audio-processor', AudioProcessor); | ||
| `; | ||
| // Custom event handler types | ||
| type EventCallback = (...args: any[]) => void; | ||
| type EventMap = Map<string, Set<EventCallback>>; | ||
| interface SimliClientConfig { | ||
| apiKey: string | ""; | ||
| faceID: string; | ||
| handleSilence: boolean; | ||
| maxSessionLength: number; | ||
| maxIdleTime: number; | ||
| session_token: string | ""; | ||
| videoRef: HTMLVideoElement; | ||
| audioRef: HTMLAudioElement; | ||
| enableConsoleLogs?: boolean; | ||
| SimliURL: string | ""; | ||
| maxRetryAttempts: number | 100; | ||
| retryDelay_ms: number | 2000; | ||
| videoReceivedTimeout: number | 15000; | ||
| enableSFU: boolean | true; | ||
| model: "fasttalk" | "artalk" | ""; | ||
| } | ||
| interface SimliSessionRequest { | ||
| faceId: string; | ||
| isJPG: boolean; | ||
| apiKey: string; | ||
| syncAudio: boolean; | ||
| handleSilence: boolean; | ||
| maxSessionLength: number; | ||
| maxIdleTime: number; | ||
| model: "fasttalk" | "artalk"; | ||
| } | ||
| interface SimliSessionToken { | ||
| session_token: string; | ||
| } | ||
| interface SimliClientEvents { | ||
| connected: () => void; | ||
| disconnected: () => void; | ||
| failed: (reason: string) => void; | ||
| speaking: () => void; | ||
| silent: () => void; | ||
| } | ||
| class SimliClient { | ||
| private pc: Room | null = null; | ||
| private apiKey: string = ""; | ||
| private session_token: string = ""; | ||
| private faceID: string = ""; | ||
| private handleSilence: boolean = true; | ||
| private videoRef: HTMLVideoElement | null = null; | ||
| private audioRef: HTMLAudioElement | null = null; | ||
| private errorReason: string | null = null; | ||
| private sessionInitialized: boolean = false; | ||
| private inputStreamTrack: MediaStreamTrack | null = null; | ||
| private sourceNode: MediaStreamAudioSourceNode | null = null; | ||
| private audioWorklet: AudioWorkletNode | null = null; | ||
| private audioBuffer: Int16Array | null = null; | ||
| private maxSessionLength: number = 3600; | ||
| private maxIdleTime: number = 600; | ||
| private model: "fasttalk" | "artalk" = "fasttalk"; | ||
| private webSocket: WebSocket | null = null; | ||
| private lastSendTime: number = 0; | ||
| private MAX_RETRY_ATTEMPTS = 100; | ||
| private RETRY_DELAY = 2000; | ||
| private connectionTimeout: NodeJS.Timeout | null = null; | ||
| private readonly CONNECTION_TIMEOUT_MS = 15000; | ||
| public isAvatarSpeaking: boolean = false; | ||
| public enableConsoleLogs: boolean = false; | ||
| // Event handling | ||
| private events: EventMap = new Map(); | ||
| private retryAttempt: number = 1; | ||
| private inputIceServers: RTCIceServer[] = []; | ||
| private videoReceived: boolean = false; | ||
| public config: SimliClientConfig | null = null; | ||
| private SimliURL: string = ""; | ||
| private SimliWSURL: string | null = null; | ||
| private audioContext: AudioContext | null = null; | ||
| private start_stamp: number = 0; | ||
| // Type-safe event methods | ||
| public on<K extends keyof SimliClientEvents>( | ||
| event: K, | ||
| callback: SimliClientEvents[K] | ||
| ): void { | ||
| if (!this.events.has(event)) { | ||
| this.events.set(event, new Set()); | ||
| } | ||
| this.events.get(event)?.add(callback as EventCallback); | ||
| } | ||
| public off<K extends keyof SimliClientEvents>( | ||
| event: K, | ||
| callback: SimliClientEvents[K], | ||
| ): void { | ||
| this.events.get(event)?.delete(callback as EventCallback); | ||
| } | ||
| private emit<K extends keyof SimliClientEvents>( | ||
| event: K, | ||
| ...args: Parameters<SimliClientEvents[K]> | ||
| ): void { | ||
| this.events.get(event)?.forEach((callback) => { | ||
| callback(...args); | ||
| }); | ||
| } | ||
| public Initialize(config: SimliClientConfig) { | ||
| if ( | ||
| (!config.apiKey || config.apiKey === "") && | ||
| (!config.session_token || config.session_token === "") | ||
| ) { | ||
| console.error( | ||
| "SIMLI: apiKey or session_token is required in config" | ||
| ); | ||
| throw new Error("apiKey or session_token is required in config"); | ||
| } | ||
| this.config = config; | ||
| this.apiKey = config.apiKey; | ||
| this.faceID = config.faceID; | ||
| this.handleSilence = config.handleSilence; | ||
| this.maxSessionLength = config.maxSessionLength; | ||
| this.maxIdleTime = config.maxIdleTime; | ||
| this.enableConsoleLogs = config.enableConsoleLogs ?? false; | ||
| this.session_token = config.session_token; | ||
| this.MAX_RETRY_ATTEMPTS = | ||
| config.maxRetryAttempts ?? this.MAX_RETRY_ATTEMPTS; | ||
| this.RETRY_DELAY = config.retryDelay_ms ?? this.RETRY_DELAY; | ||
| if (config.model !== "") { | ||
| this.model = config.model; | ||
| } | ||
| if (!config.SimliURL || config.SimliURL === "") { | ||
| this.SimliURL = "https://api.simli.ai"; | ||
| } else { | ||
| this.SimliURL = config.SimliURL; | ||
| } | ||
| this.SimliWSURL = this.SimliURL.replace("http", "ws") | ||
| if (typeof window !== "undefined") { | ||
| this.videoRef = config.videoRef; | ||
| this.audioRef = config.audioRef; | ||
| if (!(this.videoRef instanceof HTMLVideoElement)) { | ||
| console.error( | ||
| "SIMLI: videoRef is required in config as HTMLVideoElement", | ||
| ); | ||
| } | ||
| if (!(this.audioRef instanceof HTMLAudioElement)) { | ||
| console.error( | ||
| "SIMLI: audioRef is required in config as HTMLAudioElement", | ||
| ); | ||
| } | ||
| console.log("SIMLI: simli-client@2.0.0 initialized"); | ||
| } else { | ||
| console.warn( | ||
| "SIMLI: Running in Node.js environment. Some features may not be available.", | ||
| ); | ||
| } | ||
| } | ||
| private setupConnectionStateHandler(connectionSuccessResolve: () => void) { | ||
| if (!this.pc) return; | ||
| this.pc.on(RoomEvent.Disconnected, () => { | ||
| if (this.videoReceived) { | ||
| this.emit("disconnected"); | ||
| this.handleDisconnection(); | ||
| } | ||
| }) | ||
| this.pc.on(RoomEvent.Connected, () => { | ||
| this.emit("connected"); | ||
| this.clearTimeouts() | ||
| }) | ||
| this.pc.on(RoomEvent.TrackSubscribed, (track: RemoteTrack, | ||
| publication: RemoteTrackPublication, | ||
| participant: RemoteParticipant, | ||
| ) => { | ||
| if (track.kind === Track.Kind.Video && this.videoRef) { | ||
| track.attach(this.videoRef) | ||
| this.videoRef.requestVideoFrameCallback(() => { | ||
| console.log("Connection Time:", new Date().getTime() - this.start_stamp) | ||
| connectionSuccessResolve() | ||
| }); | ||
| } else if (track.kind === Track.Kind.Audio && this.audioRef) { | ||
| track.attach(this.audioRef) | ||
| } | ||
| }) | ||
| }; | ||
| async start( | ||
| retryAttempt = 0, | ||
| ): Promise<void> { | ||
| try { | ||
| this.start_stamp = new Date().getTime() | ||
| await this.cleanup(); | ||
| if (this.config) { | ||
| this.Initialize(this.config); | ||
| } | ||
| this.clearTimeouts(); | ||
| // Set overall connection timeout | ||
| if (!this.session_token) { | ||
| const metadata = { | ||
| faceId: this.faceID, | ||
| isJPG: false, | ||
| apiKey: this.apiKey, | ||
| syncAudio: true, | ||
| handleSilence: this.handleSilence, | ||
| maxSessionLength: this.maxSessionLength, | ||
| maxIdleTime: this.maxIdleTime, | ||
| model: this.model, | ||
| }; | ||
| const sessionRunData = await this.createSessionToken(this.SimliURL, metadata); | ||
| this.session_token = sessionRunData.session_token; | ||
| } | ||
| const url = `${this.SimliWSURL}/StartWebRTCSessionLivekit`; | ||
| const ws = new WebSocket(url); | ||
| this.webSocket = ws; | ||
| const wsConnectPromise = new Promise<void>((resolve, reject) => { | ||
| if (this.webSocket) { | ||
| this.setupWebSocketListeners(this.webSocket, resolve); | ||
| } | ||
| this.connectionTimeout = setTimeout(() => { | ||
| this.handleConnectionTimeout(reject); | ||
| }, this.CONNECTION_TIMEOUT_MS); | ||
| }); | ||
| await Promise.race([ | ||
| wsConnectPromise, | ||
| this.connectionTimeout, | ||
| ]); | ||
| this.videoReceived = true; | ||
| console.log("CONNECTED"); | ||
| // Clear timeout if connection successful | ||
| this.clearTimeouts(); | ||
| } catch (error) { | ||
| if (this.enableConsoleLogs) | ||
| console.error( | ||
| `SIMLI: Connection attempt ${retryAttempt} failed:`, | ||
| error, | ||
| ); | ||
| this.clearTimeouts(); | ||
| if (this.retryAttempt < this.MAX_RETRY_ATTEMPTS) { | ||
| if (this.enableConsoleLogs) | ||
| console.log( | ||
| `SIMLI: Retrying connection... Attempt ${retryAttempt + 1}`, | ||
| ); | ||
| await new Promise((resolve) => setTimeout(resolve, this.RETRY_DELAY)); | ||
| this.retryAttempt += 1; | ||
| return this.start(this.retryAttempt); | ||
| } | ||
| this.emit( | ||
| "failed", | ||
| `Failed to connect after ${this.MAX_RETRY_ATTEMPTS} attempts`, | ||
| ); | ||
| throw error; | ||
| } | ||
| } | ||
| private sendPingMessage() { | ||
| if (this.webSocket && this.webSocket.readyState === this.webSocket.OPEN) { | ||
| const message = "ping " + Date.now(); | ||
| try { | ||
| this.webSocket?.send(message); | ||
| } catch (error) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Failed to send message:", error); | ||
| this.handleConnectionFailure("Failed to send ping message"); | ||
| } | ||
| } else { | ||
| if (this.enableConsoleLogs) | ||
| console.warn( | ||
| "SIMLI: WebSocket is not open. Current state:", | ||
| this.webSocket?.readyState, | ||
| ); | ||
| if (this.errorReason !== null) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Error Reason: ", this.errorReason); | ||
| } | ||
| } | ||
| } | ||
| public async createSessionToken( | ||
| SimliURL: string, | ||
| metadata: SimliSessionRequest, | ||
| ): Promise<SimliSessionToken> { | ||
| if (this.session_token && this.session_token !== "") { | ||
| return { session_token: this.session_token }; | ||
| } | ||
| try { | ||
| const url = `${SimliURL}/startAudioToVideoSession`; | ||
| const response = await fetch(url, { | ||
| method: "POST", | ||
| body: JSON.stringify(metadata), | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| }, | ||
| }); | ||
| if (!response.ok) { | ||
| const errorText = await response.text(); | ||
| throw new Error(`${errorText}`); | ||
| } | ||
| const resJSON = await response.json(); | ||
| return resJSON; | ||
| } catch (error) { | ||
| this.handleConnectionFailure(`Session initialization failed: ${error}`); | ||
| throw error; | ||
| } | ||
| } | ||
| private async sendSessionToken(sessionToken: string) { | ||
| try { | ||
| if (this.webSocket && this.webSocket.readyState === this.webSocket.OPEN) { | ||
| this.webSocket?.send(sessionToken); | ||
| } else { | ||
| throw new Error("WebSocket not open when trying to send session token"); | ||
| } | ||
| } catch (error) { | ||
| this.handleConnectionFailure(`Session initialization failed: ${error}`); | ||
| throw error; | ||
| } | ||
| } | ||
| private handleConnectionFailure(reason: string) { | ||
| this.errorReason = reason; | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: connection failure:", reason); | ||
| if (this.retryAttempt >= this.MAX_RETRY_ATTEMPTS) { | ||
| this.emit("failed", reason); | ||
| } | ||
| this.cleanup(); | ||
| } | ||
| private handleConnectionTimeout(reject: (reason: any) => void) { | ||
| this.handleConnectionFailure("Connection timed out"); | ||
| reject(new Error("Connection timed out")) | ||
| } | ||
| private handleDisconnection() { | ||
| if (!this.sessionInitialized) { | ||
| if (this.enableConsoleLogs) | ||
| console.log("SIMLI: Connection lost while being esablished, attempting to reconnect..."); | ||
| this.start(this.retryAttempt) | ||
| .catch((error) => { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Reconnection failed:", error); | ||
| if (this.retryAttempt >= this.MAX_RETRY_ATTEMPTS) { | ||
| this.emit("failed", "Reconnection failed"); | ||
| } | ||
| }); | ||
| } | ||
| } | ||
| private async cleanup() { | ||
| if (this.videoRef) this.videoRef.srcObject = null; | ||
| if (this.audioRef) this.audioRef.srcObject = null; | ||
| if (this.webSocket) { | ||
| this.webSocket.close(); | ||
| this.webSocket = null; | ||
| } | ||
| if (this.pc) { | ||
| await this.pc.disconnect(); | ||
| this.pc = null; | ||
| } | ||
| if (this.audioWorklet) { | ||
| this.audioWorklet.disconnect(); | ||
| this.audioWorklet = null; | ||
| } | ||
| if (this.sourceNode) { | ||
| this.sourceNode.disconnect(); | ||
| this.sourceNode = null; | ||
| } | ||
| this.sessionInitialized = false; | ||
| // Event handling | ||
| this.clearTimeouts(); | ||
| } | ||
| private clearTimeouts() { | ||
| if (this.connectionTimeout) { | ||
| clearTimeout(this.connectionTimeout); | ||
| this.connectionTimeout = null; | ||
| } | ||
| } | ||
| listenToMediastreamTrack(stream: MediaStreamTrack) { | ||
| try { | ||
| this.inputStreamTrack = stream; | ||
| this.audioContext = new (window.AudioContext || | ||
| (window as any).webkitAudioContext)({ | ||
| sampleRate: 16000, | ||
| }); | ||
| this.initializeAudioWorklet(this.audioContext, stream); | ||
| } catch (error) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Failed to initialize audio stream:", error); | ||
| if (this.retryAttempt >= this.MAX_RETRY_ATTEMPTS) { | ||
| this.emit("failed", "Audio initialization failed"); | ||
| } | ||
| } | ||
| } | ||
| private initializeAudioWorklet( | ||
| audioContext: AudioContext, | ||
| stream: MediaStreamTrack, | ||
| ) { | ||
| audioContext.audioWorklet | ||
| .addModule( | ||
| URL.createObjectURL( | ||
| new Blob([AudioProcessor], { | ||
| type: "application/javascript", | ||
| }), | ||
| ), | ||
| ) | ||
| .then(() => { | ||
| this.audioWorklet = new AudioWorkletNode( | ||
| audioContext, | ||
| "audio-processor", | ||
| ); | ||
| this.sourceNode = audioContext.createMediaStreamSource( | ||
| new MediaStream([stream]), | ||
| ); | ||
| if (this.audioWorklet === null) { | ||
| throw new Error("SIMLI: AudioWorklet not initialized"); | ||
| } | ||
| this.sourceNode.connect(this.audioWorklet); | ||
| this.audioWorklet.port.onmessage = (event) => { | ||
| if (event.data.type === "audioData") { | ||
| this.sendAudioData(new Uint8Array(event.data.data.buffer)); | ||
| } | ||
| }; | ||
| }) | ||
| .catch((error) => { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Failed to initialize AudioWorklet:", error); | ||
| if (this.retryAttempt >= this.MAX_RETRY_ATTEMPTS) { | ||
| this.emit("failed", "AudioWorklet initialization failed"); | ||
| } | ||
| }); | ||
| } | ||
| sendAudioData(audioData: Uint8Array) { | ||
| if (!this.sessionInitialized) { | ||
| if (this.enableConsoleLogs) | ||
| console.log("SIMLI: Session not initialized. Ignoring audio data."); | ||
| return; | ||
| } | ||
| if (this.webSocket?.readyState !== WebSocket.OPEN) { | ||
| if (this.enableConsoleLogs) | ||
| console.error( | ||
| "SIMLI: WebSocket is not open. Current state:", | ||
| this.webSocket?.readyState, | ||
| "Error Reason:", | ||
| this.errorReason, | ||
| ); | ||
| return; | ||
| } | ||
| try { | ||
| this.webSocket.send(audioData); | ||
| const currentTime = Date.now(); | ||
| if (this.lastSendTime !== 0) { | ||
| const timeBetweenSends = currentTime - this.lastSendTime; | ||
| if (timeBetweenSends > 100) { | ||
| // Log only if significant delay | ||
| if (this.enableConsoleLogs) | ||
| console.log("SIMLI: Time between sends:", timeBetweenSends); | ||
| } | ||
| } | ||
| this.lastSendTime = currentTime; | ||
| } catch (error) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Failed to send audio data:", error); | ||
| this.handleConnectionFailure("Failed to send audio data"); | ||
| } | ||
| } | ||
| sendAudioDataImmediate(audioData: Uint8Array) { | ||
| if (!this.sessionInitialized) { | ||
| if (this.enableConsoleLogs) | ||
| console.log("SIMLI: Session not initialized. Ignoring audio data."); | ||
| return; | ||
| } | ||
| if (this.webSocket?.readyState !== WebSocket.OPEN) { | ||
| if (this.enableConsoleLogs) | ||
| console.error( | ||
| "SIMLI: WebSocket is not open. Current state:", | ||
| this.webSocket?.readyState, | ||
| "Error Reason:", | ||
| this.errorReason, | ||
| ); | ||
| return; | ||
| } | ||
| try { | ||
| const asciiStr = "PLAY_IMMEDIATE"; | ||
| const encoder = new TextEncoder(); // Default is utf-8 | ||
| const strBytes = encoder.encode(asciiStr); // Uint8Array of " World!" | ||
| const buffer = new Uint8Array(strBytes.length + audioData.length); | ||
| buffer.set(strBytes, 0); | ||
| buffer.set(audioData, strBytes.length); | ||
| this.webSocket.send(buffer); | ||
| const currentTime = Date.now(); | ||
| if (this.lastSendTime !== 0) { | ||
| const timeBetweenSends = currentTime - this.lastSendTime; | ||
| if (timeBetweenSends > 100) { | ||
| // Log only if significant delay | ||
| if (this.enableConsoleLogs) | ||
| console.log("SIMLI: Time between sends:", timeBetweenSends); | ||
| } | ||
| } | ||
| this.lastSendTime = currentTime; | ||
| } catch (error) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Failed to send audio data:", error); | ||
| this.handleConnectionFailure("Failed to send audio data"); | ||
| } | ||
| } | ||
| close() { | ||
| if (this.enableConsoleLogs) | ||
| console.log("SIMLI: Closing SimliClient connection"); | ||
| if (this.webSocket) this.webSocket.send("DONE"); | ||
| this.emit("disconnected"); | ||
| try { | ||
| this.cleanup(); | ||
| } catch (error) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Error during cleanup:", error); | ||
| } | ||
| } | ||
| public ClearBuffer = () => { | ||
| if (this.webSocket?.readyState === WebSocket.OPEN) { | ||
| try { | ||
| this.webSocket.send("SKIP"); | ||
| } catch (error) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Failed to clear buffer:", error); | ||
| } | ||
| } else { | ||
| if (this.enableConsoleLogs) | ||
| console.warn("SIMLI: Cannot clear buffer: WebSocket not open"); | ||
| } | ||
| }; | ||
| // Utility method to check connection status | ||
| public isConnected(): boolean { | ||
| return ( | ||
| this.sessionInitialized && | ||
| this.webSocket?.readyState === WebSocket.OPEN && | ||
| this.pc?.state === ConnectionState.Connected | ||
| ); | ||
| } | ||
| // Method to get current connection status details | ||
| public getConnectionStatus(): { | ||
| sessionInitialized: boolean; | ||
| webSocketState: number | null; | ||
| peerConnectionState: ConnectionState | null; | ||
| errorReason: string | null; | ||
| } { | ||
| return { | ||
| sessionInitialized: this.sessionInitialized, | ||
| webSocketState: this.webSocket?.readyState ?? null, | ||
| peerConnectionState: this.pc?.state ?? null, | ||
| errorReason: this.errorReason, | ||
| }; | ||
| } | ||
| private setupWebSocketListeners(ws: WebSocket, connectionSuccessResolve: () => void) { | ||
| ws.addEventListener("open", async () => { | ||
| connectionSuccessResolve(); | ||
| if (!this.session_token || this.session_token === "") { | ||
| const metadata = { | ||
| faceId: this.faceID, | ||
| isJPG: false, | ||
| apiKey: this.apiKey, | ||
| syncAudio: true, | ||
| handleSilence: this.handleSilence, | ||
| maxSessionLength: this.maxSessionLength, | ||
| maxIdleTime: this.maxIdleTime, | ||
| model: this.model, | ||
| }; | ||
| await this.sendSessionToken( | ||
| (await this.createSessionToken(this.SimliURL, metadata)) | ||
| .session_token, | ||
| ); | ||
| } else { | ||
| await this.sendSessionToken(this.session_token); | ||
| } | ||
| }); | ||
| ws.addEventListener("message", async (evt) => { | ||
| if (this.enableConsoleLogs) | ||
| console.log("SIMLI: Received message: ", evt.data); | ||
| try { | ||
| if (evt.data === "START") { | ||
| this.sessionInitialized = true; | ||
| this.sendAudioData(new Uint8Array(6000)); | ||
| this.emit("connected"); | ||
| console.log("START"); | ||
| } else if (evt.data === "STOP") { | ||
| this.close(); | ||
| } else if (evt.data.startsWith("pong") || evt.data === "ACK") { | ||
| // if (this.enableConsoleLogs) console.log("SIMLI: Received ACK"); | ||
| } else if (evt.data === "SPEAK") { | ||
| this.emit("speaking"); | ||
| this.isAvatarSpeaking = true; | ||
| } else if (evt.data === "SILENT") { | ||
| this.emit("silent"); | ||
| this.isAvatarSpeaking = false; | ||
| } else if (evt.data === "MISSING_SESSION_TOKEN") { | ||
| if (!this.session_token || this.session_token === "") { | ||
| const metadata = { | ||
| faceId: this.faceID, | ||
| isJPG: false, | ||
| apiKey: this.apiKey, | ||
| syncAudio: true, | ||
| handleSilence: this.handleSilence, | ||
| maxSessionLength: this.maxSessionLength, | ||
| maxIdleTime: this.maxIdleTime, | ||
| model: this.model, | ||
| }; | ||
| await this.sendSessionToken( | ||
| (await this.createSessionToken(this.SimliURL, metadata)) | ||
| .session_token, | ||
| ); | ||
| } else { | ||
| await this.sendSessionToken(this.session_token); | ||
| } | ||
| } else { | ||
| const message = JSON.parse(evt.data); | ||
| if (message.livekit_url) { | ||
| const options: RoomOptions = { adaptiveStream: true, dynacast: true } | ||
| this.pc = new Room(options); | ||
| this.setupConnectionStateHandler(connectionSuccessResolve); | ||
| await this.pc.connect(message.livekit_url, message.livekit_token); | ||
| } | ||
| } | ||
| } catch (e) { | ||
| if (this.enableConsoleLogs) | ||
| console.warn("SIMLI: Error processing WebSocket message:", e); | ||
| } | ||
| }); | ||
| ws.addEventListener("error", (error) => { | ||
| if (!this.videoReceived) { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: WebSocket error:", error); | ||
| this.emit("disconnected"); | ||
| this.handleConnectionFailure("WebSocket error"); | ||
| } else { | ||
| this.cleanup() | ||
| .then(() => this.start(this.retryAttempt)) | ||
| .catch((error) => { | ||
| if (this.enableConsoleLogs) | ||
| console.error("SIMLI: Reconnection failed:", error); | ||
| if (this.retryAttempt >= this.MAX_RETRY_ATTEMPTS) { | ||
| this.emit("failed", "Reconnection failed"); | ||
| } | ||
| }); | ||
| } | ||
| }); | ||
| ws.addEventListener("close", () => { | ||
| if (this.enableConsoleLogs) console.warn("SIMLI: WebSocket closed"); | ||
| this.emit("disconnected"); | ||
| }); | ||
| } | ||
| } | ||
| export { SimliClient, SimliClientConfig, SimliClientEvents }; |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
74828
32.04%33
266.67%1827
34.14%5
150%