Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"vite": "^7.3.0"
},
"dependencies": {
"@treyorr/voca-svelte": "^0.3.0",
"@treyorr/voca-svelte": "^0.4.0",
"@lucide/svelte": "^0.574.0"
}
}
26 changes: 23 additions & 3 deletions apps/web/src/routes/[room]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,30 @@
{#each Array.from(room?.peers.entries() ?? []) as [peerId, peer]}
<div class="peer-box" style={room?.getPulseStyle(peer.audioLevel)}>
<div class="flex-1 min-w-0">
<p class="font-bold text-sm sm:text-base">PEER</p>
<p class="text-xs font-mono truncate">{peerId.slice(0, 6)}</p>
<div class="flex items-center gap-2">
<p class="font-bold text-sm sm:text-base">PEER</p>
<span class="font-mono text-xs opacity-70">
{peerId.slice(0, 6)}
</span>
</div>
<p class="text-xs">
{#if peer.remoteMuted}
[SELF-MUTED]
{:else}
[LIVE]
{/if}
</p>
</div>
<div class="text-xs flex-shrink-0">[CONNECTED]</div>
<button
class="brutalist-button text-xs flex-shrink-0"
onclick={() => room?.togglePeerMute(peerId)}
>
{#if peer.localMuted}
[ UNMUTE ]
{:else}
[ MUTE ]
{/if}
</button>
</div>
{/each}
</div>
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/routes/docs/api/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,14 @@ console.log(client.roomId); // e.g. "abc123"`}</pre>
<tr class="border-t border-voca-border">
<td class="p-2 font-mono">toggleMute()</td>
<td class="p-2">boolean</td>
<td class="p-2">Toggle mute, returns new state</td>
<td class="p-2">Toggle local microphone mute, returns new state</td>
</tr>
<tr class="border-t border-voca-border">
<td class="p-2 font-mono">togglePeerMute(peerId)</td>
<td class="p-2">boolean</td>
<td class="p-2"
>Toggle local playback mute for a specific peer, returns new state</td
>
</tr>
<tr class="border-t border-voca-border">
<td class="p-2 font-mono">on(event, callback)</td>
Expand Down Expand Up @@ -155,6 +162,14 @@ console.log(client.roomId); // e.g. "abc123"`}</pre>
<td class="p-2 font-mono">local-audio-level</td>
<td class="p-2">level: number (0-1)</td>
</tr>
<tr class="border-t border-voca-border">
<td class="p-2 font-mono">peer-mute</td>
<td class="p-2">(peerId: string, isMuted: boolean)</td>
</tr>
<tr class="border-t border-voca-border">
<td class="p-2 font-mono">peer-local-mute</td>
<td class="p-2">(peerId: string, isMuted: boolean)</td>
</tr>
</tbody>
</table>

Expand Down
27 changes: 24 additions & 3 deletions apps/web/src/routes/docs/getting-started/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,18 @@ function VoiceRoom({ roomId }: { roomId: string }) {
<p>Status: {status}</p>
<p>Peers: {peers.size}</p>
<button onClick={toggleMute}>
{isMuted ? 'Unmute' : 'Mute'}
{isMuted ? 'Unmute Mic' : 'Mute Mic'}
</button>

{/* Remote peers list */}
{Array.from(peers.entries()).map(([id, peer]) => (
<div key={id}>
Peer {id}
<button onClick={() => togglePeerMute(id)}>
{peer.localMuted ? 'Unmute' : 'Mute'}
</button>
</div>
))}
</div>
);
}`}</pre>
Expand Down Expand Up @@ -184,7 +194,13 @@ client.on('peer-left', (peerId) => console.log('Peer left:', peerId));`}</pre>
<p>Peers: {room.peerCount}</p>

{#each Array.from(room.peers.entries()) as [id, peer]}
<div>Peer {id.slice(0, 6)} - Level: {peer.audioLevel}</div>
<div>
Peer {id.slice(0, 6)} - Level: {peer.audioLevel}
{#if peer.remoteMuted} <span>(Self-Muted)</span> {/if}
<button onclick={() => room.togglePeerMute(id)}>
{peer.localMuted ? 'Unmute Their Audio' : 'Mute Their Audio'}
</button>
</div>
{/each}`}</pre>
{:else}
<pre
Expand All @@ -197,6 +213,7 @@ function VoiceRoom({ roomId }: { roomId: string }) {
isMuted,
localAudioLevel,
toggleMute,
togglePeerMute,
disconnect,
} = useVocaRoom(roomId, {
serverUrl: 'https://voca.vc',
Expand All @@ -211,11 +228,15 @@ function VoiceRoom({ roomId }: { roomId: string }) {
{Array.from(peers.entries()).map(([id, peer]) => (
<div key={id}>
Peer {id.slice(0, 6)} - Level: {peer.audioLevel}
{peer.remoteMuted && <span> (Self-Muted)</span>}
<button onClick={() => togglePeerMute(id)}>
{peer.localMuted ? 'Unmute Their Audio' : 'Mute Their Audio'}
</button>
</div>
))}

<button onClick={toggleMute}>
{isMuted ? 'Unmute' : 'Mute'}
{isMuted ? 'Unmute Mic' : 'Mute Mic'}
</button>
<button onClick={disconnect}>Leave</button>
</div>
Expand Down
12 changes: 6 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/voca-client/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@treyorr/voca-client",
"version": "0.3.0",
"description": "Core TypeScript SDK for Voca Signaling",
"version": "0.4.0",
"description": "Voca WebRTC Client SDK",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
59 changes: 54 additions & 5 deletions packages/voca-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,20 @@ export interface Peer {
connection: RTCPeerConnection;
audioLevel: number;
stream?: MediaStream;
remoteMuted?: boolean;
localMuted?: boolean;
}

type SignalMessage = {
from: string;
type: 'hello' | 'welcome' | 'join' | 'leave' | 'offer' | 'answer' | 'ice' | 'ping' | 'pong' | 'error';
type: 'hello' | 'welcome' | 'join' | 'leave' | 'offer' | 'answer' | 'ice' | 'ping' | 'pong' | 'error' | 'mute';
peer_id?: string;
to?: string;
sdp?: string;
candidate?: string;
code?: string;
message?: string;
muted?: boolean;
// Protocol versioning
version?: string;
client?: string;
Expand All @@ -53,6 +56,8 @@ interface VocaEvents {
'peer-audio-level': (peerId: string, level: number) => void;
'local-audio-level': (level: number) => void;
'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
'peer-mute': (peerId: string, isMuted: boolean) => void;
'peer-local-mute': (peerId: string, isMuted: boolean) => void;
}

/**
Expand Down Expand Up @@ -99,7 +104,7 @@ export class VocaClient {
private shouldReconnect = true;

// Audio analysis nodes per peer (for cleanup)
private peerAnalysers: Map<string, { source: MediaStreamAudioSourceNode; analyser: AnalyserNode }> = new Map();
private peerAnalysers: Map<string, { source: MediaStreamAudioSourceNode; analyser: AnalyserNode; gainNode: GainNode }> = new Map();

/**
* Create a new room and return a VocaClient connected to it.
Expand Down Expand Up @@ -235,10 +240,31 @@ export class VocaClient {
if (track) {
track.enabled = !track.enabled;
this.isMuted = !track.enabled;
// Broadcast our mute state to everyone else
this.send({ type: 'mute', muted: this.isMuted });
}
return this.isMuted;
}

public togglePeerMute(peerId: string) {
const peer = this.peers.get(peerId);
if (!peer) return false;

const isCurrentlyMuted = peer.localMuted ?? false;
const newMutedState = !isCurrentlyMuted;
peer.localMuted = newMutedState;

// Update the gain node
const audioNodes = this.peerAnalysers.get(peerId);
if (audioNodes) {
// Mute by dropping gain to 0, unmute by setting back to 1
audioNodes.gainNode.gain.value = newMutedState ? 0 : 1;
}

this.events.emit('peer-local-mute', peerId, newMutedState);
return newMutedState;
}



private async setupMediaAndAudio() {
Expand Down Expand Up @@ -380,6 +406,10 @@ export class VocaClient {
break;
case 'join':
await this.createPeer(msg.from, true);
if (this.isMuted) {
// Send our mute state specifically to the joined peer
this.send({ type: 'mute', to: msg.from, muted: true });
}
break;
case 'offer':
await this.createPeer(msg.from, false, msg.sdp);
Expand All @@ -406,6 +436,13 @@ export class VocaClient {
case 'leave':
this.removePeer(msg.from);
break;
case 'mute':
const mutePeer = this.peers.get(msg.from);
if (mutePeer) {
mutePeer.remoteMuted = msg.muted ?? false;
this.events.emit('peer-mute', msg.from, mutePeer.remoteMuted);
}
break;
case 'error':
this.handleError(msg.code ?? 'unknown', msg.message ?? 'Unknown error');
break;
Expand All @@ -430,7 +467,7 @@ export class VocaClient {
// Add local tracks
this.localStream?.getTracks().forEach((track) => pc.addTrack(track, this.localStream!));

this.peers.set(peerId, { id: peerId, connection: pc, audioLevel: 0 });
this.peers.set(peerId, { id: peerId, connection: pc, audioLevel: 0, remoteMuted: false, localMuted: false });
this.events.emit('peer-joined', peerId);

if (isInitiator) {
Expand All @@ -455,6 +492,7 @@ export class VocaClient {
if (audio) {
audio.source.disconnect();
audio.analyser.disconnect();
audio.gainNode.disconnect();
this.peerAnalysers.delete(peerId);
}

Expand All @@ -470,13 +508,24 @@ export class VocaClient {
const source = this.audioContext.createMediaStreamSource(stream);
const analyser = this.audioContext.createAnalyser();
analyser.fftSize = 256;

// Create a GainNode for local muting
const gainNode = this.audioContext.createGain();

// Check if the peer is already locally muted (in case they reconnect)
const peer = this.peers.get(peerId);
if (peer?.localMuted) {
gainNode.gain.value = 0;
}

source.connect(analyser);
analyser.connect(gainNode);

// CRITICAL: Connect to speakers so audio is actually played!
source.connect(this.audioContext.destination);
gainNode.connect(this.audioContext.destination);

// Store for cleanup when peer leaves
this.peerAnalysers.set(peerId, { source, analyser });
this.peerAnalysers.set(peerId, { source, analyser, gainNode });

const data = new Uint8Array(analyser.frequencyBinCount);
const update = () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/voca-react/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@treyorr/voca-react",
"version": "0.3.0",
"version": "0.4.0",
"description": "React hooks for Voca WebRTC voice chat",
"main": "dist/index.js",
"module": "dist/index.js",
Expand Down Expand Up @@ -39,7 +39,7 @@
"react": ">=18.0.0"
},
"dependencies": {
"@treyorr/voca-client": "^0.3.0"
"@treyorr/voca-client": "^0.4.0"
},
"devDependencies": {
"@types/react": "^19.2.7",
Expand Down
Loading