Speaking indicator

This commit is contained in:
2026-01-07 18:41:34 +01:00
parent 1577ce38e1
commit f5769716e9
6 changed files with 69 additions and 16 deletions
+5
View File
@@ -41,6 +41,11 @@ export default function Server() {
[channelId]: remainingUsers, [channelId]: remainingUsers,
}; };
}), }),
onSpeaking: (voiceId) =>
app.setSpeaking((speaking) => ({
...speaking,
[voiceId]: Date.now(),
})),
}); });
}, },
[ip, messageStore.messages], [ip, messageStore.messages],
+35 -5
View File
@@ -4,7 +4,7 @@ import App from "@/types/app";
import { ClientMessage, IndicatorContext } from "@/types/protocol"; import { ClientMessage, IndicatorContext } from "@/types/protocol";
import { Message } from "@/types/types"; import { Message } from "@/types/types";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { startMic } from "@/lib/audio"; import { rms, startMic } from "@/lib/audio";
import { Button } from "../../ui/button"; import { Button } from "../../ui/button";
import { ChevronLeftIcon, Phone, Volume2Icon } from "lucide-react"; import { ChevronLeftIcon, Phone, Volume2Icon } from "lucide-react";
import VoiceGrid from "./VoiceGrid"; import VoiceGrid from "./VoiceGrid";
@@ -28,13 +28,43 @@ export default function VoiceBox({
useEffect(() => { useEffect(() => {
if (!voiceConn) return; 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 () => { return () => {
stop.then((s) => s()); 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 ( return (
<div className="h-svh w-full max-h-svh flex flex-col pb-5 pl-5 pr-5 gap-5"> <div className="h-svh w-full max-h-svh flex flex-col pb-5 pl-5 pr-5 gap-5">
@@ -53,9 +83,9 @@ export default function VoiceBox({
{app.voiceConns[channelId] && ( {app.voiceConns[channelId] && (
<VoiceGrid <VoiceGrid
users={Object.entries(app.voiceConns[channelId]).map( users={Object.entries(app.voiceConns[channelId]).map(
([userId, _]) => ({ ([userId, voiceId]) => ({
id: userId, id: userId,
speaking: false, speaking: Boolean(app.speaking[voiceId]),
}) })
)} )}
app={app} app={app}
+6 -2
View File
@@ -21,10 +21,12 @@ export default function useApp(): App {
const [currentChannel, setCurrentChannel] = useState<Channel | null>(null); const [currentChannel, setCurrentChannel] = useState<Channel | null>(null);
const [server, setServer] = useState<Server>(); const [server, setServer] = useState<Server>();
const privateMessages = useMessages(); const privateMessages = useMessages();
const [voiceConns, SetVoiceConns] = useState< const [voiceConns, setVoiceConns] = useState<
Record<string, Record<string, number>> Record<string, Record<string, number>>
>({}); >({});
const [speaking, setSpeaking] = useState<Record<number, number>>({});
useEffect(() => { useEffect(() => {
const stored = Cookies.get("servers"); const stored = Cookies.get("servers");
if (!stored) return; if (!stored) return;
@@ -40,8 +42,10 @@ export default function useApp(): App {
}, []); }, []);
return { return {
speaking,
setSpeaking,
voiceConns, voiceConns,
setVoiceConns: SetVoiceConns, setVoiceConns,
privateMessages, privateMessages,
currentChannel, currentChannel,
setCurrentChannel, setCurrentChannel,
+10 -1
View File
@@ -42,7 +42,7 @@ export async function startMic(onAudio: (data: Int16Array) => void) {
chunk.set(buffer.splice(0, buffer.length)); chunk.set(buffer.splice(0, buffer.length));
} }
onAudio(chunk); onAudio(chunk);
}, 1000); }, 500);
// Return cleanup function // Return cleanup function
return () => { return () => {
@@ -101,3 +101,12 @@ export function playPCM16(u8: Uint8Array) {
src.connect(audioCtx.destination); src.connect(audioCtx.destination);
src.start(); 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);
}
+5 -3
View File
@@ -17,6 +17,7 @@ type AuthParams = {
onVoice?: (userId: string, bytes: number[]) => void; onVoice?: (userId: string, bytes: number[]) => void;
onVoiceJoin?: (userId: string, channelId: string, voiceId: number) => void; onVoiceJoin?: (userId: string, channelId: string, voiceId: number) => void;
onVoiceLeave?: (userId: string, channelId: string, voiceId: number) => void; onVoiceLeave?: (userId: string, channelId: string, voiceId: number) => void;
onSpeaking?: (voiceId: number) => void;
}; };
export default async function auth({ export default async function auth({
@@ -28,6 +29,7 @@ export default async function auth({
onIndicator, onIndicator,
onVoiceJoin, onVoiceJoin,
onVoiceLeave, onVoiceLeave,
onSpeaking,
}: AuthParams) { }: AuthParams) {
console.log("Authenticating with server id:", id); console.log("Authenticating with server id:", id);
@@ -53,6 +55,7 @@ export default async function auth({
const buffer = await m.data.arrayBuffer(); const buffer = await m.data.arrayBuffer();
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
playPCM16(bytes); playPCM16(bytes);
if (onSpeaking) onSpeaking(new DataView(buffer).getUint16(0, true));
return; return;
} }
@@ -82,9 +85,8 @@ export default async function auth({
switch (msg.type) { switch (msg.type) {
case "authenticated": case "authenticated":
if (!onIndicator) break; if (onIndicator) msg.params.indicators.forEach(onIndicator);
msg.params.indicators.forEach(onIndicator); if (onVoiceJoin)
if (!onVoiceJoin) break;
Object.entries(msg.params.voice_chat).forEach(([channelId, users]) => Object.entries(msg.params.voice_chat).forEach(([channelId, users]) =>
Object.entries(users).forEach(([userId, voiceId]) => Object.entries(users).forEach(([userId, voiceId]) =>
onVoiceJoin(userId, channelId, voiceId) onVoiceJoin(userId, channelId, voiceId)
+3
View File
@@ -38,4 +38,7 @@ export default interface App {
voiceConns: Record<string, Record<string, number>>; voiceConns: Record<string, Record<string, number>>;
setVoiceConns: SetState<Record<string, Record<string, number>>>; setVoiceConns: SetState<Record<string, Record<string, number>>>;
speaking: Record<number, number>;
setSpeaking: SetState<Record<number, number>>;
} }