UNPKG

@sayna-ai/js-sdk

Version:

Browser SDK for connecting Sayna clients to real-time voice rooms.

230 lines 8.39 kB
import { ConnectionState, Room, RoomEvent } from "livekit-client"; /** * SaynaClient wraps LiveKit client logic for browser usage. */ export class SaynaClient { constructor(options) { var _a, _b; this.options = options; this.room = null; this.isConnecting = false; this.createdAudioElement = false; this.attachedAudioTracks = new Set(); this.handleTrackSubscribed = (track, publication, participant) => { if (!this.enableAudioPlayback || track.kind !== "audio") { return; } const element = this.ensureAudioElement(); if (!element) { return; } track.attach(element); this.attachedAudioTracks.add(track); }; this.handleTrackUnsubscribed = (track) => { if (track.kind === "audio" && this.attachedAudioTracks.has(track)) { track.detach(); this.attachedAudioTracks.delete(track); } }; this.handleRoomDisconnected = () => { this.detachAllTracks(); this.room = null; this.isConnecting = false; }; if (!options.tokenUrl && !options.tokenFetchHandler) { throw new Error("SaynaClient requires a tokenUrl or tokenFetchHandler"); } this.enableAudioPlayback = (_a = options.enableAudioPlayback) !== null && _a !== void 0 ? _a : true; this.audioElement = (_b = options.audioElement) !== null && _b !== void 0 ? _b : null; } /** * Returns the underlying LiveKit room instance if connected. */ get currentRoom() { return this.room; } /** * Returns true when the client is connected to LiveKit. */ get isConnected() { var _a; return ((_a = this.room) === null || _a === void 0 ? void 0 : _a.state) === ConnectionState.Connected; } /** * Returns the HTMLAudioElement used for remote playback, if any. */ get playbackElement() { return this.audioElement; } /** * Fetches a token (if needed), connects to LiveKit and resolves to the Room instance. */ async connect() { this.assertBrowserEnvironment(); if (this.isConnecting) { throw new Error("SaynaClient: connect() is already in progress."); } if (this.isConnected) { throw new Error("SaynaClient: already connected to a room."); } this.isConnecting = true; const room = new Room({ adaptiveStream: true, dynacast: true, }); this.room = room; room.on(RoomEvent.TrackSubscribed, this.handleTrackSubscribed); room.on(RoomEvent.TrackUnsubscribed, this.handleTrackUnsubscribed); room.on(RoomEvent.Disconnected, this.handleRoomDisconnected); try { const tokenResponse = await this.resolveToken(); if (this.enableAudioPlayback) { this.ensureAudioElement(); } await room.connect(tokenResponse.liveUrl .replace("http://", "ws://") .replace("https://", "wss://"), tokenResponse.token, { autoSubscribe: true, }); return room; } catch (error) { await this.safeDisconnect(room); this.detachAllTracks(); this.room = null; throw error; } finally { this.isConnecting = false; } } /** * Enables the microphone and publishes audio track to the room. */ async publishMicrophone(options) { const room = this.requireConnectedRoom("publishMicrophone"); await room.localParticipant.setMicrophoneEnabled(true, options); } /** * Disconnects from the room and cleans up local resources. */ async disconnect() { if (!this.room) { return; } const room = this.room; this.room = null; room.off(RoomEvent.TrackSubscribed, this.handleTrackSubscribed); room.off(RoomEvent.TrackUnsubscribed, this.handleTrackUnsubscribed); room.off(RoomEvent.Disconnected, this.handleRoomDisconnected); this.detachAllTracks(); if (room.state !== ConnectionState.Disconnected) { await room.disconnect(); } } async resolveToken() { let tokenResponse; if (typeof this.options.tokenFetchHandler === "function") { tokenResponse = await this.options.tokenFetchHandler(); } else if (this.options.tokenUrl) { const requestUrl = this.toAbsoluteUrl(this.options.tokenUrl); const response = await fetch(requestUrl.toString(), { method: "GET" }); if (!response.ok) { const errorData = await response.json().catch(() => ({ error: response.statusText, })); throw new Error(errorData.error || `Request failed: ${response.status} ${response.statusText}`); } tokenResponse = (await response.json()); } else { throw new Error("SaynaClient: tokenUrl or tokenFetchHandler is required."); } if (!tokenResponse || typeof tokenResponse !== "object") { throw new Error("SaynaClient: token response is not a valid object."); } if (!("token" in tokenResponse) || typeof tokenResponse.token !== "string") { throw new Error("SaynaClient: token response is missing a token string."); } if (!("liveUrl" in tokenResponse) || typeof tokenResponse.liveUrl !== "string") { throw new Error("SaynaClient: token response is missing a liveUrl string."); } return tokenResponse; } ensureAudioElement() { if (!this.enableAudioPlayback) { return null; } if (!this.audioElement) { if (typeof document !== "undefined" && document.createElement) { this.audioElement = document.createElement("audio"); this.audioElement.autoplay = true; this.createdAudioElement = true; } else if (typeof Audio !== "undefined") { this.audioElement = new Audio(); this.audioElement.autoplay = true; this.createdAudioElement = true; } else { return null; } } return this.audioElement; } detachAllTracks() { for (const track of this.attachedAudioTracks) { track.detach(); } this.attachedAudioTracks.clear(); if (this.createdAudioElement && this.audioElement) { this.audioElement.srcObject = null; } } async safeDisconnect(room) { try { if (room.state !== ConnectionState.Disconnected) { await room.disconnect(); } } catch { // Ignore disconnect errors during cleanup } finally { room.off(RoomEvent.TrackSubscribed, this.handleTrackSubscribed); room.off(RoomEvent.TrackUnsubscribed, this.handleTrackUnsubscribed); room.off(RoomEvent.Disconnected, this.handleRoomDisconnected); } } requireConnectedRoom(methodName) { if (!this.room || this.room.state !== ConnectionState.Connected) { throw new Error(`SaynaClient: cannot call ${methodName} before connect().`); } return this.room; } toAbsoluteUrl(tokenUrl) { if (tokenUrl instanceof URL) { return new URL(tokenUrl.toString()); } try { return new URL(tokenUrl); } catch { if (typeof window === "undefined" || !window.location) { throw new Error("SaynaClient: relative tokenUrl requires a browser environment."); } return new URL(tokenUrl, window.location.href); } } assertBrowserEnvironment() { if (typeof window === "undefined" || typeof document === "undefined") { throw new Error("SaynaClient runs in browser environments only."); } } } //# sourceMappingURL=index.js.map