This guide walks through integrating the ODIN Voice Web SDK into a Vue 3 application. It uses the Composition API with <script setup> and TypeScript. The code below is based on the tested example in the ODIN Web SDK repository.
This example uses @4players/odin-tokens to generate tokens client-side for simplicity. In production, tokens should be generated on your backend server to protect your access key. See Getting Started for details.
Dependencies
npm install @4players/odin @4players/odin-tokens
Full Example
App.vue
<template>
<div>
<p v-if="error">{{ error }}</p>
<template v-if="!isConnected">
<button @click="joinRoom" :disabled="isConnecting">
{{ isConnecting ? 'Connecting...' : 'Join Voice Chat' }}
</button>
</template>
<template v-else>
<button @click="toggleMute">{{ isMuted ? 'Unmute' : 'Mute' }}</button>
<button @click="leaveRoom">Leave</button>
<h3>Peers ({{ peers.length }})</h3>
<ul>
<li v-for="peer in peers" :key="peer.id">
{{ peer.isLocal ? 'You' : peer.userId }}
<span v-if="speakingPeers.has(peer.id)"> (speaking)</span>
</li>
</ul>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, shallowRef, onUnmounted } from 'vue';
import { Room, DeviceManager, setOutputDevice, type AudioInput as AudioInputType } from '@4players/odin';
import { TokenGenerator } from '@4players/odin-tokens';
interface PeerInfo {
id: number;
userId: string;
isLocal: boolean;
}
const isConnected = ref(false);
const isConnecting = ref(false);
const peers = ref<PeerInfo[]>([]);
const speakingPeers = ref<Set<number>>(new Set());
const isMuted = ref(false);
const error = ref<string | null>(null);
const room = shallowRef<Room | null>(null);
const audioInput = shallowRef<AudioInputType | null>(null);
const joinRoom = async () => {
error.value = null;
isConnecting.value = true;
try {
const generator = new TokenGenerator('YOUR_ACCESS_KEY');
const token = await generator.createToken('default', 'vue-user');
room.value = new Room();
room.value.onJoined = () => {
isConnected.value = true;
isConnecting.value = false;
};
room.value.onLeft = () => {
isConnected.value = false;
isConnecting.value = false;
peers.value = [];
};
room.value.onPeerJoined = (payload) => {
const isLocal = payload.peer.id === room.value!.ownPeerId;
peers.value = [
...peers.value.filter((p) => p.id !== payload.peer.id),
{ id: payload.peer.id, userId: payload.peer.userId, isLocal },
];
payload.peer.onAudioActivity = ({ media }) => {
const next = new Set(speakingPeers.value);
if (media.isActive) {
next.add(payload.peer.id);
} else {
next.delete(payload.peer.id);
}
speakingPeers.value = next;
};
};
room.value.onPeerLeft = (payload) => {
peers.value = peers.value.filter((p) => p.id !== payload.peer.id);
const next = new Set(speakingPeers.value);
next.delete(payload.peer.id);
speakingPeers.value = next;
};
await setOutputDevice({});
await room.value.join(token, { gateway: 'https://gateway.odin.4players.io' });
audioInput.value = await DeviceManager.createAudioInput();
await room.value.addAudioInput(audioInput.value);
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to join room';
isConnecting.value = false;
}
};
const leaveRoom = () => {
if (audioInput.value) {
room.value?.removeAudioInput(audioInput.value);
audioInput.value.close();
audioInput.value = null;
}
if (room.value) {
room.value.leave();
room.value = null;
}
isConnected.value = false;
peers.value = [];
speakingPeers.value = new Set();
isMuted.value = false;
};
const toggleMute = async () => {
if (!audioInput.value) return;
if (isMuted.value) {
await audioInput.value.setVolume(1);
await room.value?.addAudioInput(audioInput.value);
} else {
room.value?.removeAudioInput(audioInput.value);
await audioInput.value.setVolume('muted');
}
isMuted.value = !isMuted.value;
};
onUnmounted(() => {
leaveRoom();
});
</script>
Step-by-Step Breakdown
Plugin
The audio plugin is registered automatically by the SDK when needed -- no manual initialization is required. See Customize the Plugin if you need to customize the plugin.
Modern browsers require a user gesture (click/tap) before an AudioContext can be started. Ensure joinRoom is called from a button click handler (e.g. @click="joinRoom").
Reactive State with ref and shallowRef
Vue's ref() is used for simple reactive values (booleans, arrays, sets). For SDK objects (Room, AudioInput), shallowRef() is used instead — this prevents Vue from deeply observing complex class instances that have internal state the proxy system should not intercept.
Event Handlers
All event handlers must be registered before calling room.join(). The example registers four handlers:
| Handler | Purpose |
|---|
room.onJoined | Update isConnected ref when successfully joined |
room.onLeft | Reset refs when disconnected |
room.onPeerJoined | Track peers; attach per-peer onAudioActivity for speaking detection |
room.onPeerLeft | Remove peer from refs |
The per-peer PeerEvents
onAudioActivity handler receives { media } where media.isActive indicates whether the peer is currently speaking. To trigger Vue reactivity when updating a Set, a new Set instance must be assigned.
Joining and Audio
Two critical calls must happen in order:
setOutputDevice({}) — Must be called before room.join() to hear other peers
room.addAudioInput(audioInput) — Attaches the microphone and starts audio transmission
Muting
The example uses the full mute approach for maximum resource savings. See Muting & Volume Control for all available approaches.
Cleanup
onUnmounted calls leaveRoom() to ensure the microphone is released and the room connection is closed when the component is destroyed.