import type { AudioTrack, Participant, Room } from 'livekit-client'; import { ParticipantEvent, RoomEvent, Track } from 'livekit-client'; import { useEffect, useState } from 'react'; // Real-time speaking ring driven by Web Audio API AnalyserNodes directly on // each participant's audio MediaStreamTrack. LiveKit's own `audioLevel` + // `isSpeaking` are updated by a background monitor (default ~1s) — too // laggy. AnalyserNodes give us raw PCM at browser frame rate, so the ring // lights up within one animation frame of actual speech. // // Hold time of 250ms prevents flicker between words / short pauses. const POLL_MS = 50; const HOLD_MS = 250; const THRESHOLD = 0.03; // RMS on 0..1 — tuned against soft speech const FFT_SIZE = 256; interface Probe { ctx: AudioContext; analyser: AnalyserNode; source: MediaStreamAudioSourceNode; buf: Uint8Array; // Cached MediaStreamTrack reference — if a publication swaps tracks // (mute/unmute, device switch) we rebuild the node chain. trackId: string; } function makeProbe(track: MediaStreamTrack): Probe | null { try { const ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)(); const stream = new MediaStream([track]); const source = ctx.createMediaStreamSource(stream); const analyser = ctx.createAnalyser(); analyser.fftSize = FFT_SIZE; analyser.smoothingTimeConstant = 0.2; source.connect(analyser); return { ctx, analyser, source, buf: new Uint8Array(analyser.frequencyBinCount), trackId: track.id, }; } catch { return null; } } function destroyProbe(probe: Probe): void { try { probe.source.disconnect(); } catch { /* ignore */ } try { void probe.ctx.close(); } catch { /* ignore */ } } function sampleRms(probe: Probe): number { // `getByteFrequencyData` types expect `Uint8Array` (no // SharedArrayBuffer). Our `probe.buf` satisfies that at runtime; the cast // sidesteps the TS lib signature quirk. probe.analyser.getByteFrequencyData(probe.buf as Uint8Array); let sum = 0; for (let i = 0; i < probe.buf.length; i++) sum += probe.buf[i]!; return sum / (probe.buf.length * 255); } function firstAudioTrack(p: Participant): AudioTrack | null { const pubs = p.audioTrackPublications; for (const pub of pubs.values()) { if (pub.kind === Track.Kind.Audio && pub.track) { return pub.track as AudioTrack; } } return null; } export function useActiveSpeakers(room: Room | null): Set { const [ids, setIds] = useState>(() => new Set()); useEffect(() => { if (!room) { setIds(new Set()); return; } const probes = new Map(); const lastActive = new Map(); const syncProbe = (p: Participant): void => { if (!p.identity) return; const track = firstAudioTrack(p); const mst = track?.mediaStreamTrack ?? null; const existing = probes.get(p.identity); if (!mst) { if (existing) { destroyProbe(existing); probes.delete(p.identity); } return; } if (existing && existing.trackId === mst.id) return; if (existing) destroyProbe(existing); const probe = makeProbe(mst); if (probe) probes.set(p.identity, probe); }; const bind = (p: Participant): void => { p.on(ParticipantEvent.TrackPublished, () => syncProbe(p)); p.on(ParticipantEvent.TrackUnpublished, () => syncProbe(p)); p.on(ParticipantEvent.TrackSubscribed, () => syncProbe(p)); p.on(ParticipantEvent.TrackUnsubscribed, () => syncProbe(p)); p.on(ParticipantEvent.TrackMuted, () => syncProbe(p)); p.on(ParticipantEvent.TrackUnmuted, () => syncProbe(p)); syncProbe(p); }; bind(room.localParticipant); room.remoteParticipants.forEach(bind); const onConnected = (p: Participant) => bind(p); const onDisconnected = (p: Participant) => { if (!p.identity) return; const probe = probes.get(p.identity); if (probe) { destroyProbe(probe); probes.delete(p.identity); } lastActive.delete(p.identity); }; room.on(RoomEvent.ParticipantConnected, onConnected); room.on(RoomEvent.ParticipantDisconnected, onDisconnected); const tick = () => { // Defensively resync every tick — covers the gap where local mic // finishes publishing between effect mount and the first // TrackPublished event, and handles event misses on Tauri WebView. syncProbe(room.localParticipant); room.remoteParticipants.forEach(syncProbe); const now = Date.now(); for (const [id, probe] of probes) { if (sampleRms(probe) > THRESHOLD) lastActive.set(id, now); } const next = new Set(); for (const [id, t] of lastActive) { if (now - t <= HOLD_MS) next.add(id); } setIds((prev) => { if (prev.size === next.size && Array.from(prev).every((v) => next.has(v))) { return prev; } return next; }); }; const interval = window.setInterval(tick, POLL_MS); return () => { window.clearInterval(interval); room.off(RoomEvent.ParticipantConnected, onConnected); room.off(RoomEvent.ParticipantDisconnected, onDisconnected); for (const probe of probes.values()) destroyProbe(probe); probes.clear(); }; }, [room]); return ids; }