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>;
}