chore(auto): task completed - unknown
This commit is contained in:
@@ -16,10 +16,12 @@ export function createRecordingsRouter(): Router {
|
|||||||
const limit = Number(req.query.limit) || 50;
|
const limit = Number(req.query.limit) || 50;
|
||||||
const channelId = req.query.channelId as string | undefined;
|
const channelId = req.query.channelId as string | undefined;
|
||||||
const userId = req.query.userId 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, {
|
const result = await recordingsService.getRecent(limit, {
|
||||||
channelId,
|
channelId,
|
||||||
userId,
|
userId,
|
||||||
|
cursor,
|
||||||
});
|
});
|
||||||
res.json(result);
|
res.json(result);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,18 +4,45 @@ import { getDatabase } from "../../shared/database/index.js";
|
|||||||
|
|
||||||
const logger = createChildLogger("recordings.service");
|
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 {
|
export class RecordingsService {
|
||||||
async getRecent(
|
async getRecent(
|
||||||
limit = 50,
|
limit = 50,
|
||||||
filters?: { channelId?: string; userId?: string },
|
filters?: { channelId?: string; userId?: string; cursor?: string },
|
||||||
) {
|
): Promise<PaginatedRecordings> {
|
||||||
logger.info({ limit }, "getRecent called");
|
logger.info({ limit }, "getRecent called");
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
logger.debug({ limit }, "Fetching recent voice recordings");
|
|
||||||
|
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (filters?.cursor) {
|
||||||
|
params.push(filters.cursor);
|
||||||
|
conditions.push(`created_at < $${params.length}::numeric`);
|
||||||
|
}
|
||||||
if (filters?.channelId) {
|
if (filters?.channelId) {
|
||||||
params.push(filters.channelId);
|
params.push(filters.channelId);
|
||||||
conditions.push(`channel_id = $${params.length}`);
|
conditions.push(`channel_id = $${params.length}`);
|
||||||
@@ -37,10 +64,14 @@ export class RecordingsService {
|
|||||||
FROM voice_recordings
|
FROM voice_recordings
|
||||||
${sql.raw(whereClause)}
|
${sql.raw(whereClause)}
|
||||||
ORDER BY created_at DESC
|
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<void> {
|
async deleteById(id: string): Promise<void> {
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export default function App() {
|
|||||||
levels={audio.levels}
|
levels={audio.levels}
|
||||||
isListening={audio.isListening}
|
isListening={audio.isListening}
|
||||||
isStreaming={transmit.isStreaming}
|
isStreaming={transmit.isStreaming}
|
||||||
|
micLevel={transmit.micLevel}
|
||||||
mediaState={media.mediaState}
|
mediaState={media.mediaState}
|
||||||
mediaLoading={media.loading}
|
mediaLoading={media.loading}
|
||||||
onGuildChange={(id) =>
|
onGuildChange={(id) =>
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import {
|
|||||||
Skeleton,
|
Skeleton,
|
||||||
} from "../../../shared/ui";
|
} from "../../../shared/ui";
|
||||||
import { useDashboardStats } from "../hooks/useDashboard";
|
import { useDashboardStats } from "../hooks/useDashboard";
|
||||||
|
import { useUIState } from "../../../shared/hooks/useUIState";
|
||||||
|
|
||||||
export function DashboardStatsContent() {
|
export function DashboardStatsContent() {
|
||||||
const { stats, loading, error, refetch } = useDashboardStats();
|
const { stats, loading, error, refetch } = useDashboardStats();
|
||||||
|
const { patchUIState } = useUIState();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <StatsSkeleton />;
|
return <StatsSkeleton />;
|
||||||
@@ -100,6 +102,7 @@ export function DashboardStatsContent() {
|
|||||||
icon: Mic,
|
icon: Mic,
|
||||||
color: "text-cyan-500",
|
color: "text-cyan-500",
|
||||||
bg: "bg-cyan-100",
|
bg: "bg-cyan-100",
|
||||||
|
onClick: () => patchUIState({ activeTab: "live" }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "AI Profiles",
|
title: "AI Profiles",
|
||||||
@@ -123,7 +126,11 @@ export function DashboardStatsContent() {
|
|||||||
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
|
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
|
||||||
>
|
>
|
||||||
{cards.map((card) => (
|
{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">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
|||||||
const height = canvas.height / dpr;
|
const height = canvas.height / dpr;
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
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 barWidth = width / levels.length;
|
||||||
const maxBarHeight = height * 0.85;
|
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 ──
|
// ─── Recordings Sub-Panel ──
|
||||||
|
|
||||||
import { Download, Mic, Trash2 } from "lucide-react";
|
import { Download, Mic, Pause, Play, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { VoiceRecording } from "../../../shared/api/client";
|
import type { VoiceRecording } from "../../../shared/api/client";
|
||||||
import { deleteRecording, listRecordings } from "../../../shared/api/client";
|
import { deleteRecording, listRecordings } from "../../../shared/api/client";
|
||||||
import { formatBytes, formatDate } from "../../../shared/lib/utils";
|
import { formatBytes, formatDate } from "../../../shared/lib/utils";
|
||||||
@@ -10,9 +10,14 @@ import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
|||||||
|
|
||||||
export function RecordingsSubPanel() {
|
export function RecordingsSubPanel() {
|
||||||
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
||||||
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
|
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 (
|
const loadRecordings = useCallback(async (
|
||||||
opts?: { signal?: AbortSignal },
|
opts?: { signal?: AbortSignal },
|
||||||
@@ -20,8 +25,12 @@ export function RecordingsSubPanel() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const data = await listRecordings();
|
const data = await listRecordings({ limit: 50 });
|
||||||
if (!opts?.signal?.aborted) setRecordings(data);
|
if (!opts?.signal?.aborted) {
|
||||||
|
setRecordings(data.items);
|
||||||
|
setNextCursor(data.nextCursor);
|
||||||
|
setHasMore(data.hasMore);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!opts?.signal?.aborted)
|
if (!opts?.signal?.aborted)
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
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(() => {
|
useEffect(() => {
|
||||||
const ab = new AbortController();
|
const ab = new AbortController();
|
||||||
loadRecordings({ signal: ab.signal });
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -147,18 +187,50 @@ export function RecordingsSubPanel() {
|
|||||||
{rec.upload_status}
|
{rec.upload_status}
|
||||||
</Badge>
|
</Badge>
|
||||||
{rec.download_url && (
|
{rec.download_url && (
|
||||||
<a
|
<>
|
||||||
href={rec.download_url}
|
<Button
|
||||||
target="_blank"
|
size="sm"
|
||||||
rel="noreferrer"
|
variant={activePlayingId === rec.id ? "default" : "outline"}
|
||||||
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
onClick={() => handleTogglePlay(rec.id)}
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
{activePlayingId === rec.id
|
||||||
</a>
|
? <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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Headphones, Radio } from "lucide-react";
|
import { Headphones, Radio } from "lucide-react";
|
||||||
import type { Channel, Guild, VoiceStatus } from "../../../shared/api/client";
|
import type { Channel, Guild, VoiceStatus } from "../../../shared/api/client";
|
||||||
import { Button, Select } from "../../../shared/ui";
|
import { Button, Select } from "../../../shared/ui";
|
||||||
|
import { MicLevelMeter } from "./MicLevelMeter";
|
||||||
|
|
||||||
interface VoiceConnectionCardProps {
|
interface VoiceConnectionCardProps {
|
||||||
guilds: Guild[];
|
guilds: Guild[];
|
||||||
@@ -11,6 +12,7 @@ interface VoiceConnectionCardProps {
|
|||||||
voiceLoading: boolean;
|
voiceLoading: boolean;
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
micLevel: number;
|
||||||
onGuildChange: (id: string) => void;
|
onGuildChange: (id: string) => void;
|
||||||
onChannelChange: (id: string) => void;
|
onChannelChange: (id: string) => void;
|
||||||
onJoin: () => void;
|
onJoin: () => void;
|
||||||
@@ -28,6 +30,7 @@ export function VoiceConnectionCard({
|
|||||||
voiceLoading,
|
voiceLoading,
|
||||||
isListening,
|
isListening,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
micLevel,
|
||||||
onGuildChange,
|
onGuildChange,
|
||||||
onChannelChange,
|
onChannelChange,
|
||||||
onJoin,
|
onJoin,
|
||||||
@@ -100,6 +103,11 @@ export function VoiceConnectionCard({
|
|||||||
<Radio className="mr-1.5 h-4 w-4" />{" "}
|
<Radio className="mr-1.5 h-4 w-4" />{" "}
|
||||||
{isStreaming ? "Stop Transmit" : "Transmit"}
|
{isStreaming ? "Stop Transmit" : "Transmit"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{isStreaming && (
|
||||||
|
<div className="flex items-center pl-1">
|
||||||
|
<MicLevelMeter level={micLevel} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export { MusicSubPanel } from "./MusicSubPanel";
|
|||||||
export { NowPlaying } from "./NowPlaying";
|
export { NowPlaying } from "./NowPlaying";
|
||||||
export { RecordingsSubPanel } from "./RecordingsSubPanel";
|
export { RecordingsSubPanel } from "./RecordingsSubPanel";
|
||||||
export { ScreenSubPanel } from "./ScreenSubPanel";
|
export { ScreenSubPanel } from "./ScreenSubPanel";
|
||||||
|
export { MicLevelMeter } from "./MicLevelMeter";
|
||||||
export { VoiceConnectionCard } from "./VoiceConnectionCard";
|
export { VoiceConnectionCard } from "./VoiceConnectionCard";
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface LivePanelProps {
|
|||||||
levels: number[];
|
levels: number[];
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
micLevel: number;
|
||||||
mediaState: MediaState;
|
mediaState: MediaState;
|
||||||
mediaLoading: boolean;
|
mediaLoading: boolean;
|
||||||
onGuildChange: (id: string) => void;
|
onGuildChange: (id: string) => void;
|
||||||
@@ -65,6 +66,7 @@ export function LivePanel({
|
|||||||
levels,
|
levels,
|
||||||
isListening,
|
isListening,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
micLevel,
|
||||||
mediaState,
|
mediaState,
|
||||||
mediaLoading,
|
mediaLoading,
|
||||||
onGuildChange,
|
onGuildChange,
|
||||||
@@ -96,6 +98,7 @@ export function LivePanel({
|
|||||||
voiceLoading={voiceLoading}
|
voiceLoading={voiceLoading}
|
||||||
isListening={isListening}
|
isListening={isListening}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
micLevel={micLevel}
|
||||||
onGuildChange={onGuildChange}
|
onGuildChange={onGuildChange}
|
||||||
onChannelChange={onChannelChange}
|
onChannelChange={onChannelChange}
|
||||||
onJoin={onJoin}
|
onJoin={onJoin}
|
||||||
|
|||||||
@@ -254,8 +254,16 @@ export interface VoiceRecording {
|
|||||||
uploaded_at: number | null;
|
uploaded_at: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listRecordings(limit = 50): Promise<VoiceRecording[]> {
|
export function listRecordings(params?: {
|
||||||
return request<VoiceRecording[]>(`/api/recordings?limit=${limit}`);
|
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> {
|
export function deleteRecording(id: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// ─── Audio playback hook — receives PCM from WebSocket and plays through Web Audio API ──
|
// ─── 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";
|
import { createLogger } from "../lib/logger.js";
|
||||||
|
|
||||||
const logger = createLogger("use-audio-playback");
|
const logger = createLogger("use-audio-playback");
|
||||||
@@ -22,6 +22,26 @@ export function useAudioPlayback() {
|
|||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
const userTimelinesRef = useRef(new Map<string, number>());
|
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(
|
const handleIncomingPcm = useCallback(
|
||||||
(data: { userId: string; pcm: string }) => {
|
(data: { userId: string; pcm: string }) => {
|
||||||
// Decode base64 PCM data
|
// Decode base64 PCM data
|
||||||
@@ -77,6 +97,7 @@ export function useAudioPlayback() {
|
|||||||
data.userId,
|
data.userId,
|
||||||
nextStart + audioBuffer.duration,
|
nextStart + audioBuffer.duration,
|
||||||
);
|
);
|
||||||
|
pruneTimelines();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
logger.error("Failed to decode PCM audio", {
|
logger.error("Failed to decode PCM audio", {
|
||||||
@@ -85,7 +106,7 @@ export function useAudioPlayback() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isListening],
|
[isListening, pruneTimelines],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleListening = useCallback(async () => {
|
const toggleListening = useCallback(async () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getAPIURL } from "../api/client.js";
|
|||||||
import { createLogger } from "../lib/logger";
|
import { createLogger } from "../lib/logger";
|
||||||
|
|
||||||
const SAMPLE_RATE = 24000;
|
const SAMPLE_RATE = 24000;
|
||||||
|
const LEVEL_THROTTLE_MS = 50; // 20Hz mic level updates
|
||||||
const logger = createLogger("useAudioTransmit");
|
const logger = createLogger("useAudioTransmit");
|
||||||
|
|
||||||
async function sendTransmitCommand(command: string): Promise<void> {
|
async function sendTransmitCommand(command: string): Promise<void> {
|
||||||
@@ -44,9 +45,14 @@ export function useAudioTransmit(socketRef: {
|
|||||||
readonly current: WebSocket | null;
|
readonly current: WebSocket | null;
|
||||||
}) {
|
}) {
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [micError, setMicError] = useState<string | null>(null);
|
||||||
|
const [micLevel, setMicLevel] = useState(0);
|
||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
const processorRef = useRef<ScriptProcessorNode | 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(() => {
|
const stop = useCallback(() => {
|
||||||
// 6c: Prefer WebSocket round-trip over HTTP for lower latency
|
// 6c: Prefer WebSocket round-trip over HTTP for lower latency
|
||||||
@@ -54,11 +60,17 @@ export function useAudioTransmit(socketRef: {
|
|||||||
sendTransmitCommand("voice:transmit:stop").catch(() => {});
|
sendTransmitCommand("voice:transmit:stop").catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMicError(null);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
|
isTransmittingRef.current = false;
|
||||||
if (processorRef.current) {
|
if (processorRef.current) {
|
||||||
processorRef.current.disconnect();
|
processorRef.current.disconnect();
|
||||||
processorRef.current = null;
|
processorRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (sourceRef.current) {
|
||||||
|
sourceRef.current.disconnect();
|
||||||
|
sourceRef.current = null;
|
||||||
|
}
|
||||||
if (audioContextRef.current) {
|
if (audioContextRef.current) {
|
||||||
audioContextRef.current.close();
|
audioContextRef.current.close();
|
||||||
audioContextRef.current = null;
|
audioContextRef.current = null;
|
||||||
@@ -67,17 +79,36 @@ export function useAudioTransmit(socketRef: {
|
|||||||
for (const track of streamRef.current.getTracks()) track.stop();
|
for (const track of streamRef.current.getTracks()) track.stop();
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
}
|
}
|
||||||
|
setMicLevel(0);
|
||||||
}, [socketRef]);
|
}, [socketRef]);
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(async () => {
|
||||||
|
// Reset mic error on new attempt
|
||||||
|
setMicError(null);
|
||||||
|
|
||||||
// 6c: Prefer WebSocket round-trip over HTTP for lower latency
|
// 6c: Prefer WebSocket round-trip over HTTP for lower latency
|
||||||
if (!sendWsCommand(socketRef, "voice:transmit:start")) {
|
if (!sendWsCommand(socketRef, "voice:transmit:start")) {
|
||||||
await sendTransmitCommand("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;
|
streamRef.current = stream;
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
|
isTransmittingRef.current = true;
|
||||||
const AudioContextCtor =
|
const AudioContextCtor =
|
||||||
window.AudioContext ||
|
window.AudioContext ||
|
||||||
(window as unknown as { webkitAudioContext: typeof AudioContext })
|
(window as unknown as { webkitAudioContext: typeof AudioContext })
|
||||||
@@ -85,14 +116,31 @@ export function useAudioTransmit(socketRef: {
|
|||||||
const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE });
|
const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE });
|
||||||
audioContextRef.current = audioContext;
|
audioContextRef.current = audioContext;
|
||||||
const source = audioContext.createMediaStreamSource(stream);
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
sourceRef.current = source;
|
||||||
const processor = audioContext.createScriptProcessor(1024, 1, 1);
|
const processor = audioContext.createScriptProcessor(1024, 1, 1);
|
||||||
processorRef.current = processor;
|
processorRef.current = processor;
|
||||||
source.connect(processor);
|
source.connect(processor);
|
||||||
processor.connect(audioContext.destination);
|
processor.connect(audioContext.destination);
|
||||||
processor.onaudioprocess = (event) => {
|
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)
|
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN)
|
||||||
return;
|
return;
|
||||||
const inputData = event.inputBuffer.getChannelData(0);
|
|
||||||
const pcmData = new Int16Array(inputData.length);
|
const pcmData = new Int16Array(inputData.length);
|
||||||
for (let i = 0; i < inputData.length; i++)
|
for (let i = 0; i < inputData.length; i++)
|
||||||
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
||||||
@@ -114,10 +162,34 @@ export function useAudioTransmit(socketRef: {
|
|||||||
};
|
};
|
||||||
}, [socketRef]);
|
}, [socketRef]);
|
||||||
|
|
||||||
const toggle = useCallback(async () => {
|
const stopTransmit = useCallback(() => {
|
||||||
if (isStreaming) stop();
|
if (!isTransmittingRef.current) return;
|
||||||
else await start();
|
isTransmittingRef.current = false;
|
||||||
}, [isStreaming, start, stop]);
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user