@treyorr/voca-client 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface Peer {
24
24
  connection: RTCPeerConnection;
25
25
  audioLevel: number;
26
26
  stream?: MediaStream;
27
+ remoteMuted?: boolean;
28
+ localMuted?: boolean;
27
29
  }
28
30
  interface VocaEvents {
29
31
  'status': (status: ConnectionStatus) => void;
@@ -37,6 +39,8 @@ interface VocaEvents {
37
39
  'peer-audio-level': (peerId: string, level: number) => void;
38
40
  'local-audio-level': (level: number) => void;
39
41
  'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
42
+ 'peer-mute': (peerId: string, isMuted: boolean) => void;
43
+ 'peer-local-mute': (peerId: string, isMuted: boolean) => void;
40
44
  }
41
45
  /**
42
46
  * Validate password format for room creation.
@@ -88,6 +92,7 @@ export declare class VocaClient {
88
92
  connect(): Promise<void>;
89
93
  disconnect(): void;
90
94
  toggleMute(): boolean;
95
+ togglePeerMute(peerId: string): boolean;
91
96
  private setupMediaAndAudio;
92
97
  private setupAudioAnalysis;
93
98
  /**
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- var X=()=>({emit(h,...j){for(let q=this.events[h]||[],z=0,B=q.length;z<B;z++)q[z](...j)},events:{},on(h,j){return(this.events[h]||=[]).push(j),()=>{this.events[h]=this.events[h]?.filter((q)=>j!==q)}}});var G={ROOM_NOT_FOUND:"room_not_found",ROOM_FULL:"room_full",MAX_ROOMS_REACHED:"max_rooms_reached",INVALID_ROOM_ID:"invalid_room_id",CONNECTION_FAILED:"connection_failed",WEBSOCKET_ERROR:"websocket_error",HEARTBEAT_TIMEOUT:"heartbeat_timeout",MICROPHONE_NOT_FOUND:"microphone_not_found",MICROPHONE_PERMISSION_DENIED:"microphone_permission_denied",INSECURE_CONTEXT:"insecure_context",INVALID_MESSAGE:"invalid_message",PEER_NOT_FOUND:"peer_not_found",INVALID_PASSWORD:"invalid_password",PASSWORD_REQUIRED:"password_required"},N={[G.ROOM_NOT_FOUND]:"Room not found",[G.ROOM_FULL]:"Room is at maximum capacity",[G.MAX_ROOMS_REACHED]:"Maximum number of rooms reached",[G.INVALID_ROOM_ID]:"Invalid room ID format",[G.CONNECTION_FAILED]:"Failed to connect to signaling server",[G.WEBSOCKET_ERROR]:"WebSocket connection error",[G.HEARTBEAT_TIMEOUT]:"Connection lost due to heartbeat timeout",[G.MICROPHONE_NOT_FOUND]:"No microphone found. Please connect a microphone and try again.",[G.MICROPHONE_PERMISSION_DENIED]:"Microphone permission denied. Please allow microphone access.",[G.INSECURE_CONTEXT]:"HTTPS is required for microphone access",[G.INVALID_MESSAGE]:"Invalid signaling message received",[G.PEER_NOT_FOUND]:"Peer not found in room",[G.INVALID_PASSWORD]:"Incorrect password",[G.PASSWORD_REQUIRED]:"This room requires a password"};function Z(h,j){return{code:h,message:j??N[h]}}function D(h){if(!h)return null;if(h.length<4||h.length>12)return"Password must be 4-12 characters";if(!/^[a-zA-Z0-9]+$/.test(h))return"Password must contain only letters and numbers";return null}var $=[{urls:"stun:stun.l.google.com:19302"},{urls:"stun:stun1.l.google.com:19302"}];class P{peers=new Map;localStream=null;isMuted=!1;status="connecting";roomId;events=X();ws=null;audioContext=null;analyser=null;animationFrame=null;iceServers=$;config;reconnectAttempts=0;reconnectTimeout=null;shouldReconnect=!0;peerAnalysers=new Map;static async createRoom(h={}){let j=P.getHttpUrl(h.serverUrl);if(!j)throw Error("VocaConfig.serverUrl is required in non-browser environments");let q={"Content-Type":"application/json"};if(h.apiKey)q["x-api-key"]=h.apiKey;let z=`${j}/api/room`,B=new URLSearchParams;if(h.password)B.append("password",h.password);if(B.toString())z+=`?${B.toString()}`;let H=await fetch(z,{method:"POST",headers:q});if(!H.ok){let W=await H.json().catch(()=>({error:"unknown",message:"Failed to create room"}));throw Error(W.message||"Failed to create room")}let{room:J,password:O}=await H.json(),K={...h,password:O||h.password};return new P(J,K)}static getHttpUrl(h){if(!h){if(typeof window<"u")return`${window.location.protocol}//${window.location.host}`;return""}if(h.startsWith("wss://"))return h.replace("wss://","https://");if(h.startsWith("ws://"))return h.replace("ws://","http://");return h}static getWsUrl(h){if(!h){if(typeof window<"u")return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}`;throw Error("VocaConfig.serverUrl is required in non-browser environments")}if(h.startsWith("https://"))return h.replace("https://","wss://");if(h.startsWith("http://"))return h.replace("http://","ws://");return h}constructor(h,j={}){if(this.roomId=h,this.config=j,j.iceServers)this.iceServers=j.iceServers}on(h,j){return this.events.on(h,j)}async connect(){this.status="connecting",this.events.emit("status","connecting");try{await this.setupMediaAndAudio(),this.connectSocket()}catch(h){throw this.handleError(G.CONNECTION_FAILED,h instanceof Error?h.message:"Failed to connect"),h}}disconnect(){if(this.shouldReconnect=!1,this.reconnectTimeout)clearTimeout(this.reconnectTimeout),this.reconnectTimeout=null;if(this.animationFrame)cancelAnimationFrame(this.animationFrame);if(this.peers.forEach((h)=>h.connection.close()),this.peers.clear(),this.ws?.close(),this.localStream?.getTracks().forEach((h)=>h.stop()),this.audioContext&&this.audioContext.state!=="closed")this.audioContext.close().catch((h)=>console.warn("Error closing AudioContext",h));this.status="disconnected",this.events.emit("status","disconnected")}toggleMute(){let h=this.localStream?.getAudioTracks()[0];if(h)h.enabled=!h.enabled,this.isMuted=!h.enabled;return this.isMuted}async setupMediaAndAudio(){if(typeof window<"u"&&!window.isSecureContext)throw Error(N[G.INSECURE_CONTEXT]);try{this.localStream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0,latency:0,channelCount:1},video:!1})}catch(h){try{this.localStream=await navigator.mediaDevices.getUserMedia({audio:!0,video:!1})}catch(j){if(j.name==="NotFoundError"||j.message.includes("Requested device not found"))throw Error(N[G.MICROPHONE_NOT_FOUND]);if(j.name==="NotAllowedError"||j.message.includes("Permission denied"))throw Error(N[G.MICROPHONE_PERMISSION_DENIED]);throw j}}this.setupAudioAnalysis(this.localStream)}setupAudioAnalysis(h){let j=window.AudioContext||window.webkitAudioContext;this.audioContext=new j;let q=this.audioContext.createMediaStreamSource(h);this.analyser=this.audioContext.createAnalyser(),this.analyser.fftSize=256,q.connect(this.analyser);let z=new Uint8Array(this.analyser.frequencyBinCount),B=()=>{if(!this.analyser)return;this.analyser.getByteFrequencyData(z);let H=z.reduce((O,K)=>O+K,0)/z.length,J=Math.min(H/128,1);this.events.emit("local-audio-level",J),this.animationFrame=requestAnimationFrame(B)};B()}getSocketUrl(){let j=`${P.getWsUrl(this.config.serverUrl)}/ws/${this.roomId}`,q=new URLSearchParams;if(this.config.apiKey)q.append("apiKey",this.config.apiKey);if(this.config.password)q.append("password",this.config.password);if(q.toString())j+=`?${q.toString()}`;return j}connectSocket(){let h=this.getSocketUrl();this.ws=new WebSocket(h),this.ws.onopen=()=>{this.send({type:"hello",version:"0.3.0",client:"@treyorr/voca-client"}),this.status="connected",this.events.emit("status","connected"),this.reconnectAttempts=0},this.ws.onclose=()=>{if(this.status==="full"||this.status==="error")return;let j=this.config.reconnect?.enabled!==!1,q=this.config.reconnect?.maxAttempts??5,z=this.config.reconnect?.baseDelayMs??1000;if(j&&this.shouldReconnect&&this.reconnectAttempts<q){this.status="reconnecting",this.events.emit("status","reconnecting");let B=Math.min(z*Math.pow(2,this.reconnectAttempts),30000);this.reconnectAttempts++,this.reconnectTimeout=setTimeout(()=>{this.connectSocket()},B)}else this.status="disconnected",this.events.emit("status","disconnected")},this.ws.onerror=()=>this.handleError(G.WEBSOCKET_ERROR,"WebSocket connection failed"),this.ws.onmessage=(j)=>{let q=JSON.parse(j.data);this.handleSignal(q)}}async handleSignal(h){switch(h.type){case"welcome":console.debug("[Voca] Protocol version:",h.version,"Peer ID:",h.peer_id);break;case"join":await this.createPeer(h.from,!0);break;case"offer":await this.createPeer(h.from,!1,h.sdp);break;case"answer":let j=this.peers.get(h.from);if(j)await j.connection.setRemoteDescription({type:"answer",sdp:h.sdp});break;case"ice":let q=this.peers.get(h.from);if(q)try{await q.connection.addIceCandidate(JSON.parse(h.candidate))}catch(z){console.warn("[Voca] Invalid ICE candidate:",z)}break;case"ping":this.send({type:"pong"});break;case"leave":this.removePeer(h.from);break;case"error":this.handleError(h.code??"unknown",h.message??"Unknown error");break}}async createPeer(h,j,q){let z=new RTCPeerConnection({iceServers:this.iceServers});if(z.onicecandidate=(B)=>{if(B.candidate)this.send({type:"ice",to:h,candidate:JSON.stringify(B.candidate)})},z.ontrack=(B)=>{this.events.emit("track",h,B.track,B.streams[0]),this.setupRemoteAudio(h,B.streams[0]);let H=this.peers.get(h);if(H)H.stream=B.streams[0]},this.localStream?.getTracks().forEach((B)=>z.addTrack(B,this.localStream)),this.peers.set(h,{id:h,connection:z,audioLevel:0}),this.events.emit("peer-joined",h),j){let B=await z.createOffer();await z.setLocalDescription(B),this.send({type:"offer",to:h,sdp:B.sdp})}else if(q){await z.setRemoteDescription({type:"offer",sdp:q});let B=await z.createAnswer();await z.setLocalDescription(B),this.send({type:"answer",to:h,sdp:B.sdp})}}removePeer(h){this.peers.get(h)?.connection.close(),this.peers.delete(h);let q=this.peerAnalysers.get(h);if(q)q.source.disconnect(),q.analyser.disconnect(),this.peerAnalysers.delete(h);this.events.emit("peer-left",h)}setupRemoteAudio(h,j){if(!this.audioContext){let J=window.AudioContext||window.webkitAudioContext;this.audioContext=new J}let q=this.audioContext.createMediaStreamSource(j),z=this.audioContext.createAnalyser();z.fftSize=256,q.connect(z),q.connect(this.audioContext.destination),this.peerAnalysers.set(h,{source:q,analyser:z});let B=new Uint8Array(z.frequencyBinCount),H=()=>{let J=this.peers.get(h);if(!J)return;z.getByteFrequencyData(B);let O=B.reduce((W,Y)=>W+Y,0)/B.length,K=Math.min(O/128,1);this.events.emit("peer-audio-level",h,K),J.audioLevel=K,requestAnimationFrame(H)};H()}send(h){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return;this.ws.send(JSON.stringify({from:"",...h}))}handleError(h,j){this.status=h===G.ROOM_FULL||h==="room_full"?"full":"error",this.events.emit("status",this.status),this.events.emit("error",{code:h,message:j})}}export{D as validatePassword,Z as createVocaError,N as VocaErrorMessages,G as VocaErrorCode,P as VocaClient};
1
+ var $=()=>({emit(h,...j){for(let B=this.events[h]||[],q=0,z=B.length;q<z;q++)B[q](...j)},events:{},on(h,j){return(this.events[h]||=[]).push(j),()=>{this.events[h]=this.events[h]?.filter((B)=>j!==B)}}});var G={ROOM_NOT_FOUND:"room_not_found",ROOM_FULL:"room_full",MAX_ROOMS_REACHED:"max_rooms_reached",INVALID_ROOM_ID:"invalid_room_id",CONNECTION_FAILED:"connection_failed",WEBSOCKET_ERROR:"websocket_error",HEARTBEAT_TIMEOUT:"heartbeat_timeout",MICROPHONE_NOT_FOUND:"microphone_not_found",MICROPHONE_PERMISSION_DENIED:"microphone_permission_denied",INSECURE_CONTEXT:"insecure_context",INVALID_MESSAGE:"invalid_message",PEER_NOT_FOUND:"peer_not_found",INVALID_PASSWORD:"invalid_password",PASSWORD_REQUIRED:"password_required"},W={[G.ROOM_NOT_FOUND]:"Room not found",[G.ROOM_FULL]:"Room is at maximum capacity",[G.MAX_ROOMS_REACHED]:"Maximum number of rooms reached",[G.INVALID_ROOM_ID]:"Invalid room ID format",[G.CONNECTION_FAILED]:"Failed to connect to signaling server",[G.WEBSOCKET_ERROR]:"WebSocket connection error",[G.HEARTBEAT_TIMEOUT]:"Connection lost due to heartbeat timeout",[G.MICROPHONE_NOT_FOUND]:"No microphone found. Please connect a microphone and try again.",[G.MICROPHONE_PERMISSION_DENIED]:"Microphone permission denied. Please allow microphone access.",[G.INSECURE_CONTEXT]:"HTTPS is required for microphone access",[G.INVALID_MESSAGE]:"Invalid signaling message received",[G.PEER_NOT_FOUND]:"Peer not found in room",[G.INVALID_PASSWORD]:"Incorrect password",[G.PASSWORD_REQUIRED]:"This room requires a password"};function Q(h,j){return{code:h,message:j??W[h]}}function N(h){if(!h)return null;if(h.length<4||h.length>12)return"Password must be 4-12 characters";if(!/^[a-zA-Z0-9]+$/.test(h))return"Password must contain only letters and numbers";return null}var T=[{urls:"stun:stun.l.google.com:19302"},{urls:"stun:stun1.l.google.com:19302"}];class X{peers=new Map;localStream=null;isMuted=!1;status="connecting";roomId;events=$();ws=null;audioContext=null;analyser=null;animationFrame=null;iceServers=T;config;reconnectAttempts=0;reconnectTimeout=null;shouldReconnect=!0;peerAnalysers=new Map;static async createRoom(h={}){let j=X.getHttpUrl(h.serverUrl);if(!j)throw Error("VocaConfig.serverUrl is required in non-browser environments");let B={"Content-Type":"application/json"};if(h.apiKey)B["x-api-key"]=h.apiKey;let q=`${j}/api/room`,z=new URLSearchParams;if(h.password)z.append("password",h.password);if(z.toString())q+=`?${z.toString()}`;let H=await fetch(q,{method:"POST",headers:B});if(!H.ok){let Y=await H.json().catch(()=>({error:"unknown",message:"Failed to create room"}));throw Error(Y.message||"Failed to create room")}let{room:K,password:O}=await H.json(),J={...h,password:O||h.password};return new X(K,J)}static getHttpUrl(h){if(!h){if(typeof window<"u")return`${window.location.protocol}//${window.location.host}`;return""}if(h.startsWith("wss://"))return h.replace("wss://","https://");if(h.startsWith("ws://"))return h.replace("ws://","http://");return h}static getWsUrl(h){if(!h){if(typeof window<"u")return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}`;throw Error("VocaConfig.serverUrl is required in non-browser environments")}if(h.startsWith("https://"))return h.replace("https://","wss://");if(h.startsWith("http://"))return h.replace("http://","ws://");return h}constructor(h,j={}){if(this.roomId=h,this.config=j,j.iceServers)this.iceServers=j.iceServers}on(h,j){return this.events.on(h,j)}async connect(){this.status="connecting",this.events.emit("status","connecting");try{await this.setupMediaAndAudio(),this.connectSocket()}catch(h){throw this.handleError(G.CONNECTION_FAILED,h instanceof Error?h.message:"Failed to connect"),h}}disconnect(){if(this.shouldReconnect=!1,this.reconnectTimeout)clearTimeout(this.reconnectTimeout),this.reconnectTimeout=null;if(this.animationFrame)cancelAnimationFrame(this.animationFrame);if(this.peers.forEach((h)=>h.connection.close()),this.peers.clear(),this.ws?.close(),this.localStream?.getTracks().forEach((h)=>h.stop()),this.audioContext&&this.audioContext.state!=="closed")this.audioContext.close().catch((h)=>console.warn("Error closing AudioContext",h));this.status="disconnected",this.events.emit("status","disconnected")}toggleMute(){let h=this.localStream?.getAudioTracks()[0];if(h)h.enabled=!h.enabled,this.isMuted=!h.enabled,this.send({type:"mute",muted:this.isMuted});return this.isMuted}togglePeerMute(h){let j=this.peers.get(h);if(!j)return!1;let q=!(j.localMuted??!1);j.localMuted=q;let z=this.peerAnalysers.get(h);if(z)z.gainNode.gain.value=q?0:1;return this.events.emit("peer-local-mute",h,q),q}async setupMediaAndAudio(){if(typeof window<"u"&&!window.isSecureContext)throw Error(W[G.INSECURE_CONTEXT]);try{this.localStream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0,latency:0,channelCount:1},video:!1})}catch(h){try{this.localStream=await navigator.mediaDevices.getUserMedia({audio:!0,video:!1})}catch(j){if(j.name==="NotFoundError"||j.message.includes("Requested device not found"))throw Error(W[G.MICROPHONE_NOT_FOUND]);if(j.name==="NotAllowedError"||j.message.includes("Permission denied"))throw Error(W[G.MICROPHONE_PERMISSION_DENIED]);throw j}}this.setupAudioAnalysis(this.localStream)}setupAudioAnalysis(h){let j=window.AudioContext||window.webkitAudioContext;this.audioContext=new j;let B=this.audioContext.createMediaStreamSource(h);this.analyser=this.audioContext.createAnalyser(),this.analyser.fftSize=256,B.connect(this.analyser);let q=new Uint8Array(this.analyser.frequencyBinCount),z=()=>{if(!this.analyser)return;this.analyser.getByteFrequencyData(q);let H=q.reduce((O,J)=>O+J,0)/q.length,K=Math.min(H/128,1);this.events.emit("local-audio-level",K),this.animationFrame=requestAnimationFrame(z)};z()}getSocketUrl(){let j=`${X.getWsUrl(this.config.serverUrl)}/ws/${this.roomId}`,B=new URLSearchParams;if(this.config.apiKey)B.append("apiKey",this.config.apiKey);if(this.config.password)B.append("password",this.config.password);if(B.toString())j+=`?${B.toString()}`;return j}connectSocket(){let h=this.getSocketUrl();this.ws=new WebSocket(h),this.ws.onopen=()=>{this.send({type:"hello",version:"0.3.0",client:"@treyorr/voca-client"}),this.status="connected",this.events.emit("status","connected"),this.reconnectAttempts=0},this.ws.onclose=()=>{if(this.status==="full"||this.status==="error")return;let j=this.config.reconnect?.enabled!==!1,B=this.config.reconnect?.maxAttempts??5,q=this.config.reconnect?.baseDelayMs??1000;if(j&&this.shouldReconnect&&this.reconnectAttempts<B){this.status="reconnecting",this.events.emit("status","reconnecting");let z=Math.min(q*Math.pow(2,this.reconnectAttempts),30000);this.reconnectAttempts++,this.reconnectTimeout=setTimeout(()=>{this.connectSocket()},z)}else this.status="disconnected",this.events.emit("status","disconnected")},this.ws.onerror=()=>this.handleError(G.WEBSOCKET_ERROR,"WebSocket connection failed"),this.ws.onmessage=(j)=>{let B=JSON.parse(j.data);this.handleSignal(B)}}async handleSignal(h){switch(h.type){case"welcome":console.debug("[Voca] Protocol version:",h.version,"Peer ID:",h.peer_id);break;case"join":if(await this.createPeer(h.from,!0),this.isMuted)this.send({type:"mute",to:h.from,muted:!0});break;case"offer":await this.createPeer(h.from,!1,h.sdp);break;case"answer":let j=this.peers.get(h.from);if(j)await j.connection.setRemoteDescription({type:"answer",sdp:h.sdp});break;case"ice":let B=this.peers.get(h.from);if(B)try{await B.connection.addIceCandidate(JSON.parse(h.candidate))}catch(z){console.warn("[Voca] Invalid ICE candidate:",z)}break;case"ping":this.send({type:"pong"});break;case"leave":this.removePeer(h.from);break;case"mute":let q=this.peers.get(h.from);if(q)q.remoteMuted=h.muted??!1,this.events.emit("peer-mute",h.from,q.remoteMuted);break;case"error":this.handleError(h.code??"unknown",h.message??"Unknown error");break}}async createPeer(h,j,B){let q=new RTCPeerConnection({iceServers:this.iceServers});if(q.onicecandidate=(z)=>{if(z.candidate)this.send({type:"ice",to:h,candidate:JSON.stringify(z.candidate)})},q.ontrack=(z)=>{this.events.emit("track",h,z.track,z.streams[0]),this.setupRemoteAudio(h,z.streams[0]);let H=this.peers.get(h);if(H)H.stream=z.streams[0]},this.localStream?.getTracks().forEach((z)=>q.addTrack(z,this.localStream)),this.peers.set(h,{id:h,connection:q,audioLevel:0,remoteMuted:!1,localMuted:!1}),this.events.emit("peer-joined",h),j){let z=await q.createOffer();await q.setLocalDescription(z),this.send({type:"offer",to:h,sdp:z.sdp})}else if(B){await q.setRemoteDescription({type:"offer",sdp:B});let z=await q.createAnswer();await q.setLocalDescription(z),this.send({type:"answer",to:h,sdp:z.sdp})}}removePeer(h){this.peers.get(h)?.connection.close(),this.peers.delete(h);let B=this.peerAnalysers.get(h);if(B)B.source.disconnect(),B.analyser.disconnect(),B.gainNode.disconnect(),this.peerAnalysers.delete(h);this.events.emit("peer-left",h)}setupRemoteAudio(h,j){if(!this.audioContext){let J=window.AudioContext||window.webkitAudioContext;this.audioContext=new J}let B=this.audioContext.createMediaStreamSource(j),q=this.audioContext.createAnalyser();q.fftSize=256;let z=this.audioContext.createGain();if(this.peers.get(h)?.localMuted)z.gain.value=0;B.connect(q),q.connect(z),z.connect(this.audioContext.destination),this.peerAnalysers.set(h,{source:B,analyser:q,gainNode:z});let K=new Uint8Array(q.frequencyBinCount),O=()=>{let J=this.peers.get(h);if(!J)return;q.getByteFrequencyData(K);let Y=K.reduce((F,L)=>F+L,0)/K.length,Z=Math.min(Y/128,1);this.events.emit("peer-audio-level",h,Z),J.audioLevel=Z,requestAnimationFrame(O)};O()}send(h){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return;this.ws.send(JSON.stringify({from:"",...h}))}handleError(h,j){this.status=h===G.ROOM_FULL||h==="room_full"?"full":"error",this.events.emit("status",this.status),this.events.emit("error",{code:h,message:j})}}export{N as validatePassword,Q as createVocaError,W as VocaErrorMessages,G as VocaErrorCode,X as VocaClient};
2
2
 
3
- //# debugId=FA20E2EBA674C21264756E2164756E21
3
+ //# debugId=5284B6158D19334E64756E2164756E21
package/dist/index.js.map CHANGED
@@ -4,9 +4,9 @@
4
4
  "sourcesContent": [
5
5
  "export let createNanoEvents = () => ({\n emit(event, ...args) {\n for (\n let callbacks = this.events[event] || [],\n i = 0,\n length = callbacks.length;\n i < length;\n i++\n ) {\n callbacks[i](...args)\n }\n },\n events: {},\n on(event, cb) {\n ;(this.events[event] ||= []).push(cb)\n return () => {\n this.events[event] = this.events[event]?.filter(i => cb !== i)\n }\n }\n})\n",
6
6
  "/**\n * Voca Error Codes\n * \n * Unified error codes used across all Voca SDKs and the signaling server.\n */\n\nexport const VocaErrorCode = {\n // Room errors\n ROOM_NOT_FOUND: 'room_not_found',\n ROOM_FULL: 'room_full',\n MAX_ROOMS_REACHED: 'max_rooms_reached',\n INVALID_ROOM_ID: 'invalid_room_id',\n\n // Connection errors\n CONNECTION_FAILED: 'connection_failed',\n WEBSOCKET_ERROR: 'websocket_error',\n HEARTBEAT_TIMEOUT: 'heartbeat_timeout',\n\n // Media errors\n MICROPHONE_NOT_FOUND: 'microphone_not_found',\n MICROPHONE_PERMISSION_DENIED: 'microphone_permission_denied',\n INSECURE_CONTEXT: 'insecure_context',\n\n // Signaling errors\n INVALID_MESSAGE: 'invalid_message',\n PEER_NOT_FOUND: 'peer_not_found',\n\n // Password errors\n INVALID_PASSWORD: 'invalid_password',\n PASSWORD_REQUIRED: 'password_required',\n} as const;\n\nexport type VocaErrorCode = typeof VocaErrorCode[keyof typeof VocaErrorCode];\n\n/**\n * Human-readable error messages for each error code\n */\nexport const VocaErrorMessages: Record<VocaErrorCode, string> = {\n [VocaErrorCode.ROOM_NOT_FOUND]: 'Room not found',\n [VocaErrorCode.ROOM_FULL]: 'Room is at maximum capacity',\n [VocaErrorCode.MAX_ROOMS_REACHED]: 'Maximum number of rooms reached',\n [VocaErrorCode.INVALID_ROOM_ID]: 'Invalid room ID format',\n [VocaErrorCode.CONNECTION_FAILED]: 'Failed to connect to signaling server',\n [VocaErrorCode.WEBSOCKET_ERROR]: 'WebSocket connection error',\n [VocaErrorCode.HEARTBEAT_TIMEOUT]: 'Connection lost due to heartbeat timeout',\n [VocaErrorCode.MICROPHONE_NOT_FOUND]: 'No microphone found. Please connect a microphone and try again.',\n [VocaErrorCode.MICROPHONE_PERMISSION_DENIED]: 'Microphone permission denied. Please allow microphone access.',\n [VocaErrorCode.INSECURE_CONTEXT]: 'HTTPS is required for microphone access',\n [VocaErrorCode.INVALID_MESSAGE]: 'Invalid signaling message received',\n [VocaErrorCode.PEER_NOT_FOUND]: 'Peer not found in room',\n [VocaErrorCode.INVALID_PASSWORD]: 'Incorrect password',\n [VocaErrorCode.PASSWORD_REQUIRED]: 'This room requires a password',\n};\n\n/**\n * Voca error with code and message\n */\nexport interface VocaError {\n code: VocaErrorCode;\n message: string;\n}\n\n/**\n * Create a VocaError from a code\n */\nexport function createVocaError(code: VocaErrorCode, customMessage?: string): VocaError {\n return {\n code,\n message: customMessage ?? VocaErrorMessages[code],\n };\n}\n",
7
- "import { createNanoEvents } from 'nanoevents';\nimport { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';\nexport { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';\n\nexport type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'full' | 'error' | 'disconnected';\n\nexport interface VocaConfig {\n debug?: boolean;\n iceServers?: RTCIceServer[];\n serverUrl?: string; // e.g. \"ws://localhost:3001\" or \"wss://voca.vc\"\n apiKey?: string; // optional API key for signaling server auth\n password?: string; // optional room password for protected rooms\n /**\n * Reconnection options. Enabled by default.\n */\n reconnect?: {\n /** Enable automatic reconnection. Default: true */\n enabled?: boolean;\n /** Maximum reconnection attempts. Default: 5 */\n maxAttempts?: number;\n /** Base delay in milliseconds. Default: 1000 */\n baseDelayMs?: number;\n };\n}\n\nexport interface Peer {\n id: string;\n connection: RTCPeerConnection;\n audioLevel: number;\n stream?: MediaStream;\n}\n\ntype SignalMessage = {\n from: string;\n type: 'hello' | 'welcome' | 'join' | 'leave' | 'offer' | 'answer' | 'ice' | 'ping' | 'pong' | 'error';\n peer_id?: string;\n to?: string;\n sdp?: string;\n candidate?: string;\n code?: string;\n message?: string;\n // Protocol versioning\n version?: string;\n client?: string;\n};\n\ninterface VocaEvents {\n 'status': (status: ConnectionStatus) => void;\n 'error': (error: VocaError) => void;\n 'warning': (warning: { code: string; message: string }) => void;\n 'peer-joined': (peerId: string) => void;\n 'peer-left': (peerId: string) => void;\n 'peer-audio-level': (peerId: string, level: number) => void;\n 'local-audio-level': (level: number) => void;\n 'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;\n}\n\n/**\n * Validate password format for room creation.\n * Returns null if valid, or an error message if invalid.\n * \n * @param password - The password to validate\n * @returns Error message if invalid, null if valid\n */\nexport function validatePassword(password: string): string | null {\n if (!password) return null; // Empty is valid (no password)\n if (password.length < 4 || password.length > 12) {\n return 'Password must be 4-12 characters';\n }\n if (!/^[a-zA-Z0-9]+$/.test(password)) {\n return 'Password must contain only letters and numbers';\n }\n return null;\n}\n\nconst DEFAULT_ICE_SERVERS: RTCIceServer[] = [\n { urls: 'stun:stun.l.google.com:19302' },\n { urls: 'stun:stun1.l.google.com:19302' },\n];\n\nexport class VocaClient {\n public peers: Map<string, Peer> = new Map();\n public localStream: MediaStream | null = null;\n public isMuted: boolean = false;\n public status: ConnectionStatus = 'connecting';\n public roomId: string;\n\n private events = createNanoEvents<VocaEvents>();\n private ws: WebSocket | null = null;\n private audioContext: AudioContext | null = null;\n private analyser: AnalyserNode | null = null;\n private animationFrame: number | null = null;\n private iceServers: RTCIceServer[] = DEFAULT_ICE_SERVERS;\n private config: VocaConfig;\n\n // Reconnection state\n private reconnectAttempts = 0;\n private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;\n private shouldReconnect = true;\n\n // Audio analysis nodes per peer (for cleanup)\n private peerAnalysers: Map<string, { source: MediaStreamAudioSourceNode; analyser: AnalyserNode }> = new Map();\n\n /**\n * Create a new room and return a VocaClient connected to it.\n * This is a convenience method that handles room creation via the API.\n * \n * @param config - VocaClient configuration (serverUrl required for non-browser environments)\n * @returns Promise<VocaClient> - A new client instance for the created room\n */\n static async createRoom(config: VocaConfig = {}): Promise<VocaClient> {\n const httpUrl = VocaClient.getHttpUrl(config.serverUrl);\n\n if (!httpUrl) {\n throw new Error('VocaConfig.serverUrl is required in non-browser environments');\n }\n\n const headers: HeadersInit = {\n 'Content-Type': 'application/json',\n };\n\n if (config.apiKey) {\n headers['x-api-key'] = config.apiKey;\n }\n\n // Build URL with optional password query param\n let url = `${httpUrl}/api/room`;\n const params = new URLSearchParams();\n if (config.password) {\n params.append('password', config.password);\n }\n if (params.toString()) {\n url += `?${params.toString()}`;\n }\n\n const response = await fetch(url, {\n method: 'POST',\n headers,\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({ error: 'unknown', message: 'Failed to create room' }));\n throw new Error(error.message || 'Failed to create room');\n }\n\n const { room, password } = await response.json();\n // Use the password from response (in case server modified it) or from config\n const roomConfig = { ...config, password: password || config.password };\n return new VocaClient(room, roomConfig);\n }\n\n /**\n * Derive an HTTP/HTTPS URL from any input format.\n * Accepts: https://, http://, wss://, ws://\n * Returns: https:// or http:// URL\n */\n private static getHttpUrl(serverUrl?: string): string {\n if (!serverUrl) {\n if (typeof window !== 'undefined') {\n return `${window.location.protocol}//${window.location.host}`;\n }\n return '';\n }\n // Normalize to HTTP(S)\n if (serverUrl.startsWith('wss://')) return serverUrl.replace('wss://', 'https://');\n if (serverUrl.startsWith('ws://')) return serverUrl.replace('ws://', 'http://');\n return serverUrl;\n }\n\n /**\n * Derive a WebSocket URL from any input format.\n * Accepts: https://, http://, wss://, ws://\n * Returns: wss:// or ws:// URL\n */\n private static getWsUrl(serverUrl?: string): string {\n if (!serverUrl) {\n if (typeof window !== 'undefined') {\n const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n return `${protocol}//${window.location.host}`;\n }\n throw new Error('VocaConfig.serverUrl is required in non-browser environments');\n }\n // Normalize to WS(S)\n if (serverUrl.startsWith('https://')) return serverUrl.replace('https://', 'wss://');\n if (serverUrl.startsWith('http://')) return serverUrl.replace('http://', 'ws://');\n return serverUrl;\n }\n\n constructor(roomId: string, config: VocaConfig = {}) {\n this.roomId = roomId;\n this.config = config;\n if (config.iceServers) this.iceServers = config.iceServers;\n }\n\n public on<E extends keyof VocaEvents>(event: E, callback: VocaEvents[E]) {\n return this.events.on(event, callback);\n }\n\n public async connect(): Promise<void> {\n this.status = 'connecting';\n this.events.emit('status', 'connecting');\n\n try {\n // Use default STUN servers (Google's public STUN servers)\n await this.setupMediaAndAudio();\n this.connectSocket();\n } catch (err) {\n this.handleError(VocaErrorCode.CONNECTION_FAILED, err instanceof Error ? err.message : 'Failed to connect');\n throw err;\n }\n }\n\n public disconnect() {\n // Prevent reconnection attempts\n this.shouldReconnect = false;\n if (this.reconnectTimeout) {\n clearTimeout(this.reconnectTimeout);\n this.reconnectTimeout = null;\n }\n\n if (this.animationFrame) cancelAnimationFrame(this.animationFrame);\n this.peers.forEach((p) => p.connection.close());\n this.peers.clear();\n this.ws?.close();\n this.localStream?.getTracks().forEach((t) => t.stop());\n if (this.audioContext && this.audioContext.state !== 'closed') {\n this.audioContext.close().catch(e => console.warn('Error closing AudioContext', e));\n }\n this.status = 'disconnected';\n this.events.emit('status', 'disconnected');\n }\n\n public toggleMute() {\n const track = this.localStream?.getAudioTracks()[0];\n if (track) {\n track.enabled = !track.enabled;\n this.isMuted = !track.enabled;\n }\n return this.isMuted;\n }\n\n\n\n private async setupMediaAndAudio() {\n // Only verify secure context in browsers\n if (typeof window !== 'undefined' && !window.isSecureContext) {\n throw new Error(VocaErrorMessages[VocaErrorCode.INSECURE_CONTEXT]);\n }\n\n try {\n this.localStream = await navigator.mediaDevices.getUserMedia({\n audio: {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n latency: 0,\n channelCount: 1\n } as any,\n video: false,\n });\n } catch (err) {\n try {\n this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });\n } catch (retryErr: any) {\n if (retryErr.name === 'NotFoundError' || retryErr.message.includes('Requested device not found')) {\n throw new Error(VocaErrorMessages[VocaErrorCode.MICROPHONE_NOT_FOUND]);\n }\n if (retryErr.name === 'NotAllowedError' || retryErr.message.includes('Permission denied')) {\n throw new Error(VocaErrorMessages[VocaErrorCode.MICROPHONE_PERMISSION_DENIED]);\n }\n throw retryErr;\n }\n }\n\n this.setupAudioAnalysis(this.localStream!);\n }\n\n private setupAudioAnalysis(stream: MediaStream) {\n const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;\n this.audioContext = new AudioContextClass();\n const source = this.audioContext.createMediaStreamSource(stream);\n this.analyser = this.audioContext.createAnalyser();\n this.analyser.fftSize = 256;\n source.connect(this.analyser);\n\n const dataArray = new Uint8Array(this.analyser.frequencyBinCount);\n const update = () => {\n if (!this.analyser) return;\n this.analyser.getByteFrequencyData(dataArray);\n const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;\n const level = Math.min(avg / 128, 1);\n this.events.emit('local-audio-level', level);\n this.animationFrame = requestAnimationFrame(update);\n };\n update();\n }\n\n /**\n * Build the full WebSocket URL for connecting to a room.\n */\n private getSocketUrl(): string {\n const wsUrl = VocaClient.getWsUrl(this.config.serverUrl);\n\n // Build URL: base + path + query params\n let fullUrl = `${wsUrl}/ws/${this.roomId}`;\n\n const params = new URLSearchParams();\n\n // Append apiKey if present\n if (this.config.apiKey) {\n params.append('apiKey', this.config.apiKey);\n }\n\n // Append password if present\n if (this.config.password) {\n params.append('password', this.config.password);\n }\n\n if (params.toString()) {\n fullUrl += `?${params.toString()}`;\n }\n\n return fullUrl;\n }\n\n private connectSocket() {\n const socketUrl = this.getSocketUrl();\n\n this.ws = new WebSocket(socketUrl);\n\n this.ws.onopen = () => {\n // Send hello message with version info\n this.send({ type: 'hello', version: '0.3.0', client: '@treyorr/voca-client' });\n this.status = 'connected';\n this.events.emit('status', 'connected');\n // Reset reconnect attempts on successful connection\n this.reconnectAttempts = 0;\n };\n\n this.ws.onclose = () => {\n // Don't reconnect on terminal states\n if (this.status === 'full' || this.status === 'error') {\n return;\n }\n\n const reconnectEnabled = this.config.reconnect?.enabled !== false;\n const maxAttempts = this.config.reconnect?.maxAttempts ?? 5;\n const baseDelay = this.config.reconnect?.baseDelayMs ?? 1000;\n\n if (reconnectEnabled && this.shouldReconnect && this.reconnectAttempts < maxAttempts) {\n this.status = 'reconnecting';\n this.events.emit('status', 'reconnecting');\n\n // Exponential backoff: 1s, 2s, 4s, 8s, 16s (capped at 30s)\n const delay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempts), 30000);\n this.reconnectAttempts++;\n\n this.reconnectTimeout = setTimeout(() => {\n this.connectSocket();\n }, delay);\n } else {\n this.status = 'disconnected';\n this.events.emit('status', 'disconnected');\n }\n };\n\n this.ws.onerror = () => this.handleError(VocaErrorCode.WEBSOCKET_ERROR, 'WebSocket connection failed');\n\n this.ws.onmessage = (e) => {\n const msg: SignalMessage = JSON.parse(e.data);\n this.handleSignal(msg);\n };\n }\n\n private async handleSignal(msg: SignalMessage) {\n switch (msg.type) {\n case 'welcome':\n // Protocol handshake complete - peer_id is managed server-side\n console.debug('[Voca] Protocol version:', msg.version, 'Peer ID:', msg.peer_id);\n break;\n case 'join':\n await this.createPeer(msg.from, true);\n break;\n case 'offer':\n await this.createPeer(msg.from, false, msg.sdp);\n break;\n case 'answer':\n const peer = this.peers.get(msg.from);\n if (peer) {\n await peer.connection.setRemoteDescription({ type: 'answer', sdp: msg.sdp! });\n }\n break;\n case 'ice':\n const p = this.peers.get(msg.from);\n if (p) {\n try {\n await p.connection.addIceCandidate(JSON.parse(msg.candidate!));\n } catch (e) {\n console.warn('[Voca] Invalid ICE candidate:', e);\n }\n }\n break;\n case 'ping':\n this.send({ type: 'pong' });\n break;\n case 'leave':\n this.removePeer(msg.from);\n break;\n case 'error':\n this.handleError(msg.code ?? 'unknown', msg.message ?? 'Unknown error');\n break;\n }\n }\n\n private async createPeer(peerId: string, isInitiator: boolean, remoteSdp?: string) {\n const pc = new RTCPeerConnection({ iceServers: this.iceServers });\n\n pc.onicecandidate = (e) => {\n if (e.candidate) this.send({ type: 'ice', to: peerId, candidate: JSON.stringify(e.candidate) });\n };\n\n pc.ontrack = (e) => {\n // Emit track event so UI can handle the audio element/stream\n this.events.emit('track', peerId, e.track, e.streams[0]);\n this.setupRemoteAudio(peerId, e.streams[0]);\n const peer = this.peers.get(peerId);\n if (peer) peer.stream = e.streams[0];\n };\n\n // Add local tracks\n this.localStream?.getTracks().forEach((track) => pc.addTrack(track, this.localStream!));\n\n this.peers.set(peerId, { id: peerId, connection: pc, audioLevel: 0 });\n this.events.emit('peer-joined', peerId);\n\n if (isInitiator) {\n const offer = await pc.createOffer();\n await pc.setLocalDescription(offer);\n this.send({ type: 'offer', to: peerId, sdp: offer.sdp });\n } else if (remoteSdp) {\n await pc.setRemoteDescription({ type: 'offer', sdp: remoteSdp });\n const answer = await pc.createAnswer();\n await pc.setLocalDescription(answer);\n this.send({ type: 'answer', to: peerId, sdp: answer.sdp });\n }\n }\n\n private removePeer(peerId: string) {\n const peer = this.peers.get(peerId);\n peer?.connection.close();\n this.peers.delete(peerId);\n\n // Clean up audio analysis nodes\n const audio = this.peerAnalysers.get(peerId);\n if (audio) {\n audio.source.disconnect();\n audio.analyser.disconnect();\n this.peerAnalysers.delete(peerId);\n }\n\n this.events.emit('peer-left', peerId);\n }\n\n private setupRemoteAudio(peerId: string, stream: MediaStream) {\n if (!this.audioContext) {\n const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;\n this.audioContext = new AudioContextClass();\n }\n\n const source = this.audioContext.createMediaStreamSource(stream);\n const analyser = this.audioContext.createAnalyser();\n analyser.fftSize = 256;\n source.connect(analyser);\n\n // CRITICAL: Connect to speakers so audio is actually played!\n source.connect(this.audioContext.destination);\n\n // Store for cleanup when peer leaves\n this.peerAnalysers.set(peerId, { source, analyser });\n\n const data = new Uint8Array(analyser.frequencyBinCount);\n const update = () => {\n const peer = this.peers.get(peerId);\n if (!peer) return;\n\n analyser.getByteFrequencyData(data);\n const avg = data.reduce((a, b) => a + b, 0) / data.length;\n const level = Math.min(avg / 128, 1);\n\n this.events.emit('peer-audio-level', peerId, level);\n\n // Store in peer object as well for convenience\n peer.audioLevel = level;\n\n requestAnimationFrame(update);\n };\n update();\n }\n\n private send(msg: Partial<SignalMessage>) {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {\n // Queue or drop - for now we drop since this is a signaling race condition\n return;\n }\n this.ws.send(JSON.stringify({ from: '', ...msg }));\n }\n\n private handleError(code: VocaErrorCode | string, message: string) {\n this.status = code === VocaErrorCode.ROOM_FULL || code === 'room_full' ? 'full' : 'error';\n this.events.emit('status', this.status);\n this.events.emit('error', { code: code as VocaErrorCode, message });\n }\n}\n"
7
+ "import { createNanoEvents } from 'nanoevents';\nimport { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';\nexport { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';\n\nexport type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'full' | 'error' | 'disconnected';\n\nexport interface VocaConfig {\n debug?: boolean;\n iceServers?: RTCIceServer[];\n serverUrl?: string; // e.g. \"ws://localhost:3001\" or \"wss://voca.vc\"\n apiKey?: string; // optional API key for signaling server auth\n password?: string; // optional room password for protected rooms\n /**\n * Reconnection options. Enabled by default.\n */\n reconnect?: {\n /** Enable automatic reconnection. Default: true */\n enabled?: boolean;\n /** Maximum reconnection attempts. Default: 5 */\n maxAttempts?: number;\n /** Base delay in milliseconds. Default: 1000 */\n baseDelayMs?: number;\n };\n}\n\nexport interface Peer {\n id: string;\n connection: RTCPeerConnection;\n audioLevel: number;\n stream?: MediaStream;\n remoteMuted?: boolean;\n localMuted?: boolean;\n}\n\ntype SignalMessage = {\n from: string;\n type: 'hello' | 'welcome' | 'join' | 'leave' | 'offer' | 'answer' | 'ice' | 'ping' | 'pong' | 'error' | 'mute';\n peer_id?: string;\n to?: string;\n sdp?: string;\n candidate?: string;\n code?: string;\n message?: string;\n muted?: boolean;\n // Protocol versioning\n version?: string;\n client?: string;\n};\n\ninterface VocaEvents {\n 'status': (status: ConnectionStatus) => void;\n 'error': (error: VocaError) => void;\n 'warning': (warning: { code: string; message: string }) => void;\n 'peer-joined': (peerId: string) => void;\n 'peer-left': (peerId: string) => void;\n 'peer-audio-level': (peerId: string, level: number) => void;\n 'local-audio-level': (level: number) => void;\n 'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;\n 'peer-mute': (peerId: string, isMuted: boolean) => void;\n 'peer-local-mute': (peerId: string, isMuted: boolean) => void;\n}\n\n/**\n * Validate password format for room creation.\n * Returns null if valid, or an error message if invalid.\n * \n * @param password - The password to validate\n * @returns Error message if invalid, null if valid\n */\nexport function validatePassword(password: string): string | null {\n if (!password) return null; // Empty is valid (no password)\n if (password.length < 4 || password.length > 12) {\n return 'Password must be 4-12 characters';\n }\n if (!/^[a-zA-Z0-9]+$/.test(password)) {\n return 'Password must contain only letters and numbers';\n }\n return null;\n}\n\nconst DEFAULT_ICE_SERVERS: RTCIceServer[] = [\n { urls: 'stun:stun.l.google.com:19302' },\n { urls: 'stun:stun1.l.google.com:19302' },\n];\n\nexport class VocaClient {\n public peers: Map<string, Peer> = new Map();\n public localStream: MediaStream | null = null;\n public isMuted: boolean = false;\n public status: ConnectionStatus = 'connecting';\n public roomId: string;\n\n private events = createNanoEvents<VocaEvents>();\n private ws: WebSocket | null = null;\n private audioContext: AudioContext | null = null;\n private analyser: AnalyserNode | null = null;\n private animationFrame: number | null = null;\n private iceServers: RTCIceServer[] = DEFAULT_ICE_SERVERS;\n private config: VocaConfig;\n\n // Reconnection state\n private reconnectAttempts = 0;\n private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;\n private shouldReconnect = true;\n\n // Audio analysis nodes per peer (for cleanup)\n private peerAnalysers: Map<string, { source: MediaStreamAudioSourceNode; analyser: AnalyserNode; gainNode: GainNode }> = new Map();\n\n /**\n * Create a new room and return a VocaClient connected to it.\n * This is a convenience method that handles room creation via the API.\n * \n * @param config - VocaClient configuration (serverUrl required for non-browser environments)\n * @returns Promise<VocaClient> - A new client instance for the created room\n */\n static async createRoom(config: VocaConfig = {}): Promise<VocaClient> {\n const httpUrl = VocaClient.getHttpUrl(config.serverUrl);\n\n if (!httpUrl) {\n throw new Error('VocaConfig.serverUrl is required in non-browser environments');\n }\n\n const headers: HeadersInit = {\n 'Content-Type': 'application/json',\n };\n\n if (config.apiKey) {\n headers['x-api-key'] = config.apiKey;\n }\n\n // Build URL with optional password query param\n let url = `${httpUrl}/api/room`;\n const params = new URLSearchParams();\n if (config.password) {\n params.append('password', config.password);\n }\n if (params.toString()) {\n url += `?${params.toString()}`;\n }\n\n const response = await fetch(url, {\n method: 'POST',\n headers,\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({ error: 'unknown', message: 'Failed to create room' }));\n throw new Error(error.message || 'Failed to create room');\n }\n\n const { room, password } = await response.json();\n // Use the password from response (in case server modified it) or from config\n const roomConfig = { ...config, password: password || config.password };\n return new VocaClient(room, roomConfig);\n }\n\n /**\n * Derive an HTTP/HTTPS URL from any input format.\n * Accepts: https://, http://, wss://, ws://\n * Returns: https:// or http:// URL\n */\n private static getHttpUrl(serverUrl?: string): string {\n if (!serverUrl) {\n if (typeof window !== 'undefined') {\n return `${window.location.protocol}//${window.location.host}`;\n }\n return '';\n }\n // Normalize to HTTP(S)\n if (serverUrl.startsWith('wss://')) return serverUrl.replace('wss://', 'https://');\n if (serverUrl.startsWith('ws://')) return serverUrl.replace('ws://', 'http://');\n return serverUrl;\n }\n\n /**\n * Derive a WebSocket URL from any input format.\n * Accepts: https://, http://, wss://, ws://\n * Returns: wss:// or ws:// URL\n */\n private static getWsUrl(serverUrl?: string): string {\n if (!serverUrl) {\n if (typeof window !== 'undefined') {\n const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n return `${protocol}//${window.location.host}`;\n }\n throw new Error('VocaConfig.serverUrl is required in non-browser environments');\n }\n // Normalize to WS(S)\n if (serverUrl.startsWith('https://')) return serverUrl.replace('https://', 'wss://');\n if (serverUrl.startsWith('http://')) return serverUrl.replace('http://', 'ws://');\n return serverUrl;\n }\n\n constructor(roomId: string, config: VocaConfig = {}) {\n this.roomId = roomId;\n this.config = config;\n if (config.iceServers) this.iceServers = config.iceServers;\n }\n\n public on<E extends keyof VocaEvents>(event: E, callback: VocaEvents[E]) {\n return this.events.on(event, callback);\n }\n\n public async connect(): Promise<void> {\n this.status = 'connecting';\n this.events.emit('status', 'connecting');\n\n try {\n // Use default STUN servers (Google's public STUN servers)\n await this.setupMediaAndAudio();\n this.connectSocket();\n } catch (err) {\n this.handleError(VocaErrorCode.CONNECTION_FAILED, err instanceof Error ? err.message : 'Failed to connect');\n throw err;\n }\n }\n\n public disconnect() {\n // Prevent reconnection attempts\n this.shouldReconnect = false;\n if (this.reconnectTimeout) {\n clearTimeout(this.reconnectTimeout);\n this.reconnectTimeout = null;\n }\n\n if (this.animationFrame) cancelAnimationFrame(this.animationFrame);\n this.peers.forEach((p) => p.connection.close());\n this.peers.clear();\n this.ws?.close();\n this.localStream?.getTracks().forEach((t) => t.stop());\n if (this.audioContext && this.audioContext.state !== 'closed') {\n this.audioContext.close().catch(e => console.warn('Error closing AudioContext', e));\n }\n this.status = 'disconnected';\n this.events.emit('status', 'disconnected');\n }\n\n public toggleMute() {\n const track = this.localStream?.getAudioTracks()[0];\n if (track) {\n track.enabled = !track.enabled;\n this.isMuted = !track.enabled;\n // Broadcast our mute state to everyone else\n this.send({ type: 'mute', muted: this.isMuted });\n }\n return this.isMuted;\n }\n\n public togglePeerMute(peerId: string) {\n const peer = this.peers.get(peerId);\n if (!peer) return false;\n\n const isCurrentlyMuted = peer.localMuted ?? false;\n const newMutedState = !isCurrentlyMuted;\n peer.localMuted = newMutedState;\n\n // Update the gain node\n const audioNodes = this.peerAnalysers.get(peerId);\n if (audioNodes) {\n // Mute by dropping gain to 0, unmute by setting back to 1\n audioNodes.gainNode.gain.value = newMutedState ? 0 : 1;\n }\n\n this.events.emit('peer-local-mute', peerId, newMutedState);\n return newMutedState;\n }\n\n\n\n private async setupMediaAndAudio() {\n // Only verify secure context in browsers\n if (typeof window !== 'undefined' && !window.isSecureContext) {\n throw new Error(VocaErrorMessages[VocaErrorCode.INSECURE_CONTEXT]);\n }\n\n try {\n this.localStream = await navigator.mediaDevices.getUserMedia({\n audio: {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n latency: 0,\n channelCount: 1\n } as any,\n video: false,\n });\n } catch (err) {\n try {\n this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });\n } catch (retryErr: any) {\n if (retryErr.name === 'NotFoundError' || retryErr.message.includes('Requested device not found')) {\n throw new Error(VocaErrorMessages[VocaErrorCode.MICROPHONE_NOT_FOUND]);\n }\n if (retryErr.name === 'NotAllowedError' || retryErr.message.includes('Permission denied')) {\n throw new Error(VocaErrorMessages[VocaErrorCode.MICROPHONE_PERMISSION_DENIED]);\n }\n throw retryErr;\n }\n }\n\n this.setupAudioAnalysis(this.localStream!);\n }\n\n private setupAudioAnalysis(stream: MediaStream) {\n const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;\n this.audioContext = new AudioContextClass();\n const source = this.audioContext.createMediaStreamSource(stream);\n this.analyser = this.audioContext.createAnalyser();\n this.analyser.fftSize = 256;\n source.connect(this.analyser);\n\n const dataArray = new Uint8Array(this.analyser.frequencyBinCount);\n const update = () => {\n if (!this.analyser) return;\n this.analyser.getByteFrequencyData(dataArray);\n const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;\n const level = Math.min(avg / 128, 1);\n this.events.emit('local-audio-level', level);\n this.animationFrame = requestAnimationFrame(update);\n };\n update();\n }\n\n /**\n * Build the full WebSocket URL for connecting to a room.\n */\n private getSocketUrl(): string {\n const wsUrl = VocaClient.getWsUrl(this.config.serverUrl);\n\n // Build URL: base + path + query params\n let fullUrl = `${wsUrl}/ws/${this.roomId}`;\n\n const params = new URLSearchParams();\n\n // Append apiKey if present\n if (this.config.apiKey) {\n params.append('apiKey', this.config.apiKey);\n }\n\n // Append password if present\n if (this.config.password) {\n params.append('password', this.config.password);\n }\n\n if (params.toString()) {\n fullUrl += `?${params.toString()}`;\n }\n\n return fullUrl;\n }\n\n private connectSocket() {\n const socketUrl = this.getSocketUrl();\n\n this.ws = new WebSocket(socketUrl);\n\n this.ws.onopen = () => {\n // Send hello message with version info\n this.send({ type: 'hello', version: '0.3.0', client: '@treyorr/voca-client' });\n this.status = 'connected';\n this.events.emit('status', 'connected');\n // Reset reconnect attempts on successful connection\n this.reconnectAttempts = 0;\n };\n\n this.ws.onclose = () => {\n // Don't reconnect on terminal states\n if (this.status === 'full' || this.status === 'error') {\n return;\n }\n\n const reconnectEnabled = this.config.reconnect?.enabled !== false;\n const maxAttempts = this.config.reconnect?.maxAttempts ?? 5;\n const baseDelay = this.config.reconnect?.baseDelayMs ?? 1000;\n\n if (reconnectEnabled && this.shouldReconnect && this.reconnectAttempts < maxAttempts) {\n this.status = 'reconnecting';\n this.events.emit('status', 'reconnecting');\n\n // Exponential backoff: 1s, 2s, 4s, 8s, 16s (capped at 30s)\n const delay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempts), 30000);\n this.reconnectAttempts++;\n\n this.reconnectTimeout = setTimeout(() => {\n this.connectSocket();\n }, delay);\n } else {\n this.status = 'disconnected';\n this.events.emit('status', 'disconnected');\n }\n };\n\n this.ws.onerror = () => this.handleError(VocaErrorCode.WEBSOCKET_ERROR, 'WebSocket connection failed');\n\n this.ws.onmessage = (e) => {\n const msg: SignalMessage = JSON.parse(e.data);\n this.handleSignal(msg);\n };\n }\n\n private async handleSignal(msg: SignalMessage) {\n switch (msg.type) {\n case 'welcome':\n // Protocol handshake complete - peer_id is managed server-side\n console.debug('[Voca] Protocol version:', msg.version, 'Peer ID:', msg.peer_id);\n break;\n case 'join':\n await this.createPeer(msg.from, true);\n if (this.isMuted) {\n // Send our mute state specifically to the joined peer\n this.send({ type: 'mute', to: msg.from, muted: true });\n }\n break;\n case 'offer':\n await this.createPeer(msg.from, false, msg.sdp);\n break;\n case 'answer':\n const peer = this.peers.get(msg.from);\n if (peer) {\n await peer.connection.setRemoteDescription({ type: 'answer', sdp: msg.sdp! });\n }\n break;\n case 'ice':\n const p = this.peers.get(msg.from);\n if (p) {\n try {\n await p.connection.addIceCandidate(JSON.parse(msg.candidate!));\n } catch (e) {\n console.warn('[Voca] Invalid ICE candidate:', e);\n }\n }\n break;\n case 'ping':\n this.send({ type: 'pong' });\n break;\n case 'leave':\n this.removePeer(msg.from);\n break;\n case 'mute':\n const mutePeer = this.peers.get(msg.from);\n if (mutePeer) {\n mutePeer.remoteMuted = msg.muted ?? false;\n this.events.emit('peer-mute', msg.from, mutePeer.remoteMuted);\n }\n break;\n case 'error':\n this.handleError(msg.code ?? 'unknown', msg.message ?? 'Unknown error');\n break;\n }\n }\n\n private async createPeer(peerId: string, isInitiator: boolean, remoteSdp?: string) {\n const pc = new RTCPeerConnection({ iceServers: this.iceServers });\n\n pc.onicecandidate = (e) => {\n if (e.candidate) this.send({ type: 'ice', to: peerId, candidate: JSON.stringify(e.candidate) });\n };\n\n pc.ontrack = (e) => {\n // Emit track event so UI can handle the audio element/stream\n this.events.emit('track', peerId, e.track, e.streams[0]);\n this.setupRemoteAudio(peerId, e.streams[0]);\n const peer = this.peers.get(peerId);\n if (peer) peer.stream = e.streams[0];\n };\n\n // Add local tracks\n this.localStream?.getTracks().forEach((track) => pc.addTrack(track, this.localStream!));\n\n this.peers.set(peerId, { id: peerId, connection: pc, audioLevel: 0, remoteMuted: false, localMuted: false });\n this.events.emit('peer-joined', peerId);\n\n if (isInitiator) {\n const offer = await pc.createOffer();\n await pc.setLocalDescription(offer);\n this.send({ type: 'offer', to: peerId, sdp: offer.sdp });\n } else if (remoteSdp) {\n await pc.setRemoteDescription({ type: 'offer', sdp: remoteSdp });\n const answer = await pc.createAnswer();\n await pc.setLocalDescription(answer);\n this.send({ type: 'answer', to: peerId, sdp: answer.sdp });\n }\n }\n\n private removePeer(peerId: string) {\n const peer = this.peers.get(peerId);\n peer?.connection.close();\n this.peers.delete(peerId);\n\n // Clean up audio analysis nodes\n const audio = this.peerAnalysers.get(peerId);\n if (audio) {\n audio.source.disconnect();\n audio.analyser.disconnect();\n audio.gainNode.disconnect();\n this.peerAnalysers.delete(peerId);\n }\n\n this.events.emit('peer-left', peerId);\n }\n\n private setupRemoteAudio(peerId: string, stream: MediaStream) {\n if (!this.audioContext) {\n const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;\n this.audioContext = new AudioContextClass();\n }\n\n const source = this.audioContext.createMediaStreamSource(stream);\n const analyser = this.audioContext.createAnalyser();\n analyser.fftSize = 256;\n\n // Create a GainNode for local muting\n const gainNode = this.audioContext.createGain();\n\n // Check if the peer is already locally muted (in case they reconnect)\n const peer = this.peers.get(peerId);\n if (peer?.localMuted) {\n gainNode.gain.value = 0;\n }\n\n source.connect(analyser);\n analyser.connect(gainNode);\n\n // CRITICAL: Connect to speakers so audio is actually played!\n gainNode.connect(this.audioContext.destination);\n\n // Store for cleanup when peer leaves\n this.peerAnalysers.set(peerId, { source, analyser, gainNode });\n\n const data = new Uint8Array(analyser.frequencyBinCount);\n const update = () => {\n const peer = this.peers.get(peerId);\n if (!peer) return;\n\n analyser.getByteFrequencyData(data);\n const avg = data.reduce((a, b) => a + b, 0) / data.length;\n const level = Math.min(avg / 128, 1);\n\n this.events.emit('peer-audio-level', peerId, level);\n\n // Store in peer object as well for convenience\n peer.audioLevel = level;\n\n requestAnimationFrame(update);\n };\n update();\n }\n\n private send(msg: Partial<SignalMessage>) {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {\n // Queue or drop - for now we drop since this is a signaling race condition\n return;\n }\n this.ws.send(JSON.stringify({ from: '', ...msg }));\n }\n\n private handleError(code: VocaErrorCode | string, message: string) {\n this.status = code === VocaErrorCode.ROOM_FULL || code === 'room_full' ? 'full' : 'error';\n this.events.emit('status', this.status);\n this.events.emit('error', { code: code as VocaErrorCode, message });\n }\n}\n"
8
8
  ],
9
- "mappings": "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",
10
- "debugId": "FA20E2EBA674C21264756E2164756E21",
9
+ "mappings": "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",
10
+ "debugId": "5284B6158D19334E64756E2164756E21",
11
11
  "names": []
12
12
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@treyorr/voca-client",
3
- "version": "0.3.0",
4
- "description": "Core TypeScript SDK for Voca Signaling",
3
+ "version": "0.4.0",
4
+ "description": "Voca WebRTC Client SDK",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -28,17 +28,20 @@ export interface Peer {
28
28
  connection: RTCPeerConnection;
29
29
  audioLevel: number;
30
30
  stream?: MediaStream;
31
+ remoteMuted?: boolean;
32
+ localMuted?: boolean;
31
33
  }
32
34
 
33
35
  type SignalMessage = {
34
36
  from: string;
35
- type: 'hello' | 'welcome' | 'join' | 'leave' | 'offer' | 'answer' | 'ice' | 'ping' | 'pong' | 'error';
37
+ type: 'hello' | 'welcome' | 'join' | 'leave' | 'offer' | 'answer' | 'ice' | 'ping' | 'pong' | 'error' | 'mute';
36
38
  peer_id?: string;
37
39
  to?: string;
38
40
  sdp?: string;
39
41
  candidate?: string;
40
42
  code?: string;
41
43
  message?: string;
44
+ muted?: boolean;
42
45
  // Protocol versioning
43
46
  version?: string;
44
47
  client?: string;
@@ -53,6 +56,8 @@ interface VocaEvents {
53
56
  'peer-audio-level': (peerId: string, level: number) => void;
54
57
  'local-audio-level': (level: number) => void;
55
58
  'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
59
+ 'peer-mute': (peerId: string, isMuted: boolean) => void;
60
+ 'peer-local-mute': (peerId: string, isMuted: boolean) => void;
56
61
  }
57
62
 
58
63
  /**
@@ -99,7 +104,7 @@ export class VocaClient {
99
104
  private shouldReconnect = true;
100
105
 
101
106
  // Audio analysis nodes per peer (for cleanup)
102
- private peerAnalysers: Map<string, { source: MediaStreamAudioSourceNode; analyser: AnalyserNode }> = new Map();
107
+ private peerAnalysers: Map<string, { source: MediaStreamAudioSourceNode; analyser: AnalyserNode; gainNode: GainNode }> = new Map();
103
108
 
104
109
  /**
105
110
  * Create a new room and return a VocaClient connected to it.
@@ -235,10 +240,31 @@ export class VocaClient {
235
240
  if (track) {
236
241
  track.enabled = !track.enabled;
237
242
  this.isMuted = !track.enabled;
243
+ // Broadcast our mute state to everyone else
244
+ this.send({ type: 'mute', muted: this.isMuted });
238
245
  }
239
246
  return this.isMuted;
240
247
  }
241
248
 
249
+ public togglePeerMute(peerId: string) {
250
+ const peer = this.peers.get(peerId);
251
+ if (!peer) return false;
252
+
253
+ const isCurrentlyMuted = peer.localMuted ?? false;
254
+ const newMutedState = !isCurrentlyMuted;
255
+ peer.localMuted = newMutedState;
256
+
257
+ // Update the gain node
258
+ const audioNodes = this.peerAnalysers.get(peerId);
259
+ if (audioNodes) {
260
+ // Mute by dropping gain to 0, unmute by setting back to 1
261
+ audioNodes.gainNode.gain.value = newMutedState ? 0 : 1;
262
+ }
263
+
264
+ this.events.emit('peer-local-mute', peerId, newMutedState);
265
+ return newMutedState;
266
+ }
267
+
242
268
 
243
269
 
244
270
  private async setupMediaAndAudio() {
@@ -380,6 +406,10 @@ export class VocaClient {
380
406
  break;
381
407
  case 'join':
382
408
  await this.createPeer(msg.from, true);
409
+ if (this.isMuted) {
410
+ // Send our mute state specifically to the joined peer
411
+ this.send({ type: 'mute', to: msg.from, muted: true });
412
+ }
383
413
  break;
384
414
  case 'offer':
385
415
  await this.createPeer(msg.from, false, msg.sdp);
@@ -406,6 +436,13 @@ export class VocaClient {
406
436
  case 'leave':
407
437
  this.removePeer(msg.from);
408
438
  break;
439
+ case 'mute':
440
+ const mutePeer = this.peers.get(msg.from);
441
+ if (mutePeer) {
442
+ mutePeer.remoteMuted = msg.muted ?? false;
443
+ this.events.emit('peer-mute', msg.from, mutePeer.remoteMuted);
444
+ }
445
+ break;
409
446
  case 'error':
410
447
  this.handleError(msg.code ?? 'unknown', msg.message ?? 'Unknown error');
411
448
  break;
@@ -430,7 +467,7 @@ export class VocaClient {
430
467
  // Add local tracks
431
468
  this.localStream?.getTracks().forEach((track) => pc.addTrack(track, this.localStream!));
432
469
 
433
- this.peers.set(peerId, { id: peerId, connection: pc, audioLevel: 0 });
470
+ this.peers.set(peerId, { id: peerId, connection: pc, audioLevel: 0, remoteMuted: false, localMuted: false });
434
471
  this.events.emit('peer-joined', peerId);
435
472
 
436
473
  if (isInitiator) {
@@ -455,6 +492,7 @@ export class VocaClient {
455
492
  if (audio) {
456
493
  audio.source.disconnect();
457
494
  audio.analyser.disconnect();
495
+ audio.gainNode.disconnect();
458
496
  this.peerAnalysers.delete(peerId);
459
497
  }
460
498
 
@@ -470,13 +508,24 @@ export class VocaClient {
470
508
  const source = this.audioContext.createMediaStreamSource(stream);
471
509
  const analyser = this.audioContext.createAnalyser();
472
510
  analyser.fftSize = 256;
511
+
512
+ // Create a GainNode for local muting
513
+ const gainNode = this.audioContext.createGain();
514
+
515
+ // Check if the peer is already locally muted (in case they reconnect)
516
+ const peer = this.peers.get(peerId);
517
+ if (peer?.localMuted) {
518
+ gainNode.gain.value = 0;
519
+ }
520
+
473
521
  source.connect(analyser);
522
+ analyser.connect(gainNode);
474
523
 
475
524
  // CRITICAL: Connect to speakers so audio is actually played!
476
- source.connect(this.audioContext.destination);
525
+ gainNode.connect(this.audioContext.destination);
477
526
 
478
527
  // Store for cleanup when peer leaves
479
- this.peerAnalysers.set(peerId, { source, analyser });
528
+ this.peerAnalysers.set(peerId, { source, analyser, gainNode });
480
529
 
481
530
  const data = new Uint8Array(analyser.frequencyBinCount);
482
531
  const update = () => {