From fce24278f0fb13492f0ada1bc4c2f3c8f4e99963 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sat, 13 Jun 2026 14:49:47 +0700 Subject: [PATCH] chore(auto): task completed - unknown --- .../modules/recordings/recordings.routes.ts | 4 +- .../modules/recordings/recordings.service.ts | 41 +++++++- services/frontend/src/App.tsx | 1 + .../dashboard/components/DashboardStats.tsx | 9 +- .../live/components/AudioVisualizer.tsx | 2 +- .../live/components/MicLevelMeter.tsx | 29 ++++++ .../live/components/RecordingsSubPanel.tsx | 96 ++++++++++++++++--- .../live/components/VoiceConnectionCard.tsx | 8 ++ .../src/features/live/components/index.ts | 1 + services/frontend/src/features/live/index.tsx | 3 + services/frontend/src/shared/api/client.ts | 12 ++- .../src/shared/hooks/useAudioPlayback.ts | 25 ++++- .../src/shared/hooks/useAudioTransmit.ts | 86 +++++++++++++++-- 13 files changed, 286 insertions(+), 31 deletions(-) create mode 100644 services/frontend/src/features/live/components/MicLevelMeter.tsx diff --git a/services/backend/src/modules/recordings/recordings.routes.ts b/services/backend/src/modules/recordings/recordings.routes.ts index 7c1525d..3039f61 100644 --- a/services/backend/src/modules/recordings/recordings.routes.ts +++ b/services/backend/src/modules/recordings/recordings.routes.ts @@ -16,10 +16,12 @@ export function createRecordingsRouter(): Router { const limit = Number(req.query.limit) || 50; const channelId = req.query.channelId as string | undefined; const userId = req.query.userId as string | undefined; - logger.debug({ limit, channelId, userId }, "Fetching recordings"); + const cursor = req.query.cursor as string | undefined; + logger.debug({ limit, channelId, userId, cursor }, "Fetching recordings"); const result = await recordingsService.getRecent(limit, { channelId, userId, + cursor, }); res.json(result); }), diff --git a/services/backend/src/modules/recordings/recordings.service.ts b/services/backend/src/modules/recordings/recordings.service.ts index 2e217e2..6379be8 100644 --- a/services/backend/src/modules/recordings/recordings.service.ts +++ b/services/backend/src/modules/recordings/recordings.service.ts @@ -4,18 +4,45 @@ import { getDatabase } from "../../shared/database/index.js"; const logger = createChildLogger("recordings.service"); +export interface RecordingRow { + id: string; + user_id: string; + username: string; + avatar_url: string | null; + guild_id: string | null; + channel_id: string | null; + channel_name: string | null; + filename: string; + size_bytes: number; + download_url: string | null; + upload_status: string; + upload_error: string | null; + created_at: number; + uploaded_at: number | null; + duration_bytes: number; +} + +export interface PaginatedRecordings { + items: RecordingRow[]; + nextCursor: string | null; + hasMore: boolean; +} + export class RecordingsService { async getRecent( limit = 50, - filters?: { channelId?: string; userId?: string }, - ) { + filters?: { channelId?: string; userId?: string; cursor?: string }, + ): Promise { logger.info({ limit }, "getRecent called"); const db = getDatabase(); - logger.debug({ limit }, "Fetching recent voice recordings"); const conditions: string[] = []; const params: unknown[] = []; + if (filters?.cursor) { + params.push(filters.cursor); + conditions.push(`created_at < $${params.length}::numeric`); + } if (filters?.channelId) { params.push(filters.channelId); conditions.push(`channel_id = $${params.length}`); @@ -37,10 +64,14 @@ export class RecordingsService { FROM voice_recordings ${sql.raw(whereClause)} ORDER BY created_at DESC - LIMIT ${limit} + LIMIT ${limit + 1} `); - return rows; + const items = rows.slice(0, limit) as RecordingRow[]; + const hasMore = rows.length > limit; + const nextCursor = hasMore ? String(items[items.length - 1]!.created_at) : null; + + return { items, nextCursor, hasMore }; } async deleteById(id: string): Promise { diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index 6b37c1e..7381327 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -194,6 +194,7 @@ export default function App() { levels={audio.levels} isListening={audio.isListening} isStreaming={transmit.isStreaming} + micLevel={transmit.micLevel} mediaState={media.mediaState} mediaLoading={media.loading} onGuildChange={(id) => diff --git a/services/frontend/src/features/dashboard/components/DashboardStats.tsx b/services/frontend/src/features/dashboard/components/DashboardStats.tsx index d4011f8..5e0b7e2 100644 --- a/services/frontend/src/features/dashboard/components/DashboardStats.tsx +++ b/services/frontend/src/features/dashboard/components/DashboardStats.tsx @@ -19,9 +19,11 @@ import { Skeleton, } from "../../../shared/ui"; import { useDashboardStats } from "../hooks/useDashboard"; +import { useUIState } from "../../../shared/hooks/useUIState"; export function DashboardStatsContent() { const { stats, loading, error, refetch } = useDashboardStats(); + const { patchUIState } = useUIState(); if (loading) { return ; @@ -100,6 +102,7 @@ export function DashboardStatsContent() { icon: Mic, color: "text-cyan-500", bg: "bg-cyan-100", + onClick: () => patchUIState({ activeTab: "live" }), }, { title: "AI Profiles", @@ -123,7 +126,11 @@ export function DashboardStatsContent() { className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4" > {cards.map((card) => ( - +
diff --git a/services/frontend/src/features/live/components/AudioVisualizer.tsx b/services/frontend/src/features/live/components/AudioVisualizer.tsx index b531598..ab6c113 100644 --- a/services/frontend/src/features/live/components/AudioVisualizer.tsx +++ b/services/frontend/src/features/live/components/AudioVisualizer.tsx @@ -35,7 +35,7 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) { const height = canvas.height / dpr; ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.scale(dpr, dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const barWidth = width / levels.length; const maxBarHeight = height * 0.85; diff --git a/services/frontend/src/features/live/components/MicLevelMeter.tsx b/services/frontend/src/features/live/components/MicLevelMeter.tsx new file mode 100644 index 0000000..e83b52d --- /dev/null +++ b/services/frontend/src/features/live/components/MicLevelMeter.tsx @@ -0,0 +1,29 @@ +// ─── Mic level meter — vertical bar showing outgoing audio RMS level ───────── + +interface MicLevelMeterProps { + level: number; // 0-1 +} + +export function MicLevelMeter({ level }: MicLevelMeterProps) { + const pct = Math.round(level * 100); + + // Color gradient: green <-> yellow <-> red + const hue = 120 - level * 120; // 120 (green) -> 0 (red) + const bg = `hsl(${hue}, 80%, 45%)`; + + return ( +
+
+
+ ); +} diff --git a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx index 1adefd0..8ed32cf 100644 --- a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx +++ b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx @@ -1,7 +1,7 @@ // ─── Recordings Sub-Panel ── -import { Download, Mic, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { Download, Mic, Pause, Play, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { VoiceRecording } from "../../../shared/api/client"; import { deleteRecording, listRecordings } from "../../../shared/api/client"; import { formatBytes, formatDate } from "../../../shared/lib/utils"; @@ -10,9 +10,14 @@ import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; export function RecordingsSubPanel() { const [recordings, setRecordings] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [deletingIds, setDeletingIds] = useState>(new Set()); + const [activePlayingId, setActivePlayingId] = useState(null); + const audioRefs = useRef>(new Map()); const loadRecordings = useCallback(async ( opts?: { signal?: AbortSignal }, @@ -20,8 +25,12 @@ export function RecordingsSubPanel() { try { setLoading(true); setError(null); - const data = await listRecordings(); - if (!opts?.signal?.aborted) setRecordings(data); + const data = await listRecordings({ limit: 50 }); + if (!opts?.signal?.aborted) { + setRecordings(data.items); + setNextCursor(data.nextCursor); + setHasMore(data.hasMore); + } } catch (err) { if (!opts?.signal?.aborted) setError(err instanceof Error ? err.message : String(err)); @@ -30,6 +39,21 @@ export function RecordingsSubPanel() { } }, []); + const loadMore = useCallback(async () => { + if (!nextCursor || loadingMore) return; + try { + setLoadingMore(true); + const data = await listRecordings({ limit: 50, cursor: nextCursor }); + setRecordings((prev) => [...prev, ...data.items]); + setNextCursor(data.nextCursor); + setHasMore(data.hasMore); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoadingMore(false); + } + }, [nextCursor, loadingMore]); + useEffect(() => { const ab = new AbortController(); loadRecordings({ signal: ab.signal }); @@ -58,6 +82,22 @@ export function RecordingsSubPanel() { } }, []); + const handleTogglePlay = useCallback((id: string) => { + setActivePlayingId((prev) => { + if (prev === id) { + // Pause current + audioRefs.current.get(id)?.pause(); + return null; + } + // Pause any previously playing + if (prev) audioRefs.current.get(prev)?.pause(); + // Play new + const audio = audioRefs.current.get(id); + if (audio) audio.play().catch(() => {}); + return id; + }); + }, []); + if (loading) { return (
@@ -147,18 +187,50 @@ export function RecordingsSubPanel() { {rec.upload_status} {rec.download_url && ( - - - + <> + +
))} + {hasMore && ( +
+ +
+ )}
); } diff --git a/services/frontend/src/features/live/components/VoiceConnectionCard.tsx b/services/frontend/src/features/live/components/VoiceConnectionCard.tsx index 2eb5c1e..8edb7f9 100644 --- a/services/frontend/src/features/live/components/VoiceConnectionCard.tsx +++ b/services/frontend/src/features/live/components/VoiceConnectionCard.tsx @@ -1,6 +1,7 @@ import { Headphones, Radio } from "lucide-react"; import type { Channel, Guild, VoiceStatus } from "../../../shared/api/client"; import { Button, Select } from "../../../shared/ui"; +import { MicLevelMeter } from "./MicLevelMeter"; interface VoiceConnectionCardProps { guilds: Guild[]; @@ -11,6 +12,7 @@ interface VoiceConnectionCardProps { voiceLoading: boolean; isListening: boolean; isStreaming: boolean; + micLevel: number; onGuildChange: (id: string) => void; onChannelChange: (id: string) => void; onJoin: () => void; @@ -28,6 +30,7 @@ export function VoiceConnectionCard({ voiceLoading, isListening, isStreaming, + micLevel, onGuildChange, onChannelChange, onJoin, @@ -100,6 +103,11 @@ export function VoiceConnectionCard({ {" "} {isStreaming ? "Stop Transmit" : "Transmit"} + {isStreaming && ( +
+ +
+ )}
diff --git a/services/frontend/src/features/live/components/index.ts b/services/frontend/src/features/live/components/index.ts index 54fc9d1..1545061 100644 --- a/services/frontend/src/features/live/components/index.ts +++ b/services/frontend/src/features/live/components/index.ts @@ -6,4 +6,5 @@ export { MusicSubPanel } from "./MusicSubPanel"; export { NowPlaying } from "./NowPlaying"; export { RecordingsSubPanel } from "./RecordingsSubPanel"; export { ScreenSubPanel } from "./ScreenSubPanel"; +export { MicLevelMeter } from "./MicLevelMeter"; export { VoiceConnectionCard } from "./VoiceConnectionCard"; diff --git a/services/frontend/src/features/live/index.tsx b/services/frontend/src/features/live/index.tsx index 1942eb5..af0d855 100644 --- a/services/frontend/src/features/live/index.tsx +++ b/services/frontend/src/features/live/index.tsx @@ -39,6 +39,7 @@ interface LivePanelProps { levels: number[]; isListening: boolean; isStreaming: boolean; + micLevel: number; mediaState: MediaState; mediaLoading: boolean; onGuildChange: (id: string) => void; @@ -65,6 +66,7 @@ export function LivePanel({ levels, isListening, isStreaming, + micLevel, mediaState, mediaLoading, onGuildChange, @@ -96,6 +98,7 @@ export function LivePanel({ voiceLoading={voiceLoading} isListening={isListening} isStreaming={isStreaming} + micLevel={micLevel} onGuildChange={onGuildChange} onChannelChange={onChannelChange} onJoin={onJoin} diff --git a/services/frontend/src/shared/api/client.ts b/services/frontend/src/shared/api/client.ts index 7ef446d..550d775 100644 --- a/services/frontend/src/shared/api/client.ts +++ b/services/frontend/src/shared/api/client.ts @@ -254,8 +254,16 @@ export interface VoiceRecording { uploaded_at: number | null; } -export function listRecordings(limit = 50): Promise { - return request(`/api/recordings?limit=${limit}`); +export function listRecordings(params?: { + limit?: number; + cursor?: string; +}): Promise<{ items: VoiceRecording[]; nextCursor: string | null; hasMore: boolean }> { + const sp = new URLSearchParams(); + sp.set("limit", String(params?.limit ?? 50)); + if (params?.cursor) sp.set("cursor", params.cursor); + return request<{ items: VoiceRecording[]; nextCursor: string | null; hasMore: boolean }>( + `/api/recordings?${sp}`, + ); } export function deleteRecording(id: string): Promise { diff --git a/services/frontend/src/shared/hooks/useAudioPlayback.ts b/services/frontend/src/shared/hooks/useAudioPlayback.ts index 9c3cbd3..95ad8a5 100644 --- a/services/frontend/src/shared/hooks/useAudioPlayback.ts +++ b/services/frontend/src/shared/hooks/useAudioPlayback.ts @@ -1,5 +1,5 @@ // ─── Audio playback hook — receives PCM from WebSocket and plays through Web Audio API ── -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { createLogger } from "../lib/logger.js"; const logger = createLogger("use-audio-playback"); @@ -22,6 +22,26 @@ export function useAudioPlayback() { const audioContextRef = useRef(null); const userTimelinesRef = useRef(new Map()); + // Cleanup AudioContext on unmount to prevent leak + useEffect(() => { + return () => { + const ctx = audioContextRef.current; + if (ctx) { + ctx.close(); + audioContextRef.current = null; + } + userTimelinesRef.current.clear(); + }; + }, []); + + // Prune stale timeline entries (> 30s old) based on current audioContext time + const pruneTimelines = useCallback(() => { + const now = audioContextRef.current?.currentTime ?? performance.now() / 1000; + for (const [userId, endTime] of userTimelinesRef.current) { + if (endTime + 30 < now) userTimelinesRef.current.delete(userId); + } + }, []); + const handleIncomingPcm = useCallback( (data: { userId: string; pcm: string }) => { // Decode base64 PCM data @@ -77,6 +97,7 @@ export function useAudioPlayback() { data.userId, nextStart + audioBuffer.duration, ); + pruneTimelines(); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error("Failed to decode PCM audio", { @@ -85,7 +106,7 @@ export function useAudioPlayback() { }); } }, - [isListening], + [isListening, pruneTimelines], ); const toggleListening = useCallback(async () => { diff --git a/services/frontend/src/shared/hooks/useAudioTransmit.ts b/services/frontend/src/shared/hooks/useAudioTransmit.ts index f540b89..34cd663 100644 --- a/services/frontend/src/shared/hooks/useAudioTransmit.ts +++ b/services/frontend/src/shared/hooks/useAudioTransmit.ts @@ -4,6 +4,7 @@ import { getAPIURL } from "../api/client.js"; import { createLogger } from "../lib/logger"; const SAMPLE_RATE = 24000; +const LEVEL_THROTTLE_MS = 50; // 20Hz mic level updates const logger = createLogger("useAudioTransmit"); async function sendTransmitCommand(command: string): Promise { @@ -44,9 +45,14 @@ export function useAudioTransmit(socketRef: { readonly current: WebSocket | null; }) { const [isStreaming, setIsStreaming] = useState(false); + const [micError, setMicError] = useState(null); + const [micLevel, setMicLevel] = useState(0); const streamRef = useRef(null); const audioContextRef = useRef(null); const processorRef = useRef(null); + const sourceRef = useRef(null); + const isTransmittingRef = useRef(false); + const lastLevelUpdateRef = useRef(0); const stop = useCallback(() => { // 6c: Prefer WebSocket round-trip over HTTP for lower latency @@ -54,11 +60,17 @@ export function useAudioTransmit(socketRef: { sendTransmitCommand("voice:transmit:stop").catch(() => {}); } + setMicError(null); setIsStreaming(false); + isTransmittingRef.current = false; if (processorRef.current) { processorRef.current.disconnect(); processorRef.current = null; } + if (sourceRef.current) { + sourceRef.current.disconnect(); + sourceRef.current = null; + } if (audioContextRef.current) { audioContextRef.current.close(); audioContextRef.current = null; @@ -67,17 +79,36 @@ export function useAudioTransmit(socketRef: { for (const track of streamRef.current.getTracks()) track.stop(); streamRef.current = null; } + setMicLevel(0); }, [socketRef]); const start = useCallback(async () => { + // Reset mic error on new attempt + setMicError(null); + // 6c: Prefer WebSocket round-trip over HTTP for lower latency if (!sendWsCommand(socketRef, "voice:transmit:start")) { await sendTransmitCommand("voice:transmit:start"); } - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch (err) { + if (err instanceof DOMException && err.name === "NotAllowedError") { + setMicError( + "Microphone access denied. Please allow microphone permissions.", + ); + } else { + const message = err instanceof Error ? err.message : String(err); + setMicError(`Microphone access failed: ${message}`); + } + logger.error("getUserMedia failed", { error: String(err) }); + return; + } streamRef.current = stream; setIsStreaming(true); + isTransmittingRef.current = true; const AudioContextCtor = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }) @@ -85,14 +116,31 @@ export function useAudioTransmit(socketRef: { const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE }); audioContextRef.current = audioContext; const source = audioContext.createMediaStreamSource(stream); + sourceRef.current = source; const processor = audioContext.createScriptProcessor(1024, 1, 1); processorRef.current = processor; source.connect(processor); processor.connect(audioContext.destination); processor.onaudioprocess = (event) => { + const inputData = event.inputBuffer.getChannelData(0); + + // Compute RMS from input buffer for mic level metering + let sumSquares = 0; + for (let i = 0; i < inputData.length; i++) { + sumSquares += inputData[i] * inputData[i]; + } + const rms = Math.sqrt(sumSquares / inputData.length); + const now = Date.now(); + if (now - lastLevelUpdateRef.current >= LEVEL_THROTTLE_MS) { + lastLevelUpdateRef.current = now; + // Scale so conversational speech hits ~0.3-0.6 + setMicLevel(Math.min(1, rms * 3)); + } + + if (!isTransmittingRef.current) return; if (!socketRef.current || 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; @@ -114,10 +162,34 @@ export function useAudioTransmit(socketRef: { }; }, [socketRef]); - const toggle = useCallback(async () => { - if (isStreaming) stop(); - else await start(); - }, [isStreaming, start, stop]); + const stopTransmit = useCallback(() => { + if (!isTransmittingRef.current) return; + isTransmittingRef.current = false; + if (!sendWsCommand(socketRef, "voice:transmit:stop")) { + sendTransmitCommand("voice:transmit:stop").catch(() => {}); + } + setIsStreaming(false); + }, [socketRef]); - return { isStreaming, toggle, stop, start }; + const startTransmit = useCallback(async () => { + if (isTransmittingRef.current) return; + isTransmittingRef.current = true; + if (!sendWsCommand(socketRef, "voice:transmit:start")) { + await sendTransmitCommand("voice:transmit:start"); + } + setIsStreaming(true); + }, [socketRef]); + + const toggle = useCallback(async () => { + if (isStreaming) { + stopTransmit(); + } else if (streamRef.current) { + // Mic already captured, resume transmission without re-acquiring + await startTransmit(); + } else { + await start(); + } + }, [isStreaming, startTransmit, stopTransmit, start]); + + return { isStreaming, micError, micLevel, toggle, stopTransmit, startTransmit, stop, start }; }