feat: implement local audio streaming with controls in voice components
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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: [] };
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user