Claude
Skills
Sign in
Back

audio-room

Included with Lifetime
$97 forever

Audio-only room with LiveKit — circular avatar grid, speaking indicators, raise hand queue. Twitter Spaces / Clubhouse style. Use this skill when the user says "add audio room", "audio chat", "voice room", "spaces", "clubhouse", or "audio-only room".

Image & Video

What this skill does


# Audio Room (LiveKit)

Audio-only room experience using [LiveKit](https://livekit.io). Participants appear as circular avatars in a grid layout with real-time speaking indicators (pulsing rings), raise hand queue, mute/deafen controls, and no video tracks published. Designed to feel like Twitter Spaces or Clubhouse.

## Prerequisites

- Next.js app with `src/` directory and App Router
- `video-room` skill applied (provides `livekit-client`, `@livekit/components-react`, LiveKit token server, and room connection infrastructure)
- `add-shadcn` skill applied (provides `Button`, `Badge`, `Card`, `Avatar`, `Tooltip` components)

## Installation

No additional packages required. Uses `livekit-client` and `@livekit/components-react` from the `video-room` skill.

## Environment Variables

Uses the same LiveKit environment variables from the `video-room` skill:

```env
LIVEKIT_API_KEY=your_api_key
LIVEKIT_API_SECRET=your_api_secret
NEXT_PUBLIC_LIVEKIT_URL=wss://your-project.livekit.cloud
```

## What Gets Created

```
src/
├── components/
│   └── video/
│       ├── audio-room.tsx             # Audio-only room layout — circular avatar grid
│       ├── audio-controls.tsx         # Mute, deafen, raise hand, leave controls
│       └── speaking-indicator.tsx     # Pulsing ring animation for speaking state
└── lib/
    └── video/
        └── use-audio-room.ts         # Hook: join room with audio-only config, speaking state
```

## Setup Steps

### Step 1: Create `src/lib/video/use-audio-room.ts`

```typescript
"use client";

import { useCallback, useEffect, useState } from "react";
import {
  Room,
  RoomEvent,
  Track,
  type RemoteParticipant,
  type LocalParticipant,
  type Participant,
  type RoomOptions,
  type AudioCaptureOptions,
  ConnectionState,
} from "livekit-client";

type AudioParticipant = {
  identity: string;
  name: string;
  isSpeaking: boolean;
  isMuted: boolean;
  isLocal: boolean;
  audioLevel: number;
  metadata: string | undefined;
};

type RaiseHandEntry = {
  identity: string;
  name: string;
  raisedAt: number;
};

type UseAudioRoomOptions = {
  token: string;
  serverUrl: string;
  onDisconnected?: () => void;
};

type UseAudioRoomReturn = {
  room: Room | null;
  participants: AudioParticipant[];
  isConnected: boolean;
  isConnecting: boolean;
  isMuted: boolean;
  isDeafened: boolean;
  handRaised: boolean;
  raiseHandQueue: RaiseHandEntry[];
  toggleMute: () => Promise<void>;
  toggleDeafen: () => void;
  toggleRaiseHand: () => void;
  disconnect: () => void;
};

const RAISE_HAND_EVENT = "raise-hand";
const LOWER_HAND_EVENT = "lower-hand";

function participantToAudio(
  participant: Participant,
  isLocal: boolean
): AudioParticipant {
  const audioPublication = participant.getTrackPublications().find(
    (pub) => pub.track?.kind === Track.Kind.Audio
  );

  return {
    identity: participant.identity,
    name: participant.name ?? participant.identity,
    isSpeaking: participant.isSpeaking,
    isMuted: audioPublication?.isMuted ?? true,
    isLocal,
    audioLevel: participant.audioLevel,
    metadata: participant.metadata,
  };
}

export function useAudioRoom({
  token,
  serverUrl,
  onDisconnected,
}: UseAudioRoomOptions): UseAudioRoomReturn {
  const [room, setRoom] = useState<Room | null>(null);
  const [participants, setParticipants] = useState<AudioParticipant[]>([]);
  const [isConnected, setIsConnected] = useState(false);
  const [isConnecting, setIsConnecting] = useState(false);
  const [isMuted, setIsMuted] = useState(false);
  const [isDeafened, setIsDeafened] = useState(false);
  const [handRaised, setHandRaised] = useState(false);
  const [raiseHandQueue, setRaiseHandQueue] = useState<RaiseHandEntry[]>([]);

  const updateParticipants = useCallback((currentRoom: Room) => {
    const local = participantToAudio(currentRoom.localParticipant, true);
    const remotes = Array.from(currentRoom.remoteParticipants.values()).map(
      (p) => participantToAudio(p, false)
    );
    setParticipants([local, ...remotes]);
  }, []);

  useEffect(() => {
    if (!token || !serverUrl) return;

    const roomOptions: RoomOptions = {
      audioCaptureDefaults: {
        autoGainControl: true,
        echoCancellation: true,
        noiseSuppression: true,
      } satisfies AudioCaptureOptions,

      adaptiveStream: false,
    };

    const newRoom = new Room(roomOptions);
    setRoom(newRoom);
    setIsConnecting(true);

    const handleParticipantChange = () => updateParticipants(newRoom);

    const handleActiveSpeakersChanged = (speakers: Participant[]) => {
      void speakers;
      updateParticipants(newRoom);
    };

    const handleDisconnected = () => {
      setIsConnected(false);
      setIsConnecting(false);
      setParticipants([]);
      setRaiseHandQueue([]);
      onDisconnected?.();
    };

    const handleDataReceived = (
      payload: Uint8Array,
      participant?: RemoteParticipant
    ) => {
      const decoder = new TextDecoder();
      const message = decoder.decode(payload);

      if (message === RAISE_HAND_EVENT && participant) {
        setRaiseHandQueue((prev) => {
          if (prev.some((entry) => entry.identity === participant.identity)) {
            return prev;
          }
          return [
            ...prev,
            {
              identity: participant.identity,
              name: participant.name ?? participant.identity,
              raisedAt: Date.now(),
            },
          ];
        });
      }

      if (message === LOWER_HAND_EVENT && participant) {
        setRaiseHandQueue((prev) =>
          prev.filter((entry) => entry.identity !== participant.identity)
        );
      }
    };

    newRoom.on(RoomEvent.ParticipantConnected, handleParticipantChange);
    newRoom.on(RoomEvent.ParticipantDisconnected, handleParticipantChange);
    newRoom.on(RoomEvent.TrackSubscribed, handleParticipantChange);
    newRoom.on(RoomEvent.TrackUnsubscribed, handleParticipantChange);
    newRoom.on(RoomEvent.TrackMuted, handleParticipantChange);
    newRoom.on(RoomEvent.TrackUnmuted, handleParticipantChange);
    newRoom.on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakersChanged);
    newRoom.on(RoomEvent.Disconnected, handleDisconnected);
    newRoom.on(RoomEvent.DataReceived, handleDataReceived);

    newRoom
      .connect(serverUrl, token)
      .then(() => {
        setIsConnected(true);
        setIsConnecting(false);
        // Publish audio only — no video
        return newRoom.localParticipant.setMicrophoneEnabled(true);
      })
      .then(() => {
        updateParticipants(newRoom);
      })
      .catch((err: unknown) => {
        console.error("Failed to connect to audio room:", err);
        setIsConnecting(false);
      });

    return () => {
      newRoom.off(RoomEvent.ParticipantConnected, handleParticipantChange);
      newRoom.off(RoomEvent.ParticipantDisconnected, handleParticipantChange);
      newRoom.off(RoomEvent.TrackSubscribed, handleParticipantChange);
      newRoom.off(RoomEvent.TrackUnsubscribed, handleParticipantChange);
      newRoom.off(RoomEvent.TrackMuted, handleParticipantChange);
      newRoom.off(RoomEvent.TrackUnmuted, handleParticipantChange);
      newRoom.off(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakersChanged);
      newRoom.off(RoomEvent.Disconnected, handleDisconnected);
      newRoom.off(RoomEvent.DataReceived, handleDataReceived);

      if (newRoom.state !== ConnectionState.Disconnected) {
        newRoom.disconnect();
      }
    };
  }, [token, serverUrl, updateParticipants, onDisconnected]);

  const toggleMute = useCallback(async () => {
    if (!room) return;
    const newMuted = !isMuted;
    await room.localParticipant.setMicrophoneEnabled(!newMuted);
    setIsMuted(newMuted);
  }, [room, isMuted]);

  const toggleDeafen = useCallback(() => {
    if (!room) return;
    const newDeafened = !isDeafened;
    setIsDeafened(newDeafened);

    // Enable/disable all remote audio track publications using RemoteParticipant's 
Files: 1
Size: 30.1 KB
Complexity: 39/100
Category: Image & Video

Related in Image & Video