chore(auto): task completed - unknown

This commit is contained in:
MythEclipse
2026-06-13 14:49:47 +07:00
parent 3965ea79bc
commit fce24278f0
13 changed files with 286 additions and 31 deletions
+1
View File
@@ -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) =>
@@ -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 <StatsSkeleton />;
@@ -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) => (
<Card key={card.title} className="overflow-hidden">
<Card
key={card.title}
className={cn("overflow-hidden", card.onClick && "cursor-pointer transition-colors hover:bg-accent/50")}
onClick={card.onClick}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
@@ -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;
@@ -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 (
<div
className="relative flex h-6 w-24 overflow-hidden rounded-full bg-muted"
role="meter"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Microphone level"
>
<div
className="h-full rounded-full transition-[width,background-color] duration-75 ease-linear"
style={{ width: `${pct}%`, backgroundColor: bg }}
/>
</div>
);
}
@@ -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<VoiceRecording[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
const [activePlayingId, setActivePlayingId] = useState<string | null>(null);
const audioRefs = useRef<Map<string, HTMLAudioElement>>(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 (
<div className="space-y-3">
@@ -147,18 +187,50 @@ export function RecordingsSubPanel() {
{rec.upload_status}
</Badge>
{rec.download_url && (
<a
href={rec.download_url}
target="_blank"
rel="noreferrer"
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Download className="h-4 w-4" />
</a>
<>
<Button
size="sm"
variant={activePlayingId === rec.id ? "default" : "outline"}
onClick={() => handleTogglePlay(rec.id)}
>
{activePlayingId === rec.id
? <Pause className="h-4 w-4" />
: <Play className="h-4 w-4" />}
</Button>
<audio
ref={(el) => {
if (el) audioRefs.current.set(rec.id, el);
else audioRefs.current.delete(rec.id);
}}
src={rec.download_url}
preload="none"
onEnded={() => setActivePlayingId((p) => p === rec.id ? null : p)}
className="hidden"
/>
<a
href={rec.download_url}
download={rec.filename}
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Download className="h-4 w-4" />
</a>
</>
)}
</div>
</div>
))}
{hasMore && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
disabled={loadingMore}
onClick={loadMore}
>
{loadingMore ? "Loading..." : "Load More"}
</Button>
</div>
)}
</div>
);
}
@@ -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({
<Radio className="mr-1.5 h-4 w-4" />{" "}
{isStreaming ? "Stop Transmit" : "Transmit"}
</Button>
{isStreaming && (
<div className="flex items-center pl-1">
<MicLevelMeter level={micLevel} />
</div>
)}
</div>
</div>
</div>
@@ -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";
@@ -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}
+10 -2
View File
@@ -254,8 +254,16 @@ export interface VoiceRecording {
uploaded_at: number | null;
}
export function listRecordings(limit = 50): Promise<VoiceRecording[]> {
return request<VoiceRecording[]>(`/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<void> {
@@ -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<AudioContext | null>(null);
const userTimelinesRef = useRef(new Map<string, number>());
// 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 () => {
@@ -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<void> {
@@ -44,9 +45,14 @@ export function useAudioTransmit(socketRef: {
readonly current: WebSocket | null;
}) {
const [isStreaming, setIsStreaming] = useState(false);
const [micError, setMicError] = useState<string | null>(null);
const [micLevel, setMicLevel] = useState(0);
const streamRef = useRef<MediaStream | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const processorRef = useRef<ScriptProcessorNode | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(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 };
}