Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

simli-client

Package Overview
Dependencies
Maintainers
2
Versions
42
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

simli-client - npm Package Compare versions

Comparing version
2.0.0
to
3.0.0
+60
dist/Client.d.ts
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 };
"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;
// 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';
"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; } });

@@ -1,1 +0,7 @@

export {SimliClient, SimliClientConfig, SimliClientEvents} from './SimliClient';
export {
SimliClient, generateSimliSessionToken, generateIceServers, LogLevel
} from './Client';
export type {
SimliSessionRequest,
} from './Client';
{
"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 };