From f5769716e98e8df66ff5a7253be5423b233aac56 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Wed, 7 Jan 2026 18:41:34 +0100 Subject: [PATCH] Speaking indicator --- src/app/server/[ip]/page.tsx | 5 +++ src/components/channel/voice/VoiceBox.tsx | 40 ++++++++++++++++++++--- src/hooks/use-app.ts | 8 +++-- src/lib/audio.ts | 11 ++++++- src/lib/auth.ts | 18 +++++----- src/types/app.ts | 3 ++ 6 files changed, 69 insertions(+), 16 deletions(-) diff --git a/src/app/server/[ip]/page.tsx b/src/app/server/[ip]/page.tsx index 1d81717..9e3b012 100644 --- a/src/app/server/[ip]/page.tsx +++ b/src/app/server/[ip]/page.tsx @@ -41,6 +41,11 @@ export default function Server() { [channelId]: remainingUsers, }; }), + onSpeaking: (voiceId) => + app.setSpeaking((speaking) => ({ + ...speaking, + [voiceId]: Date.now(), + })), }); }, [ip, messageStore.messages], diff --git a/src/components/channel/voice/VoiceBox.tsx b/src/components/channel/voice/VoiceBox.tsx index 388f661..e2c1ccb 100644 --- a/src/components/channel/voice/VoiceBox.tsx +++ b/src/components/channel/voice/VoiceBox.tsx @@ -4,7 +4,7 @@ import App from "@/types/app"; import { ClientMessage, IndicatorContext } from "@/types/protocol"; import { Message } from "@/types/types"; import { useEffect, useState } from "react"; -import { startMic } from "@/lib/audio"; +import { rms, startMic } from "@/lib/audio"; import { Button } from "../../ui/button"; import { ChevronLeftIcon, Phone, Volume2Icon } from "lucide-react"; import VoiceGrid from "./VoiceGrid"; @@ -28,13 +28,43 @@ export default function VoiceBox({ useEffect(() => { if (!voiceConn) return; + if (!app.profile) return; - const stop = startMic(sendVoice); + const stop = startMic((data) => { + if (rms(data) > 0.02) { + sendVoice(data); + + if (!app.profile) return; + + const voiceId = app.voiceConns[channelId]?.[app.profile.id]; + if (!voiceId) return; + + app.setSpeaking((prev) => ({ + ...prev, + [voiceId]: Date.now(), + })); + } + }); return () => { stop.then((s) => s()); }; - }, [voiceConn]); + }, [voiceConn, channelId, app.profile?.id, app.voiceConns]); + + useEffect(() => { + const inv = setInterval(() => { + const now = Date.now(); + const TIMEOUT = 500; + + app.setSpeaking((prev) => + Object.fromEntries( + Object.entries(prev).filter(([_, ts]) => now - ts < TIMEOUT) + ) + ); + }, 250); + + return () => clearInterval(inv); + }, []); return (
@@ -53,9 +83,9 @@ export default function VoiceBox({ {app.voiceConns[channelId] && ( ({ + ([userId, voiceId]) => ({ id: userId, - speaking: false, + speaking: Boolean(app.speaking[voiceId]), }) )} app={app} diff --git a/src/hooks/use-app.ts b/src/hooks/use-app.ts index 7f6f464..9e71dca 100644 --- a/src/hooks/use-app.ts +++ b/src/hooks/use-app.ts @@ -21,10 +21,12 @@ export default function useApp(): App { const [currentChannel, setCurrentChannel] = useState(null); const [server, setServer] = useState(); const privateMessages = useMessages(); - const [voiceConns, SetVoiceConns] = useState< + const [voiceConns, setVoiceConns] = useState< Record> >({}); + const [speaking, setSpeaking] = useState>({}); + useEffect(() => { const stored = Cookies.get("servers"); if (!stored) return; @@ -40,8 +42,10 @@ export default function useApp(): App { }, []); return { + speaking, + setSpeaking, voiceConns, - setVoiceConns: SetVoiceConns, + setVoiceConns, privateMessages, currentChannel, setCurrentChannel, diff --git a/src/lib/audio.ts b/src/lib/audio.ts index 1f62d94..8c1e620 100644 --- a/src/lib/audio.ts +++ b/src/lib/audio.ts @@ -42,7 +42,7 @@ export async function startMic(onAudio: (data: Int16Array) => void) { chunk.set(buffer.splice(0, buffer.length)); } onAudio(chunk); - }, 1000); + }, 500); // Return cleanup function return () => { @@ -101,3 +101,12 @@ export function playPCM16(u8: Uint8Array) { src.connect(audioCtx.destination); src.start(); } + +export function rms(samples: Int16Array): number { + let sum = 0; + for (let i = 0; i < samples.length; i++) { + const s = samples[i] / 32768; // normalize to [-1, 1] + sum += s * s; + } + return Math.sqrt(sum / samples.length); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 032a3e4..9368cae 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -17,6 +17,7 @@ type AuthParams = { onVoice?: (userId: string, bytes: number[]) => void; onVoiceJoin?: (userId: string, channelId: string, voiceId: number) => void; onVoiceLeave?: (userId: string, channelId: string, voiceId: number) => void; + onSpeaking?: (voiceId: number) => void; }; export default async function auth({ @@ -28,6 +29,7 @@ export default async function auth({ onIndicator, onVoiceJoin, onVoiceLeave, + onSpeaking, }: AuthParams) { console.log("Authenticating with server id:", id); @@ -53,6 +55,7 @@ export default async function auth({ const buffer = await m.data.arrayBuffer(); const bytes = new Uint8Array(buffer); playPCM16(bytes); + if (onSpeaking) onSpeaking(new DataView(buffer).getUint16(0, true)); return; } @@ -82,14 +85,13 @@ export default async function auth({ switch (msg.type) { case "authenticated": - if (!onIndicator) break; - msg.params.indicators.forEach(onIndicator); - if (!onVoiceJoin) break; - Object.entries(msg.params.voice_chat).forEach(([channelId, users]) => - Object.entries(users).forEach(([userId, voiceId]) => - onVoiceJoin(userId, channelId, voiceId) - ) - ); + if (onIndicator) msg.params.indicators.forEach(onIndicator); + if (onVoiceJoin) + Object.entries(msg.params.voice_chat).forEach(([channelId, users]) => + Object.entries(users).forEach(([userId, voiceId]) => + onVoiceJoin(userId, channelId, voiceId) + ) + ); break; case "message_create": diff --git a/src/types/app.ts b/src/types/app.ts index 6fac9b5..b9304f2 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -38,4 +38,7 @@ export default interface App { voiceConns: Record>; setVoiceConns: SetState>>; + + speaking: Record; + setSpeaking: SetState>; }