Compare commits

..

2 Commits

6 changed files with 108 additions and 10 deletions

View File

@@ -25,7 +25,11 @@ export default function App() {
const [activeSpeakers, setActiveSpeakers] = useState<ActiveSpeaker[]>([]);
const [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04));
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 activeTab = uiState.activeTab || "voice";
@@ -44,7 +48,7 @@ export default function App() {
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)));
const audioContext = audioContextRef.current;
const audioContext = audioContextListenRef.current;
if (!isListening || !audioContext) return;
const float32Array = new Float32Array(int16Array.length);
for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768;
@@ -72,6 +76,73 @@ export default function App() {
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(() => {
if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
@@ -86,15 +157,15 @@ export default function App() {
const toggleListening = useCallback(async () => {
if (isListening) {
await audioContextRef.current?.suspend();
await audioContextListenRef.current?.suspend();
userTimelinesRef.current.clear();
setIsListening(false);
await patchUIState({ isListening: false });
return;
}
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
audioContextRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
await audioContextRef.current.resume();
audioContextListenRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
await audioContextListenRef.current.resume();
setIsListening(true);
await patchUIState({ isListening: true });
}, [isListening, patchUIState]);
@@ -131,11 +202,13 @@ export default function App() {
activeSpeakers={activeSpeakers}
levels={levels}
isListening={isListening}
isStreaming={isStreaming}
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
onDisconnect={() => voice.leaveVoice()}
onListenToggle={toggleListening}
onStreamingToggle={toggleStreaming}
/>
</TabsContent>
<TabsContent value="media">

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ export interface SharedUIState {
selectedVoiceChannel: string;
selectedTextGuild: string;
selectedTextChannel: string;
activeTab: "voice" | "text";
activeTab: "voice" | "messages" | "media" | "review";
isListening: boolean;
isStreaming: boolean;
}

View File

@@ -54,7 +54,7 @@ interface SharedUIState {
selectedVoiceChannel: string;
selectedTextGuild: string;
selectedTextChannel: string;
activeTab: "voice" | "text";
activeTab: "voice" | "messages" | "media" | "review";
isListening: boolean;
isStreaming: boolean;
}
@@ -84,7 +84,11 @@ export function normalizeSharedUIState(
selectedVoiceChannel: value.selectedVoiceChannel ?? "",
selectedTextGuild: value.selectedTextGuild ?? guild,
selectedTextChannel: value.selectedTextChannel ?? "",
activeTab: value.activeTab === "text" ? "text" : "voice",
activeTab: (["voice", "messages", "media", "review"].includes(
value.activeTab ?? "",
)
? value.activeTab
: "voice") as "voice" | "messages" | "media" | "review",
isListening: value.isListening ?? false,
isStreaming: value.isStreaming ?? false,
};
@@ -117,8 +121,12 @@ function patchSharedUIState(patch: SharedUIStatePatch) {
if (typeof patch.selectedTextChannel === "string") {
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
}
if (patch.activeTab === "voice" || patch.activeTab === "text") {
sharedUIState.activeTab = patch.activeTab;
if (["voice", "messages", "media", "review"].includes(patch.activeTab ?? "")) {
sharedUIState.activeTab = patch.activeTab as
| "voice"
| "messages"
| "media"
| "review";
}
if (typeof patch.isListening === "boolean") {
sharedUIState.isListening = patch.isListening;
@@ -218,6 +226,7 @@ export async function startWebserver(
app.use(express.json());
app.use(express.static(path.join(__dirname, "../public")));
app.use(express.static(path.join(__dirname, "../public/app")));
app.get("/", (_req: Request, res: Response) => {
const reactIndex = path.join(__dirname, "../public/app/index.html");