@treyorr/voca-client 0.1.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/README.md +118 -0
- package/dist/errors.d.ts +35 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +12 -0
- package/package.json +45 -0
- package/src/__tests__/client.test.ts +184 -0
- package/src/errors.ts +65 -0
- package/src/index.ts +452 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# @treyorr/voca-client
|
|
2
|
+
|
|
3
|
+
Core TypeScript SDK for Voca WebRTC signaling.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @treyorr/voca-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
### Option 1: Create a New Room
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { VocaClient } from '@treyorr/voca-client';
|
|
17
|
+
|
|
18
|
+
// Create a room and get a connected client
|
|
19
|
+
const client = await VocaClient.createRoom({
|
|
20
|
+
serverUrl: 'wss://your-server.com',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
console.log('Room ID:', client.roomId); // Share this with others
|
|
24
|
+
await client.connect();
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Option 2: Join an Existing Room
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { VocaClient } from '@treyorr/voca-client';
|
|
31
|
+
|
|
32
|
+
const client = new VocaClient('abc123', {
|
|
33
|
+
serverUrl: 'wss://your-server.com',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
await client.connect();
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Common Use Cases
|
|
40
|
+
|
|
41
|
+
### Voice Chat App (Create + Share Link)
|
|
42
|
+
```typescript
|
|
43
|
+
const client = await VocaClient.createRoom();
|
|
44
|
+
const inviteLink = `https://myapp.com/room/${client.roomId}`;
|
|
45
|
+
// Share inviteLink with friends
|
|
46
|
+
await client.connect();
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Game Lobby (Join by Code)
|
|
50
|
+
```typescript
|
|
51
|
+
const roomCode = prompt('Enter room code:');
|
|
52
|
+
const client = new VocaClient(roomCode);
|
|
53
|
+
await client.connect();
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Check if Room is Full Before Joining
|
|
57
|
+
```typescript
|
|
58
|
+
const response = await fetch(`/api/room/${roomId}`);
|
|
59
|
+
const { exists, full, peers, capacity } = await response.json();
|
|
60
|
+
|
|
61
|
+
if (!exists) {
|
|
62
|
+
alert('Room not found');
|
|
63
|
+
} else if (full) {
|
|
64
|
+
alert(`Room is full (${peers}/${capacity})`);
|
|
65
|
+
} else {
|
|
66
|
+
const client = new VocaClient(roomId);
|
|
67
|
+
await client.connect();
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## API Reference
|
|
72
|
+
|
|
73
|
+
### `VocaClient.createRoom(config?)`
|
|
74
|
+
|
|
75
|
+
Creates a new room on the server and returns a client instance.
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
const client = await VocaClient.createRoom({
|
|
79
|
+
serverUrl: 'wss://your-server.com', // Required in Node.js
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### `new VocaClient(roomId, config?)`
|
|
84
|
+
|
|
85
|
+
Creates a client for joining an existing room.
|
|
86
|
+
|
|
87
|
+
### Config Options
|
|
88
|
+
|
|
89
|
+
| Option | Type | Description |
|
|
90
|
+
|--------|------|-------------|
|
|
91
|
+
| `serverUrl` | string | WebSocket server URL |
|
|
92
|
+
| `turnApiKey` | string | TURN API key for NAT traversal |
|
|
93
|
+
| `reconnect.enabled` | boolean | Enable auto-reconnect (default: true) |
|
|
94
|
+
| `reconnect.maxAttempts` | number | Max reconnection attempts (default: 5) |
|
|
95
|
+
| `reconnect.baseDelayMs` | number | Base delay for exponential backoff |
|
|
96
|
+
|
|
97
|
+
### Methods
|
|
98
|
+
|
|
99
|
+
| Method | Returns | Description |
|
|
100
|
+
|--------|---------|-------------|
|
|
101
|
+
| `connect()` | Promise | Connect to room |
|
|
102
|
+
| `disconnect()` | void | Leave room |
|
|
103
|
+
| `toggleMute()` | boolean | Toggle mute, returns new state |
|
|
104
|
+
| `on(event, callback)` | Function | Subscribe to events |
|
|
105
|
+
|
|
106
|
+
### Events
|
|
107
|
+
|
|
108
|
+
- `status` - Connection status changes
|
|
109
|
+
- `error` - Error events with typed codes
|
|
110
|
+
- `warning` - Non-fatal warnings
|
|
111
|
+
- `peer-joined` - Peer joined the room
|
|
112
|
+
- `peer-left` - Peer left the room
|
|
113
|
+
- `peer-audio-level` - Peer audio level (0-1)
|
|
114
|
+
- `local-audio-level` - Local audio level (0-1)
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voca Error Codes
|
|
3
|
+
*
|
|
4
|
+
* Unified error codes used across all Voca SDKs and the signaling server.
|
|
5
|
+
*/
|
|
6
|
+
export declare const VocaErrorCode: {
|
|
7
|
+
readonly ROOM_NOT_FOUND: "room_not_found";
|
|
8
|
+
readonly ROOM_FULL: "room_full";
|
|
9
|
+
readonly MAX_ROOMS_REACHED: "max_rooms_reached";
|
|
10
|
+
readonly INVALID_ROOM_ID: "invalid_room_id";
|
|
11
|
+
readonly CONNECTION_FAILED: "connection_failed";
|
|
12
|
+
readonly WEBSOCKET_ERROR: "websocket_error";
|
|
13
|
+
readonly HEARTBEAT_TIMEOUT: "heartbeat_timeout";
|
|
14
|
+
readonly MICROPHONE_NOT_FOUND: "microphone_not_found";
|
|
15
|
+
readonly MICROPHONE_PERMISSION_DENIED: "microphone_permission_denied";
|
|
16
|
+
readonly INSECURE_CONTEXT: "insecure_context";
|
|
17
|
+
readonly INVALID_MESSAGE: "invalid_message";
|
|
18
|
+
readonly PEER_NOT_FOUND: "peer_not_found";
|
|
19
|
+
};
|
|
20
|
+
export type VocaErrorCode = typeof VocaErrorCode[keyof typeof VocaErrorCode];
|
|
21
|
+
/**
|
|
22
|
+
* Human-readable error messages for each error code
|
|
23
|
+
*/
|
|
24
|
+
export declare const VocaErrorMessages: Record<VocaErrorCode, string>;
|
|
25
|
+
/**
|
|
26
|
+
* Voca error with code and message
|
|
27
|
+
*/
|
|
28
|
+
export interface VocaError {
|
|
29
|
+
code: VocaErrorCode;
|
|
30
|
+
message: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Create a VocaError from a code
|
|
34
|
+
*/
|
|
35
|
+
export declare function createVocaError(code: VocaErrorCode, customMessage?: string): VocaError;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { type VocaError } from './errors';
|
|
2
|
+
export { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';
|
|
3
|
+
export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'full' | 'error' | 'disconnected';
|
|
4
|
+
export interface VocaConfig {
|
|
5
|
+
debug?: boolean;
|
|
6
|
+
iceServers?: RTCIceServer[];
|
|
7
|
+
serverUrl?: string;
|
|
8
|
+
apiKey?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Reconnection options. Enabled by default.
|
|
11
|
+
*/
|
|
12
|
+
reconnect?: {
|
|
13
|
+
/** Enable automatic reconnection. Default: true */
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
/** Maximum reconnection attempts. Default: 5 */
|
|
16
|
+
maxAttempts?: number;
|
|
17
|
+
/** Base delay in milliseconds. Default: 1000 */
|
|
18
|
+
baseDelayMs?: number;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export interface Peer {
|
|
22
|
+
id: string;
|
|
23
|
+
connection: RTCPeerConnection;
|
|
24
|
+
audioLevel: number;
|
|
25
|
+
stream?: MediaStream;
|
|
26
|
+
}
|
|
27
|
+
interface VocaEvents {
|
|
28
|
+
'status': (status: ConnectionStatus) => void;
|
|
29
|
+
'error': (error: VocaError) => void;
|
|
30
|
+
'warning': (warning: {
|
|
31
|
+
code: string;
|
|
32
|
+
message: string;
|
|
33
|
+
}) => void;
|
|
34
|
+
'peer-joined': (peerId: string) => void;
|
|
35
|
+
'peer-left': (peerId: string) => void;
|
|
36
|
+
'peer-audio-level': (peerId: string, level: number) => void;
|
|
37
|
+
'local-audio-level': (level: number) => void;
|
|
38
|
+
'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
|
|
39
|
+
}
|
|
40
|
+
export declare class VocaClient {
|
|
41
|
+
peers: Map<string, Peer>;
|
|
42
|
+
localStream: MediaStream | null;
|
|
43
|
+
isMuted: boolean;
|
|
44
|
+
status: ConnectionStatus;
|
|
45
|
+
roomId: string;
|
|
46
|
+
private events;
|
|
47
|
+
private ws;
|
|
48
|
+
private audioContext;
|
|
49
|
+
private analyser;
|
|
50
|
+
private animationFrame;
|
|
51
|
+
private iceServers;
|
|
52
|
+
private config;
|
|
53
|
+
private reconnectAttempts;
|
|
54
|
+
private reconnectTimeout;
|
|
55
|
+
private shouldReconnect;
|
|
56
|
+
private peerAnalysers;
|
|
57
|
+
/**
|
|
58
|
+
* Create a new room and return a VocaClient connected to it.
|
|
59
|
+
* This is a convenience method that handles room creation via the API.
|
|
60
|
+
*
|
|
61
|
+
* @param config - VocaClient configuration (serverUrl required for non-browser environments)
|
|
62
|
+
* @returns Promise<VocaClient> - A new client instance for the created room
|
|
63
|
+
*/
|
|
64
|
+
static createRoom(config?: VocaConfig): Promise<VocaClient>;
|
|
65
|
+
constructor(roomId: string, config?: VocaConfig);
|
|
66
|
+
on<E extends keyof VocaEvents>(event: E, callback: VocaEvents[E]): import("nanoevents", { with: { "resolution-mode": "import" } }).Unsubscribe;
|
|
67
|
+
connect(): Promise<void>;
|
|
68
|
+
disconnect(): void;
|
|
69
|
+
toggleMute(): boolean;
|
|
70
|
+
private setupMediaAndAudio;
|
|
71
|
+
private setupAudioAnalysis;
|
|
72
|
+
/**
|
|
73
|
+
* Infer the WebSocket server URL from config or environment.
|
|
74
|
+
*/
|
|
75
|
+
private getServerUrl;
|
|
76
|
+
private connectSocket;
|
|
77
|
+
private handleSignal;
|
|
78
|
+
private createPeer;
|
|
79
|
+
private removePeer;
|
|
80
|
+
private setupRemoteAudio;
|
|
81
|
+
private send;
|
|
82
|
+
private handleError;
|
|
83
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
var Q=()=>({emit(h,...q){for(let B=this.events[h]||[],z=0,G=B.length;z<G;z++)B[z](...q)},events:{},on(h,q){return(this.events[h]||=[]).push(q),()=>{this.events[h]=this.events[h]?.filter((B)=>q!==B)}}});var H={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"},N={[H.ROOM_NOT_FOUND]:"Room not found",[H.ROOM_FULL]:"Room is at maximum capacity",[H.MAX_ROOMS_REACHED]:"Maximum number of rooms reached",[H.INVALID_ROOM_ID]:"Invalid room ID format",[H.CONNECTION_FAILED]:"Failed to connect to signaling server",[H.WEBSOCKET_ERROR]:"WebSocket connection error",[H.HEARTBEAT_TIMEOUT]:"Connection lost due to heartbeat timeout",[H.MICROPHONE_NOT_FOUND]:"No microphone found. Please connect a microphone and try again.",[H.MICROPHONE_PERMISSION_DENIED]:"Microphone permission denied. Please allow microphone access.",[H.INSECURE_CONTEXT]:"HTTPS is required for microphone access",[H.INVALID_MESSAGE]:"Invalid signaling message received",[H.PEER_NOT_FOUND]:"Peer not found in room"};function Z(h,q){return{code:h,message:q??N[h]}}var $=[{urls:"stun:stun.l.google.com:19302"},{urls:"stun:stun1.l.google.com:19302"}];class W{peers=new Map;localStream=null;isMuted=!1;status="connecting";roomId;events=Q();ws=null;audioContext=null;analyser=null;animationFrame=null;iceServers=$;config;reconnectAttempts=0;reconnectTimeout=null;shouldReconnect=!0;peerAnalysers=new Map;static async createRoom(h={}){let q=h.serverUrl?h.serverUrl.replace(/^ws/,"http"):typeof window<"u"?`${window.location.protocol}//${window.location.host}`:"";if(!q)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 z=await fetch(`${q}/api/room`,{method:"POST",headers:B,body:JSON.stringify(h)});if(!z.ok){let J=await z.json().catch(()=>({error:"unknown",message:"Failed to create room"}));throw Error(J.message||"Failed to create room")}let{room:G}=await z.json();return new W(G,h)}constructor(h,q={}){if(this.roomId=h,this.config=q,q.iceServers)this.iceServers=q.iceServers}on(h,q){return this.events.on(h,q)}async connect(){this.status="connecting",this.events.emit("status","connecting");try{await this.setupMediaAndAudio(),this.connectSocket()}catch(h){throw this.handleError(H.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[H.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(q){if(q.name==="NotFoundError"||q.message.includes("Requested device not found"))throw Error(N[H.MICROPHONE_NOT_FOUND]);if(q.name==="NotAllowedError"||q.message.includes("Permission denied"))throw Error(N[H.MICROPHONE_PERMISSION_DENIED]);throw q}}this.setupAudioAnalysis(this.localStream)}setupAudioAnalysis(h){let q=window.AudioContext||window.webkitAudioContext;this.audioContext=new q;let B=this.audioContext.createMediaStreamSource(h);this.analyser=this.audioContext.createAnalyser(),this.analyser.fftSize=256,B.connect(this.analyser);let z=new Uint8Array(this.analyser.frequencyBinCount),G=()=>{if(!this.analyser)return;this.analyser.getByteFrequencyData(z);let J=z.reduce((P,O)=>P+O,0)/z.length,K=Math.min(J/128,1);this.events.emit("local-audio-level",K),this.animationFrame=requestAnimationFrame(G)};G()}getServerUrl(){let h,q=this.config;if(typeof window>"u"){if(!q.serverUrl)throw Error("VocaConfig.serverUrl is required in non-browser environments");h=q.serverUrl}else if(q.serverUrl)h=q.serverUrl;else h=`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}`;if(q.apiKey){let B=h.includes("?")?"&":"?";h+=`${B}apiKey=${encodeURIComponent(q.apiKey)}`}return`${h}/ws/${this.roomId}`}connectSocket(){let h=this.getServerUrl();this.ws=new WebSocket(h),this.ws.onopen=()=>{this.send({type:"hello",version:"1.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 q=this.config.reconnect?.enabled!==!1,B=this.config.reconnect?.maxAttempts??5,z=this.config.reconnect?.baseDelayMs??1000;if(q&&this.shouldReconnect&&this.reconnectAttempts<B){this.status="reconnecting",this.events.emit("status","reconnecting");let G=Math.min(z*Math.pow(2,this.reconnectAttempts),30000);this.reconnectAttempts++,this.reconnectTimeout=setTimeout(()=>{this.connectSocket()},G)}else this.status="disconnected",this.events.emit("status","disconnected")},this.ws.onerror=()=>this.handleError(H.WEBSOCKET_ERROR,"WebSocket connection failed"),this.ws.onmessage=(q)=>{let B=JSON.parse(q.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":await this.createPeer(h.from,!0);break;case"offer":await this.createPeer(h.from,!1,h.sdp);break;case"answer":let q=this.peers.get(h.from);if(q)await q.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.ws?.send(JSON.stringify({from:"",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,q,B){let z=new RTCPeerConnection({iceServers:this.iceServers});if(z.onicecandidate=(G)=>{if(G.candidate)this.send({type:"ice",to:h,candidate:JSON.stringify(G.candidate)})},z.ontrack=(G)=>{this.events.emit("track",h,G.track,G.streams[0]),this.setupRemoteAudio(h,G.streams[0]);let J=this.peers.get(h);if(J)J.stream=G.streams[0]},this.localStream?.getTracks().forEach((G)=>z.addTrack(G,this.localStream)),this.peers.set(h,{id:h,connection:z,audioLevel:0}),this.events.emit("peer-joined",h),q){let G=await z.createOffer();await z.setLocalDescription(G),this.send({type:"offer",to:h,sdp:G.sdp})}else if(B){await z.setRemoteDescription({type:"offer",sdp:B});let G=await z.createAnswer();await z.setLocalDescription(G),this.send({type:"answer",to:h,sdp:G.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(),this.peerAnalysers.delete(h);this.events.emit("peer-left",h)}setupRemoteAudio(h,q){if(!this.audioContext){let K=window.AudioContext||window.webkitAudioContext;this.audioContext=new K}let B=this.audioContext.createMediaStreamSource(q),z=this.audioContext.createAnalyser();z.fftSize=256,B.connect(z),B.connect(this.audioContext.destination),this.peerAnalysers.set(h,{source:B,analyser:z});let G=new Uint8Array(z.frequencyBinCount),J=()=>{let K=this.peers.get(h);if(!K)return;z.getByteFrequencyData(G);let P=G.reduce((X,Y)=>X+Y,0)/G.length,O=Math.min(P/128,1);this.events.emit("peer-audio-level",h,O),K.audioLevel=O,requestAnimationFrame(J)};J()}send(h){if(this.ws?.readyState===WebSocket.OPEN)this.ws.send(JSON.stringify({from:"",...h}))}handleError(h,q){this.status=h===H.ROOM_FULL||h==="room_full"?"full":"error",this.events.emit("status",this.status),this.events.emit("error",{code:h,message:q})}}export{Z as createVocaError,N as VocaErrorMessages,H as VocaErrorCode,W as VocaClient};
|
|
2
|
+
|
|
3
|
+
//# debugId=6D5B0730A9EDB40E64756E2164756E21
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../node_modules/.bun/nanoevents@9.1.0/node_modules/nanoevents/index.js", "../src/errors.ts", "../src/index.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
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
|
+
"/**\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} 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};\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';\n\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 /**\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\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 // Determine the HTTP base URL from serverUrl or window.location\n const serverUrl = config.serverUrl\n ? config.serverUrl.replace(/^ws/, 'http')\n : (typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.host}` : '');\n\n if (!serverUrl) {\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 const response = await fetch(`${serverUrl}/api/room`, {\n method: 'POST',\n headers,\n body: JSON.stringify(config)\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 } = await response.json();\n return new VocaClient(room, config);\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 // turnServers is no longer part of VocaConfig, removed this line\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 * Infer the WebSocket server URL from config or environment.\n */\n private getServerUrl(): string {\n let url: string;\n const config = this.config;\n\n // Non-browser environments require explicit serverUrl\n if (typeof window === 'undefined') {\n if (!config.serverUrl) {\n throw new Error('VocaConfig.serverUrl is required in non-browser environments');\n }\n url = config.serverUrl;\n } else {\n if (config.serverUrl) {\n url = config.serverUrl;\n } else {\n const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n url = `${protocol}//${window.location.host}`;\n }\n }\n\n // Append apiKey if present\n if (config.apiKey) {\n const separator = url.includes('?') ? '&' : '?';\n url += `${separator}apiKey=${encodeURIComponent(config.apiKey)}`;\n }\n\n return `${url}/ws/${this.roomId}`;\n }\n\n private connectSocket() {\n const socketUrl = this.getServerUrl();\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: '1.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.ws?.send(JSON.stringify({ from: '', 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?.readyState === WebSocket.OPEN) {\n this.ws.send(JSON.stringify({ from: '', ...msg }));\n }\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
|
+
],
|
|
9
|
+
"mappings": "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",
|
|
10
|
+
"debugId": "6D5B0730A9EDB40E64756E2164756E21",
|
|
11
|
+
"names": []
|
|
12
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@treyorr/voca-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Core TypeScript SDK for Voca Signaling",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "bun build src/index.ts --outdir dist --format esm --sourcemap=external --minify && tsc --emitDeclarationOnly --outDir dist",
|
|
17
|
+
"dev": "bun build src/index.ts --outdir dist --format esm --watch",
|
|
18
|
+
"lint": "tsc --noEmit",
|
|
19
|
+
"test": "bun test",
|
|
20
|
+
"test:watch": "bun test --watch"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"webrtc",
|
|
24
|
+
"signaling",
|
|
25
|
+
"voca"
|
|
26
|
+
],
|
|
27
|
+
"author": "Trey Orr",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/treyorr/voca.git",
|
|
32
|
+
"directory": "packages/voca-client"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://voca.vc",
|
|
35
|
+
"bugs": "https://github.com/treyorr/voca/issues",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"typescript": "^5.9.3"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"nanoevents": "^9.1.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
|
2
|
+
import { VocaClient } from '../index';
|
|
3
|
+
|
|
4
|
+
// Mock WebSocket
|
|
5
|
+
class MockWebSocket {
|
|
6
|
+
static OPEN = 1;
|
|
7
|
+
static CLOSED = 3;
|
|
8
|
+
|
|
9
|
+
readyState = MockWebSocket.OPEN;
|
|
10
|
+
onopen: (() => void) | null = null;
|
|
11
|
+
onclose: (() => void) | null = null;
|
|
12
|
+
onmessage: ((e: { data: string }) => void) | null = null;
|
|
13
|
+
onerror: (() => void) | null = null;
|
|
14
|
+
|
|
15
|
+
constructor(public url: string) {
|
|
16
|
+
// Simulate async connection
|
|
17
|
+
setTimeout(() => this.onopen?.(), 0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
send = mock();
|
|
21
|
+
close = mock(() => {
|
|
22
|
+
this.readyState = MockWebSocket.CLOSED;
|
|
23
|
+
this.onclose?.();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Mock navigator.mediaDevices
|
|
28
|
+
const createMockMediaStream = () => {
|
|
29
|
+
const trackState = { enabled: true };
|
|
30
|
+
const mockTrack = {
|
|
31
|
+
stop: mock(),
|
|
32
|
+
};
|
|
33
|
+
Object.defineProperty(mockTrack, 'enabled', {
|
|
34
|
+
get: () => trackState.enabled,
|
|
35
|
+
set: (v: boolean) => { trackState.enabled = v; },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
getTracks: () => [mockTrack],
|
|
40
|
+
getAudioTracks: () => [mockTrack],
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const mockGetUserMedia = mock().mockImplementation(() => Promise.resolve(createMockMediaStream()));
|
|
45
|
+
|
|
46
|
+
// Mock AudioContext
|
|
47
|
+
class MockAudioContext {
|
|
48
|
+
state = 'running';
|
|
49
|
+
createMediaStreamSource = mock(() => ({ connect: mock() }));
|
|
50
|
+
createAnalyser = mock(() => ({
|
|
51
|
+
fftSize: 256,
|
|
52
|
+
frequencyBinCount: 128,
|
|
53
|
+
getByteFrequencyData: mock(),
|
|
54
|
+
}));
|
|
55
|
+
close = mock().mockResolvedValue(undefined);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Setup global mocks
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
(globalThis as any).WebSocket = MockWebSocket;
|
|
61
|
+
(globalThis as any).navigator = {
|
|
62
|
+
mediaDevices: { getUserMedia: mockGetUserMedia },
|
|
63
|
+
};
|
|
64
|
+
(globalThis as any).AudioContext = MockAudioContext;
|
|
65
|
+
(globalThis as any).window = {
|
|
66
|
+
location: { protocol: 'https:', host: 'localhost:3001', origin: 'https://localhost:3001' },
|
|
67
|
+
isSecureContext: true,
|
|
68
|
+
AudioContext: MockAudioContext,
|
|
69
|
+
};
|
|
70
|
+
(globalThis as any).requestAnimationFrame = mock();
|
|
71
|
+
(globalThis as any).cancelAnimationFrame = mock();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
// Bun doesn't have restoreAllMocks, but we can manually clean up
|
|
76
|
+
mockGetUserMedia.mockClear();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('VocaClient', () => {
|
|
80
|
+
describe('constructor', () => {
|
|
81
|
+
it('should create a client with roomId', () => {
|
|
82
|
+
const client = new VocaClient('test-room');
|
|
83
|
+
expect(client).toBeDefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should accept custom config', () => {
|
|
87
|
+
const client = new VocaClient('test-room', {
|
|
88
|
+
signalingUrl: 'wss://custom.example.com',
|
|
89
|
+
});
|
|
90
|
+
expect(client).toBeDefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should accept custom config with apiKey', () => {
|
|
94
|
+
const client = new VocaClient('test-room', {
|
|
95
|
+
apiKey: 'test-key',
|
|
96
|
+
});
|
|
97
|
+
expect(client).toBeDefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should include apiKey in WebSocket URL', () => {
|
|
101
|
+
const client = new VocaClient('test-room', {
|
|
102
|
+
apiKey: 'test-api-key',
|
|
103
|
+
});
|
|
104
|
+
expect(client).toBeDefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('createRoom', () => {
|
|
109
|
+
it('should create a room via API and return client', async () => {
|
|
110
|
+
globalThis.fetch = mock(() => Promise.resolve({
|
|
111
|
+
ok: true,
|
|
112
|
+
json: () => Promise.resolve({ roomId: 'new-room' }),
|
|
113
|
+
} as Response));
|
|
114
|
+
|
|
115
|
+
const client = await VocaClient.createRoom();
|
|
116
|
+
expect(client).toBeDefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should include x-api-key header when apiKey is configured', async () => {
|
|
120
|
+
const fetchMock = mock(() => Promise.resolve({
|
|
121
|
+
ok: true,
|
|
122
|
+
json: () => Promise.resolve({ roomId: 'new-room' }),
|
|
123
|
+
} as Response));
|
|
124
|
+
globalThis.fetch = fetchMock;
|
|
125
|
+
|
|
126
|
+
await VocaClient.createRoom({ apiKey: 'test-key' });
|
|
127
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should throw on API error', async () => {
|
|
131
|
+
globalThis.fetch = mock(() => Promise.resolve({
|
|
132
|
+
ok: false,
|
|
133
|
+
status: 500,
|
|
134
|
+
} as Response));
|
|
135
|
+
|
|
136
|
+
await expect(VocaClient.createRoom()).rejects.toThrow();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('connect', () => {
|
|
141
|
+
it('should emit status events during connection', async () => {
|
|
142
|
+
const client = new VocaClient('test-room');
|
|
143
|
+
const statusEvents: string[] = [];
|
|
144
|
+
|
|
145
|
+
client.on('status', (status) => {
|
|
146
|
+
statusEvents.push(status);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await client.connect();
|
|
150
|
+
|
|
151
|
+
expect(statusEvents.length).toBeGreaterThan(0);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('disconnect', () => {
|
|
156
|
+
it('should clean up resources on disconnect', async () => {
|
|
157
|
+
const client = new VocaClient('test-room');
|
|
158
|
+
await client.connect();
|
|
159
|
+
client.disconnect();
|
|
160
|
+
|
|
161
|
+
expect(client).toBeDefined();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('toggleMute', () => {
|
|
166
|
+
it('should return mute state when called', async () => {
|
|
167
|
+
const client = new VocaClient('test-room');
|
|
168
|
+
await client.connect();
|
|
169
|
+
|
|
170
|
+
const result = client.toggleMute();
|
|
171
|
+
expect(typeof result).toBe('boolean');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('event emitter', () => {
|
|
176
|
+
it('should allow subscribing to events', () => {
|
|
177
|
+
const client = new VocaClient('test-room');
|
|
178
|
+
const handler = mock();
|
|
179
|
+
|
|
180
|
+
client.on('status', handler);
|
|
181
|
+
expect(handler).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voca Error Codes
|
|
3
|
+
*
|
|
4
|
+
* Unified error codes used across all Voca SDKs and the signaling server.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const VocaErrorCode = {
|
|
8
|
+
// Room errors
|
|
9
|
+
ROOM_NOT_FOUND: 'room_not_found',
|
|
10
|
+
ROOM_FULL: 'room_full',
|
|
11
|
+
MAX_ROOMS_REACHED: 'max_rooms_reached',
|
|
12
|
+
INVALID_ROOM_ID: 'invalid_room_id',
|
|
13
|
+
|
|
14
|
+
// Connection errors
|
|
15
|
+
CONNECTION_FAILED: 'connection_failed',
|
|
16
|
+
WEBSOCKET_ERROR: 'websocket_error',
|
|
17
|
+
HEARTBEAT_TIMEOUT: 'heartbeat_timeout',
|
|
18
|
+
|
|
19
|
+
// Media errors
|
|
20
|
+
MICROPHONE_NOT_FOUND: 'microphone_not_found',
|
|
21
|
+
MICROPHONE_PERMISSION_DENIED: 'microphone_permission_denied',
|
|
22
|
+
INSECURE_CONTEXT: 'insecure_context',
|
|
23
|
+
|
|
24
|
+
// Signaling errors
|
|
25
|
+
INVALID_MESSAGE: 'invalid_message',
|
|
26
|
+
PEER_NOT_FOUND: 'peer_not_found',
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export type VocaErrorCode = typeof VocaErrorCode[keyof typeof VocaErrorCode];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Human-readable error messages for each error code
|
|
33
|
+
*/
|
|
34
|
+
export const VocaErrorMessages: Record<VocaErrorCode, string> = {
|
|
35
|
+
[VocaErrorCode.ROOM_NOT_FOUND]: 'Room not found',
|
|
36
|
+
[VocaErrorCode.ROOM_FULL]: 'Room is at maximum capacity',
|
|
37
|
+
[VocaErrorCode.MAX_ROOMS_REACHED]: 'Maximum number of rooms reached',
|
|
38
|
+
[VocaErrorCode.INVALID_ROOM_ID]: 'Invalid room ID format',
|
|
39
|
+
[VocaErrorCode.CONNECTION_FAILED]: 'Failed to connect to signaling server',
|
|
40
|
+
[VocaErrorCode.WEBSOCKET_ERROR]: 'WebSocket connection error',
|
|
41
|
+
[VocaErrorCode.HEARTBEAT_TIMEOUT]: 'Connection lost due to heartbeat timeout',
|
|
42
|
+
[VocaErrorCode.MICROPHONE_NOT_FOUND]: 'No microphone found. Please connect a microphone and try again.',
|
|
43
|
+
[VocaErrorCode.MICROPHONE_PERMISSION_DENIED]: 'Microphone permission denied. Please allow microphone access.',
|
|
44
|
+
[VocaErrorCode.INSECURE_CONTEXT]: 'HTTPS is required for microphone access',
|
|
45
|
+
[VocaErrorCode.INVALID_MESSAGE]: 'Invalid signaling message received',
|
|
46
|
+
[VocaErrorCode.PEER_NOT_FOUND]: 'Peer not found in room',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Voca error with code and message
|
|
51
|
+
*/
|
|
52
|
+
export interface VocaError {
|
|
53
|
+
code: VocaErrorCode;
|
|
54
|
+
message: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a VocaError from a code
|
|
59
|
+
*/
|
|
60
|
+
export function createVocaError(code: VocaErrorCode, customMessage?: string): VocaError {
|
|
61
|
+
return {
|
|
62
|
+
code,
|
|
63
|
+
message: customMessage ?? VocaErrorMessages[code],
|
|
64
|
+
};
|
|
65
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { createNanoEvents } from 'nanoevents';
|
|
2
|
+
import { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';
|
|
3
|
+
|
|
4
|
+
export { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';
|
|
5
|
+
|
|
6
|
+
export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'full' | 'error' | 'disconnected';
|
|
7
|
+
|
|
8
|
+
export interface VocaConfig {
|
|
9
|
+
debug?: boolean;
|
|
10
|
+
iceServers?: RTCIceServer[];
|
|
11
|
+
serverUrl?: string; // e.g. "ws://localhost:3001" or "wss://voca.vc"
|
|
12
|
+
apiKey?: string; // optional API key for signaling server auth
|
|
13
|
+
/**
|
|
14
|
+
* Reconnection options. Enabled by default.
|
|
15
|
+
*/
|
|
16
|
+
reconnect?: {
|
|
17
|
+
/** Enable automatic reconnection. Default: true */
|
|
18
|
+
enabled?: boolean;
|
|
19
|
+
/** Maximum reconnection attempts. Default: 5 */
|
|
20
|
+
maxAttempts?: number;
|
|
21
|
+
/** Base delay in milliseconds. Default: 1000 */
|
|
22
|
+
baseDelayMs?: number;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Peer {
|
|
27
|
+
id: string;
|
|
28
|
+
connection: RTCPeerConnection;
|
|
29
|
+
audioLevel: number;
|
|
30
|
+
stream?: MediaStream;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type SignalMessage = {
|
|
34
|
+
from: string;
|
|
35
|
+
type: 'hello' | 'welcome' | 'join' | 'leave' | 'offer' | 'answer' | 'ice' | 'ping' | 'pong' | 'error';
|
|
36
|
+
peer_id?: string;
|
|
37
|
+
to?: string;
|
|
38
|
+
sdp?: string;
|
|
39
|
+
candidate?: string;
|
|
40
|
+
code?: string;
|
|
41
|
+
message?: string;
|
|
42
|
+
// Protocol versioning
|
|
43
|
+
version?: string;
|
|
44
|
+
client?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
interface VocaEvents {
|
|
48
|
+
'status': (status: ConnectionStatus) => void;
|
|
49
|
+
'error': (error: VocaError) => void;
|
|
50
|
+
'warning': (warning: { code: string; message: string }) => void;
|
|
51
|
+
'peer-joined': (peerId: string) => void;
|
|
52
|
+
'peer-left': (peerId: string) => void;
|
|
53
|
+
'peer-audio-level': (peerId: string, level: number) => void;
|
|
54
|
+
'local-audio-level': (level: number) => void;
|
|
55
|
+
'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
|
|
59
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
60
|
+
{ urls: 'stun:stun1.l.google.com:19302' },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export class VocaClient {
|
|
64
|
+
public peers: Map<string, Peer> = new Map();
|
|
65
|
+
public localStream: MediaStream | null = null;
|
|
66
|
+
public isMuted: boolean = false;
|
|
67
|
+
public status: ConnectionStatus = 'connecting';
|
|
68
|
+
public roomId: string;
|
|
69
|
+
|
|
70
|
+
private events = createNanoEvents<VocaEvents>();
|
|
71
|
+
private ws: WebSocket | null = null;
|
|
72
|
+
private audioContext: AudioContext | null = null;
|
|
73
|
+
private analyser: AnalyserNode | null = null;
|
|
74
|
+
private animationFrame: number | null = null;
|
|
75
|
+
private iceServers: RTCIceServer[] = DEFAULT_ICE_SERVERS;
|
|
76
|
+
private config: VocaConfig;
|
|
77
|
+
|
|
78
|
+
// Reconnection state
|
|
79
|
+
private reconnectAttempts = 0;
|
|
80
|
+
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
81
|
+
private shouldReconnect = true;
|
|
82
|
+
|
|
83
|
+
// Audio analysis nodes per peer (for cleanup)
|
|
84
|
+
private peerAnalysers: Map<string, { source: MediaStreamAudioSourceNode; analyser: AnalyserNode }> = new Map();
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a new room and return a VocaClient connected to it.
|
|
88
|
+
* This is a convenience method that handles room creation via the API.
|
|
89
|
+
*
|
|
90
|
+
* @param config - VocaClient configuration (serverUrl required for non-browser environments)
|
|
91
|
+
* @returns Promise<VocaClient> - A new client instance for the created room
|
|
92
|
+
*/
|
|
93
|
+
static async createRoom(config: VocaConfig = {}): Promise<VocaClient> {
|
|
94
|
+
// Determine the HTTP base URL from serverUrl or window.location
|
|
95
|
+
const serverUrl = config.serverUrl
|
|
96
|
+
? config.serverUrl.replace(/^ws/, 'http')
|
|
97
|
+
: (typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.host}` : '');
|
|
98
|
+
|
|
99
|
+
if (!serverUrl) {
|
|
100
|
+
throw new Error('VocaConfig.serverUrl is required in non-browser environments');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const headers: HeadersInit = {
|
|
104
|
+
'Content-Type': 'application/json',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (config.apiKey) {
|
|
108
|
+
headers['x-api-key'] = config.apiKey;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const response = await fetch(`${serverUrl}/api/room`, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers,
|
|
114
|
+
body: JSON.stringify(config)
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
const error = await response.json().catch(() => ({ error: 'unknown', message: 'Failed to create room' }));
|
|
119
|
+
throw new Error(error.message || 'Failed to create room');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const { room } = await response.json();
|
|
123
|
+
return new VocaClient(room, config);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
constructor(roomId: string, config: VocaConfig = {}) {
|
|
127
|
+
this.roomId = roomId;
|
|
128
|
+
this.config = config;
|
|
129
|
+
if (config.iceServers) this.iceServers = config.iceServers;
|
|
130
|
+
// turnServers is no longer part of VocaConfig, removed this line
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
public on<E extends keyof VocaEvents>(event: E, callback: VocaEvents[E]) {
|
|
134
|
+
return this.events.on(event, callback);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
public async connect(): Promise<void> {
|
|
138
|
+
this.status = 'connecting';
|
|
139
|
+
this.events.emit('status', 'connecting');
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
// Use default STUN servers (Google's public STUN servers)
|
|
143
|
+
await this.setupMediaAndAudio();
|
|
144
|
+
this.connectSocket();
|
|
145
|
+
} catch (err) {
|
|
146
|
+
this.handleError(VocaErrorCode.CONNECTION_FAILED, err instanceof Error ? err.message : 'Failed to connect');
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
public disconnect() {
|
|
152
|
+
// Prevent reconnection attempts
|
|
153
|
+
this.shouldReconnect = false;
|
|
154
|
+
if (this.reconnectTimeout) {
|
|
155
|
+
clearTimeout(this.reconnectTimeout);
|
|
156
|
+
this.reconnectTimeout = null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
|
|
160
|
+
this.peers.forEach((p) => p.connection.close());
|
|
161
|
+
this.peers.clear();
|
|
162
|
+
this.ws?.close();
|
|
163
|
+
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
164
|
+
if (this.audioContext && this.audioContext.state !== 'closed') {
|
|
165
|
+
this.audioContext.close().catch(e => console.warn('Error closing AudioContext', e));
|
|
166
|
+
}
|
|
167
|
+
this.status = 'disconnected';
|
|
168
|
+
this.events.emit('status', 'disconnected');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public toggleMute() {
|
|
172
|
+
const track = this.localStream?.getAudioTracks()[0];
|
|
173
|
+
if (track) {
|
|
174
|
+
track.enabled = !track.enabled;
|
|
175
|
+
this.isMuted = !track.enabled;
|
|
176
|
+
}
|
|
177
|
+
return this.isMuted;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
private async setupMediaAndAudio() {
|
|
183
|
+
// Only verify secure context in browsers
|
|
184
|
+
if (typeof window !== 'undefined' && !window.isSecureContext) {
|
|
185
|
+
throw new Error(VocaErrorMessages[VocaErrorCode.INSECURE_CONTEXT]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
this.localStream = await navigator.mediaDevices.getUserMedia({
|
|
190
|
+
audio: {
|
|
191
|
+
echoCancellation: true,
|
|
192
|
+
noiseSuppression: true,
|
|
193
|
+
autoGainControl: true,
|
|
194
|
+
latency: 0,
|
|
195
|
+
channelCount: 1
|
|
196
|
+
} as any,
|
|
197
|
+
video: false,
|
|
198
|
+
});
|
|
199
|
+
} catch (err) {
|
|
200
|
+
try {
|
|
201
|
+
this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
|
202
|
+
} catch (retryErr: any) {
|
|
203
|
+
if (retryErr.name === 'NotFoundError' || retryErr.message.includes('Requested device not found')) {
|
|
204
|
+
throw new Error(VocaErrorMessages[VocaErrorCode.MICROPHONE_NOT_FOUND]);
|
|
205
|
+
}
|
|
206
|
+
if (retryErr.name === 'NotAllowedError' || retryErr.message.includes('Permission denied')) {
|
|
207
|
+
throw new Error(VocaErrorMessages[VocaErrorCode.MICROPHONE_PERMISSION_DENIED]);
|
|
208
|
+
}
|
|
209
|
+
throw retryErr;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
this.setupAudioAnalysis(this.localStream!);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private setupAudioAnalysis(stream: MediaStream) {
|
|
217
|
+
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
|
|
218
|
+
this.audioContext = new AudioContextClass();
|
|
219
|
+
const source = this.audioContext.createMediaStreamSource(stream);
|
|
220
|
+
this.analyser = this.audioContext.createAnalyser();
|
|
221
|
+
this.analyser.fftSize = 256;
|
|
222
|
+
source.connect(this.analyser);
|
|
223
|
+
|
|
224
|
+
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
|
|
225
|
+
const update = () => {
|
|
226
|
+
if (!this.analyser) return;
|
|
227
|
+
this.analyser.getByteFrequencyData(dataArray);
|
|
228
|
+
const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
|
|
229
|
+
const level = Math.min(avg / 128, 1);
|
|
230
|
+
this.events.emit('local-audio-level', level);
|
|
231
|
+
this.animationFrame = requestAnimationFrame(update);
|
|
232
|
+
};
|
|
233
|
+
update();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Infer the WebSocket server URL from config or environment.
|
|
238
|
+
*/
|
|
239
|
+
private getServerUrl(): string {
|
|
240
|
+
let url: string;
|
|
241
|
+
const config = this.config;
|
|
242
|
+
|
|
243
|
+
// Non-browser environments require explicit serverUrl
|
|
244
|
+
if (typeof window === 'undefined') {
|
|
245
|
+
if (!config.serverUrl) {
|
|
246
|
+
throw new Error('VocaConfig.serverUrl is required in non-browser environments');
|
|
247
|
+
}
|
|
248
|
+
url = config.serverUrl;
|
|
249
|
+
} else {
|
|
250
|
+
if (config.serverUrl) {
|
|
251
|
+
url = config.serverUrl;
|
|
252
|
+
} else {
|
|
253
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
254
|
+
url = `${protocol}//${window.location.host}`;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Append apiKey if present
|
|
259
|
+
if (config.apiKey) {
|
|
260
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
261
|
+
url += `${separator}apiKey=${encodeURIComponent(config.apiKey)}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return `${url}/ws/${this.roomId}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private connectSocket() {
|
|
268
|
+
const socketUrl = this.getServerUrl();
|
|
269
|
+
|
|
270
|
+
this.ws = new WebSocket(socketUrl);
|
|
271
|
+
|
|
272
|
+
this.ws.onopen = () => {
|
|
273
|
+
// Send hello message with version info
|
|
274
|
+
this.send({ type: 'hello', version: '1.0', client: '@treyorr/voca-client' });
|
|
275
|
+
this.status = 'connected';
|
|
276
|
+
this.events.emit('status', 'connected');
|
|
277
|
+
// Reset reconnect attempts on successful connection
|
|
278
|
+
this.reconnectAttempts = 0;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
this.ws.onclose = () => {
|
|
282
|
+
// Don't reconnect on terminal states
|
|
283
|
+
if (this.status === 'full' || this.status === 'error') {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const reconnectEnabled = this.config.reconnect?.enabled !== false;
|
|
288
|
+
const maxAttempts = this.config.reconnect?.maxAttempts ?? 5;
|
|
289
|
+
const baseDelay = this.config.reconnect?.baseDelayMs ?? 1000;
|
|
290
|
+
|
|
291
|
+
if (reconnectEnabled && this.shouldReconnect && this.reconnectAttempts < maxAttempts) {
|
|
292
|
+
this.status = 'reconnecting';
|
|
293
|
+
this.events.emit('status', 'reconnecting');
|
|
294
|
+
|
|
295
|
+
// Exponential backoff: 1s, 2s, 4s, 8s, 16s (capped at 30s)
|
|
296
|
+
const delay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempts), 30000);
|
|
297
|
+
this.reconnectAttempts++;
|
|
298
|
+
|
|
299
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
300
|
+
this.connectSocket();
|
|
301
|
+
}, delay);
|
|
302
|
+
} else {
|
|
303
|
+
this.status = 'disconnected';
|
|
304
|
+
this.events.emit('status', 'disconnected');
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
this.ws.onerror = () => this.handleError(VocaErrorCode.WEBSOCKET_ERROR, 'WebSocket connection failed');
|
|
309
|
+
|
|
310
|
+
this.ws.onmessage = (e) => {
|
|
311
|
+
const msg: SignalMessage = JSON.parse(e.data);
|
|
312
|
+
this.handleSignal(msg);
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private async handleSignal(msg: SignalMessage) {
|
|
317
|
+
switch (msg.type) {
|
|
318
|
+
case 'welcome':
|
|
319
|
+
// Protocol handshake complete - peer_id is managed server-side
|
|
320
|
+
console.debug('[Voca] Protocol version:', msg.version, 'Peer ID:', msg.peer_id);
|
|
321
|
+
break;
|
|
322
|
+
case 'join':
|
|
323
|
+
await this.createPeer(msg.from, true);
|
|
324
|
+
break;
|
|
325
|
+
case 'offer':
|
|
326
|
+
await this.createPeer(msg.from, false, msg.sdp);
|
|
327
|
+
break;
|
|
328
|
+
case 'answer':
|
|
329
|
+
const peer = this.peers.get(msg.from);
|
|
330
|
+
if (peer) {
|
|
331
|
+
await peer.connection.setRemoteDescription({ type: 'answer', sdp: msg.sdp! });
|
|
332
|
+
}
|
|
333
|
+
break;
|
|
334
|
+
case 'ice':
|
|
335
|
+
const p = this.peers.get(msg.from);
|
|
336
|
+
if (p) {
|
|
337
|
+
try {
|
|
338
|
+
await p.connection.addIceCandidate(JSON.parse(msg.candidate!));
|
|
339
|
+
} catch (e) {
|
|
340
|
+
console.warn('[Voca] Invalid ICE candidate:', e);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
break;
|
|
344
|
+
case 'ping':
|
|
345
|
+
this.ws?.send(JSON.stringify({ from: '', type: 'pong' }));
|
|
346
|
+
break;
|
|
347
|
+
case 'leave':
|
|
348
|
+
this.removePeer(msg.from);
|
|
349
|
+
break;
|
|
350
|
+
case 'error':
|
|
351
|
+
this.handleError(msg.code ?? 'unknown', msg.message ?? 'Unknown error');
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private async createPeer(peerId: string, isInitiator: boolean, remoteSdp?: string) {
|
|
357
|
+
const pc = new RTCPeerConnection({ iceServers: this.iceServers });
|
|
358
|
+
|
|
359
|
+
pc.onicecandidate = (e) => {
|
|
360
|
+
if (e.candidate) this.send({ type: 'ice', to: peerId, candidate: JSON.stringify(e.candidate) });
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
pc.ontrack = (e) => {
|
|
364
|
+
// Emit track event so UI can handle the audio element/stream
|
|
365
|
+
this.events.emit('track', peerId, e.track, e.streams[0]);
|
|
366
|
+
this.setupRemoteAudio(peerId, e.streams[0]);
|
|
367
|
+
const peer = this.peers.get(peerId);
|
|
368
|
+
if (peer) peer.stream = e.streams[0];
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// Add local tracks
|
|
372
|
+
this.localStream?.getTracks().forEach((track) => pc.addTrack(track, this.localStream!));
|
|
373
|
+
|
|
374
|
+
this.peers.set(peerId, { id: peerId, connection: pc, audioLevel: 0 });
|
|
375
|
+
this.events.emit('peer-joined', peerId);
|
|
376
|
+
|
|
377
|
+
if (isInitiator) {
|
|
378
|
+
const offer = await pc.createOffer();
|
|
379
|
+
await pc.setLocalDescription(offer);
|
|
380
|
+
this.send({ type: 'offer', to: peerId, sdp: offer.sdp });
|
|
381
|
+
} else if (remoteSdp) {
|
|
382
|
+
await pc.setRemoteDescription({ type: 'offer', sdp: remoteSdp });
|
|
383
|
+
const answer = await pc.createAnswer();
|
|
384
|
+
await pc.setLocalDescription(answer);
|
|
385
|
+
this.send({ type: 'answer', to: peerId, sdp: answer.sdp });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private removePeer(peerId: string) {
|
|
390
|
+
const peer = this.peers.get(peerId);
|
|
391
|
+
peer?.connection.close();
|
|
392
|
+
this.peers.delete(peerId);
|
|
393
|
+
|
|
394
|
+
// Clean up audio analysis nodes
|
|
395
|
+
const audio = this.peerAnalysers.get(peerId);
|
|
396
|
+
if (audio) {
|
|
397
|
+
audio.source.disconnect();
|
|
398
|
+
audio.analyser.disconnect();
|
|
399
|
+
this.peerAnalysers.delete(peerId);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
this.events.emit('peer-left', peerId);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private setupRemoteAudio(peerId: string, stream: MediaStream) {
|
|
406
|
+
if (!this.audioContext) {
|
|
407
|
+
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
|
|
408
|
+
this.audioContext = new AudioContextClass();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const source = this.audioContext.createMediaStreamSource(stream);
|
|
412
|
+
const analyser = this.audioContext.createAnalyser();
|
|
413
|
+
analyser.fftSize = 256;
|
|
414
|
+
source.connect(analyser);
|
|
415
|
+
|
|
416
|
+
// CRITICAL: Connect to speakers so audio is actually played!
|
|
417
|
+
source.connect(this.audioContext.destination);
|
|
418
|
+
|
|
419
|
+
// Store for cleanup when peer leaves
|
|
420
|
+
this.peerAnalysers.set(peerId, { source, analyser });
|
|
421
|
+
|
|
422
|
+
const data = new Uint8Array(analyser.frequencyBinCount);
|
|
423
|
+
const update = () => {
|
|
424
|
+
const peer = this.peers.get(peerId);
|
|
425
|
+
if (!peer) return;
|
|
426
|
+
|
|
427
|
+
analyser.getByteFrequencyData(data);
|
|
428
|
+
const avg = data.reduce((a, b) => a + b, 0) / data.length;
|
|
429
|
+
const level = Math.min(avg / 128, 1);
|
|
430
|
+
|
|
431
|
+
this.events.emit('peer-audio-level', peerId, level);
|
|
432
|
+
|
|
433
|
+
// Store in peer object as well for convenience
|
|
434
|
+
peer.audioLevel = level;
|
|
435
|
+
|
|
436
|
+
requestAnimationFrame(update);
|
|
437
|
+
};
|
|
438
|
+
update();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private send(msg: Partial<SignalMessage>) {
|
|
442
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
443
|
+
this.ws.send(JSON.stringify({ from: '', ...msg }));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private handleError(code: VocaErrorCode | string, message: string) {
|
|
448
|
+
this.status = code === VocaErrorCode.ROOM_FULL || code === 'room_full' ? 'full' : 'error';
|
|
449
|
+
this.events.emit('status', this.status);
|
|
450
|
+
this.events.emit('error', { code: code as VocaErrorCode, message });
|
|
451
|
+
}
|
|
452
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"outDir": "dist",
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": [
|
|
14
|
+
"src"
|
|
15
|
+
],
|
|
16
|
+
"exclude": [
|
|
17
|
+
"src/**/*.test.ts",
|
|
18
|
+
"src/**/__tests__/**/*"
|
|
19
|
+
]
|
|
20
|
+
}
|