Skip to main content

Angular

This guide walks through integrating the ODIN Voice Web SDK into an Angular application. It uses modern Angular patterns — standalone components, signals for reactive state, and OnPush change detection. The code below is based on the tested example in the ODIN Web SDK repository.

info

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.component.ts
import {
Component,
OnDestroy,
signal,
ChangeDetectionStrategy,
} from "@angular/core";
import { FormsModule } from "@angular/forms";
import {
Room,
DeviceManager,
AudioInput,
setOutputDevice,
} from "@4players/odin";
import { TokenGenerator } from "@4players/odin-tokens";

// Type for tracking peer information in component state
interface PeerInfo {
id: number;
userId: string;
isLocal: boolean;
}

@Component({
selector: "app-root",
standalone: true,
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (error()) {
<p>{{ error() }}</p>
}

@if (!isConnected()) {
<button (click)="joinRoom()" [disabled]="isConnecting()">
{{ isConnecting() ? "Connecting..." : "Join Voice Chat" }}
</button>
} @else {
<button (click)="toggleMute()">
{{ isMuted() ? "Unmute" : "Mute" }}
</button>
<button (click)="leaveRoom()">Leave</button>

<h3>Peers ({{ peers().length }})</h3>
<ul>
@for (peer of peers(); track peer.id) {
<li>
{{ peer.isLocal ? "You" : peer.userId }}
@if (speakingPeers().has(peer.id)) {
(speaking)
}
</li>
}
</ul>
}
`,
})
export class AppComponent implements OnDestroy {
// ---------------------------------------------------------------------------
// Reactive state using Angular signals
// ---------------------------------------------------------------------------

isConnected = signal(false);
isConnecting = signal(false);
peers = signal<PeerInfo[]>([]);
// Track which peers are currently speaking via their peer ID
speakingPeers = signal<Set<number>>(new Set());
isMuted = signal(false);
error = signal<string | null>(null);

// SDK objects live outside the signal system — they are not rendered directly
private room: Room | null = null;
private audioInput: AudioInput | null = null;

// ---------------------------------------------------------------------------
// Cleanup on destroy — close audio and leave room
// ---------------------------------------------------------------------------

ngOnDestroy() {
this.leaveRoom();
}

// ---------------------------------------------------------------------------
// Join a voice room
// ---------------------------------------------------------------------------

async joinRoom() {
this.error.set(null);
this.isConnecting.set(true);

try {
// Generate a token (in production, fetch this from your backend)
const generator = new TokenGenerator("YOUR_ACCESS_KEY");
const token = await generator.createToken("default", "angular-user");

// Create a new Room instance for this session
this.room = new Room();

// ----- Register event handlers BEFORE joining -----

// Called once the room has been successfully joined
this.room.onJoined = () => {
this.isConnected.set(true);
this.isConnecting.set(false);
};

// Called when we leave or get disconnected from the room
this.room.onLeft = () => {
this.isConnected.set(false);
this.isConnecting.set(false);
this.peers.set([]);
};

// Called whenever a peer joins — including ourselves and peers already present
this.room.onPeerJoined = (payload) => {
// Compare with room.ownPeerId to identify the local peer
const isLocal = payload.peer.id === this.room!.ownPeerId;

this.peers.update((prev) => [
...prev.filter((p) => p.id !== payload.peer.id),
{ id: payload.peer.id, userId: payload.peer.userId, isLocal },
]);

// Attach a per-peer audio activity handler to track who is speaking
payload.peer.onAudioActivity = ({ media }) => {
this.speakingPeers.update((prev) => {
const next = new Set(prev);
if (media.isActive) {
next.add(payload.peer.id);
} else {
next.delete(payload.peer.id);
}
return next;
});
};
};

// Called when a peer leaves the room
this.room.onPeerLeft = (payload) => {
this.peers.update((prev) =>
prev.filter((p) => p.id !== payload.peer.id),
);
this.speakingPeers.update((prev) => {
const next = new Set(prev);
next.delete(payload.peer.id);
return next;
});
};

// Set the output device — required to hear other peers
await setOutputDevice({});

// Join the room with the token
await this.room.join(token, {
gateway: "https://gateway.odin.4players.io",
});

// Create a microphone input. Defaults: system mic with echo cancellation,
// noise suppression, and automatic gain control enabled.
this.audioInput = await DeviceManager.createAudioInput();

// Attach the microphone to the room — audio transmission starts here
await this.room.addAudioInput(this.audioInput);
} catch (e) {
this.error.set(e instanceof Error ? e.message : "Failed to join room");
this.isConnecting.set(false);
}
}

// ---------------------------------------------------------------------------
// Leave the room
// ---------------------------------------------------------------------------

leaveRoom() {
if (this.audioInput) {
// Remove the audio input from the room (stops the encoder)
this.room?.removeAudioInput(this.audioInput);
// Close the AudioInput (releases the microphone)
this.audioInput.close();
this.audioInput = null;
}
if (this.room) {
// Disconnect from the room
this.room.leave();
this.room = null;
}
// Reset all state
this.isConnected.set(false);
this.peers.set([]);
this.speakingPeers.set(new Set());
this.isMuted.set(false);
}

// ---------------------------------------------------------------------------
// Mute / Unmute toggle
// ---------------------------------------------------------------------------

async toggleMute() {
if (!this.audioInput) return;

if (this.isMuted()) {
// Unmute: restore volume first, then re-add to room to resume encoding
await this.audioInput.setVolume(1);
await this.room?.addAudioInput(this.audioInput);
} else {
// Mute: remove from room to stop the encoder (saves CPU),
// then use 'muted' to stop the MediaStream so the browser's
// recording indicator disappears
this.room?.removeAudioInput(this.audioInput);
await this.audioInput.setVolume("muted");
}
this.isMuted.update((v) => !v);
}
}

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.

warning

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 Signals

Angular signals provide fine-grained reactivity compatible with OnPush change detection. SDK objects (Room, AudioInput) are stored as private class properties since they are not rendered directly — only their derived state (connection status, peers, mute state) is exposed as signals.

Event Handlers

All event handlers must be registered before calling room.join(). The example registers four handlers:

HandlerPurpose
room.onJoinedUpdate connection signal when successfully joined
room.onLeftReset signals when disconnected
room.onPeerJoinedTrack peers via signal.update(); attach per-peer onAudioActivity
room.onPeerLeftRemove peer from signals

The per-peer

PeerEvents

onAudioActivity handler receives { media } where media.isActive indicates whether the peer is currently speaking.

Joining and Audio

Two critical calls must happen in order:

  1. setOutputDevice({}) — Must be called to hear other peers
  2. 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

ngOnDestroy calls leaveRoom() to ensure the microphone is released and the room connection is closed when the component is destroyed.