feat: implement local audio streaming with controls in voice components

This commit is contained in:
MythEclipse
2026-05-16 21:08:39 +07:00
parent 82025a19b2
commit 62d131cf14
5 changed files with 95 additions and 5 deletions

View File

@@ -25,7 +25,11 @@ export default function App() {
const [activeSpeakers, setActiveSpeakers] = useState<ActiveSpeaker[]>([]); const [activeSpeakers, setActiveSpeakers] = useState<ActiveSpeaker[]>([]);
const [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04)); const [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04));
const [isListening, setIsListening] = useState(false); const [isListening, setIsListening] = useState(false);
const audioContextRef = useRef<AudioContext | null>(null); const [isStreaming, setIsStreaming] = useState(false);
const audioContextListenRef = useRef<AudioContext | null>(null);
const audioContextTransmitRef = useRef<AudioContext | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const processorRef = useRef<ScriptProcessorNode | null>(null);
const userTimelinesRef = useRef(new Map<number, number>()); const userTimelinesRef = useRef(new Map<number, number>());
const activeTab = uiState.activeTab || "voice"; const activeTab = uiState.activeTab || "voice";
@@ -44,7 +48,7 @@ export default function App() {
const average = int16Array.length ? sum / int16Array.length : 0; const average = int16Array.length ? sum / int16Array.length : 0;
setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5))); setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5)));
const audioContext = audioContextRef.current; const audioContext = audioContextListenRef.current;
if (!isListening || !audioContext) return; if (!isListening || !audioContext) return;
const float32Array = new Float32Array(int16Array.length); const float32Array = new Float32Array(int16Array.length);
for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768; for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768;
@@ -72,6 +76,73 @@ export default function App() {
onPcm: handleIncomingPcm, onPcm: handleIncomingPcm,
}); });
const stopStreamingLocal = useCallback(() => {
setIsStreaming(false);
if (processorRef.current) {
processorRef.current.disconnect();
processorRef.current = null;
}
if (audioContextTransmitRef.current) {
audioContextTransmitRef.current.close();
audioContextTransmitRef.current = null;
}
if (streamRef.current) {
for (const track of streamRef.current.getTracks()) track.stop();
streamRef.current = null;
}
setLevels(Array.from({ length: 32 }, () => 0.04));
}, []);
const startStreamingLocal = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
setIsStreaming(true);
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE });
audioContextTransmitRef.current = audioContext;
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
processorRef.current = processor;
source.connect(processor);
processor.connect(audioContext.destination);
processor.onaudioprocess = (event) => {
if (!socket.socketRef.current || socket.socketRef.current.readyState !== WebSocket.OPEN) return;
const inputData = event.inputBuffer.getChannelData(0);
const pcmData = new Int16Array(inputData.length);
for (let i = 0; i < inputData.length; i++) {
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
}
socket.socketRef.current.send(pcmData.buffer);
// Update local levels from mic
let sum = 0;
for (let i = 0; i < inputData.length; i++) sum += Math.abs(inputData[i]);
const average = inputData.length ? sum / inputData.length : 0;
setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5)));
};
} catch (err) {
console.error("Microphone access failed:", err);
setIsStreaming(false);
throw err;
}
}, [socket.socketRef]);
const toggleStreaming = useCallback(async () => {
if (isStreaming) {
stopStreamingLocal();
await patchUIState({ isStreaming: false });
} else {
await startStreamingLocal();
await patchUIState({ isStreaming: true });
}
}, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]);
useEffect(() => { useEffect(() => {
if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined); if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
}, [selectedVoiceGuild, voice.loadVoiceChannels]); }, [selectedVoiceGuild, voice.loadVoiceChannels]);
@@ -86,15 +157,15 @@ export default function App() {
const toggleListening = useCallback(async () => { const toggleListening = useCallback(async () => {
if (isListening) { if (isListening) {
await audioContextRef.current?.suspend(); await audioContextListenRef.current?.suspend();
userTimelinesRef.current.clear(); userTimelinesRef.current.clear();
setIsListening(false); setIsListening(false);
await patchUIState({ isListening: false }); await patchUIState({ isListening: false });
return; return;
} }
const AudioContextCtor = window.AudioContext || window.webkitAudioContext; const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
audioContextRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE }); audioContextListenRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
await audioContextRef.current.resume(); await audioContextListenRef.current.resume();
setIsListening(true); setIsListening(true);
await patchUIState({ isListening: true }); await patchUIState({ isListening: true });
}, [isListening, patchUIState]); }, [isListening, patchUIState]);
@@ -131,11 +202,13 @@ export default function App() {
activeSpeakers={activeSpeakers} activeSpeakers={activeSpeakers}
levels={levels} levels={levels}
isListening={isListening} isListening={isListening}
isStreaming={isStreaming}
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })} onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })} onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)} onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
onDisconnect={() => voice.leaveVoice()} onDisconnect={() => voice.leaveVoice()}
onListenToggle={toggleListening} onListenToggle={toggleListening}
onStreamingToggle={toggleStreaming}
/> />
</TabsContent> </TabsContent>
<TabsContent value="media"> <TabsContent value="media">

View File

@@ -15,7 +15,9 @@ interface VoiceControlProps {
onJoin: () => void; onJoin: () => void;
onDisconnect: () => void; onDisconnect: () => void;
onListenToggle: () => void; onListenToggle: () => void;
onStreamingToggle: () => void;
isListening: boolean; isListening: boolean;
isStreaming: boolean;
} }
export function VoiceControl({ export function VoiceControl({
@@ -30,7 +32,9 @@ export function VoiceControl({
onJoin, onJoin,
onDisconnect, onDisconnect,
onListenToggle, onListenToggle,
onStreamingToggle,
isListening, isListening,
isStreaming,
}: VoiceControlProps) { }: VoiceControlProps) {
return ( return (
<Card> <Card>
@@ -69,6 +73,9 @@ export function VoiceControl({
<Button variant={isListening ? "secondary" : "outline"} onClick={onListenToggle}> <Button variant={isListening ? "secondary" : "outline"} onClick={onListenToggle}>
{isListening ? "Stop Listening" : "Listen Live"} {isListening ? "Stop Listening" : "Listen Live"}
</Button> </Button>
<Button variant={isStreaming ? "destructive" : "default"} onClick={onStreamingToggle}>
{isStreaming ? "Stop Transmitting" : "Start Transmitting"}
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -14,11 +14,13 @@ interface VoicePanelProps {
activeSpeakers: ActiveSpeaker[]; activeSpeakers: ActiveSpeaker[];
levels: number[]; levels: number[];
isListening: boolean; isListening: boolean;
isStreaming: boolean;
onGuildChange: (guildId: string) => void; onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void; onChannelChange: (channelId: string) => void;
onJoin: () => void; onJoin: () => void;
onDisconnect: () => void; onDisconnect: () => void;
onListenToggle: () => void; onListenToggle: () => void;
onStreamingToggle: () => void;
} }
export function VoicePanel(props: VoicePanelProps) { export function VoicePanel(props: VoicePanelProps) {

View File

@@ -1,4 +1,5 @@
import { parentPort } from "node:worker_threads"; import { parentPort } from "node:worker_threads";
import { initializeDatabase } from "../database/drizzle";
import { buildConversationPromptMessages } from "./conversationContext"; import { buildConversationPromptMessages } from "./conversationContext";
import { runModerationAnalysis } from "./llmModerationClient"; import { runModerationAnalysis } from "./llmModerationClient";
import { import {
@@ -9,6 +10,8 @@ import type { MessageRecord } from "./types";
const MAX_CONTEXT_TOKENS = 8000; const MAX_CONTEXT_TOKENS = 8000;
let dbInitialized = false;
interface AnalysisWorkerRequest { interface AnalysisWorkerRequest {
conversationKey: string; conversationKey: string;
messages: MessageRecord[]; messages: MessageRecord[];
@@ -32,6 +35,10 @@ async function processAnalysisRequest({
messages, messages,
}: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> { }: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> {
try { try {
if (!dbInitialized) {
await initializeDatabase();
dbInitialized = true;
}
const firstMessage = messages[0]; const firstMessage = messages[0];
if (!firstMessage) return { ok: true, conversationKey, rows: [] }; if (!firstMessage) return { ok: true, conversationKey, rows: [] };

View File

@@ -218,6 +218,7 @@ export async function startWebserver(
app.use(express.json()); app.use(express.json());
app.use(express.static(path.join(__dirname, "../public"))); app.use(express.static(path.join(__dirname, "../public")));
app.use(express.static(path.join(__dirname, "../public/app")));
app.get("/", (_req: Request, res: Response) => { app.get("/", (_req: Request, res: Response) => {
const reactIndex = path.join(__dirname, "../public/app/index.html"); const reactIndex = path.join(__dirname, "../public/app/index.html");