feat(fe): play Discord voice live + fix recording play/download
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m42s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 4m11s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 11m4s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m42s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 4m11s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 11m4s
Voice page (connection tab): - ListenControl baru: toggle Listen (Headphones) — mulai PcmPlayer dari user gesture, subscribe onPcm WS, volume slider, bar level per-user REAL dari PCM (bukan random). - lib/audio/pcm-player.ts (baru): ScriptProcessorNode mixer — ring buffer 2s per user (hash FNV-1a sama dengan gateway), upsampling 24k→48k linear, mix semua user ke mono, gain volume, cleanup ring diam 5s. - useVoiceListen + hashUserId di hooks; auto-stop saat disconnect. Recordings: - recording-player: reset src+load+play() eksplisit (bukan autoPlay doang), tampilkan filename + error state 'playback failed' kalau file rusak. - recording-card: tombol Download fetch blob (CORS tele open) → objectURL → force download dengan nama asli; fallback buka tab baru kalau fetch gagal; spinner saat mendownload. Verified: FE tsc 0, next build 10/10 static pages.
This commit is contained in:
@@ -153,6 +153,7 @@ export default function RecordingsPage() {
|
|||||||
|
|
||||||
<RecordingPlayer
|
<RecordingPlayer
|
||||||
url={currentTrack?.download_url ?? undefined}
|
url={currentTrack?.download_url ?? undefined}
|
||||||
|
filename={currentTrack?.filename ?? undefined}
|
||||||
onClose={() => setPlayingId(null)}
|
onClose={() => setPlayingId(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { SubNav } from "@/components/layout/sub-nav";
|
import { SubNav } from "@/components/layout/sub-nav";
|
||||||
import { VoiceActivityTimeline } from "@/components/voice/activity-timeline";
|
import { VoiceActivityTimeline } from "@/components/voice/activity-timeline";
|
||||||
import { VoiceConnectionCard } from "@/components/voice/connection-card";
|
import { VoiceConnectionCard } from "@/components/voice/connection-card";
|
||||||
|
import { ListenControl } from "@/components/voice/listen-control";
|
||||||
import { MicControl } from "@/components/voice/mic-control";
|
import { MicControl } from "@/components/voice/mic-control";
|
||||||
import { SpeakerWaveform } from "@/components/voice/speaker-waveform";
|
import { SpeakerWaveform } from "@/components/voice/speaker-waveform";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
useVoiceChannels,
|
useVoiceChannels,
|
||||||
useVoiceConnect,
|
useVoiceConnect,
|
||||||
useVoiceDisconnect,
|
useVoiceDisconnect,
|
||||||
|
useVoiceListen,
|
||||||
useVoiceStatus,
|
useVoiceStatus,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
@@ -29,9 +31,11 @@ export default function VoicePage() {
|
|||||||
const connectMut = useVoiceConnect();
|
const connectMut = useVoiceConnect();
|
||||||
const disconnectMut = useVoiceDisconnect();
|
const disconnectMut = useVoiceDisconnect();
|
||||||
const micMut = useMicTransmit(ws);
|
const micMut = useMicTransmit(ws);
|
||||||
|
const listen = useVoiceListen(ws);
|
||||||
const [selectedChannel, setSelectedChannel] = useState("");
|
const [selectedChannel, setSelectedChannel] = useState("");
|
||||||
const [micActive, setMicActive] = useState(false);
|
const [micActive, setMicActive] = useState(false);
|
||||||
const [volume, setVolume] = useState(75);
|
const [volume, setVolume] = useState(75);
|
||||||
|
const [listenVolume, setListenVolume] = useState(75);
|
||||||
const [tab, setTab] = useState<VoiceTab>("connection");
|
const [tab, setTab] = useState<VoiceTab>("connection");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -111,6 +115,7 @@ export default function VoicePage() {
|
|||||||
setMicActive(false);
|
setMicActive(false);
|
||||||
void micMut.mutateAsync(false).catch(() => {});
|
void micMut.mutateAsync(false).catch(() => {});
|
||||||
}
|
}
|
||||||
|
if (listen.active) listen.toggle(false);
|
||||||
disconnectMut.mutate(undefined);
|
disconnectMut.mutate(undefined);
|
||||||
}}
|
}}
|
||||||
connecting={connectMut.isPending}
|
connecting={connectMut.isPending}
|
||||||
@@ -119,13 +124,27 @@ export default function VoicePage() {
|
|||||||
{tab === "connection" && (
|
{tab === "connection" && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<SpeakerWaveform speakers={activeSpeakers} />
|
<SpeakerWaveform speakers={activeSpeakers} />
|
||||||
<MicControl
|
<div className="space-y-4">
|
||||||
connected={connected}
|
<ListenControl
|
||||||
active={micActive}
|
connected={connected}
|
||||||
onToggle={handleMicToggle}
|
active={listen.active}
|
||||||
volume={volume}
|
levels={listen.levels}
|
||||||
onVolumeChange={handleVolumeChange}
|
speakers={speakers}
|
||||||
/>
|
onToggle={(on) => listen.toggle(on)}
|
||||||
|
volume={listenVolume}
|
||||||
|
onVolumeChange={(v) => {
|
||||||
|
setListenVolume(v);
|
||||||
|
listen.setVolume(v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MicControl
|
||||||
|
connected={connected}
|
||||||
|
active={micActive}
|
||||||
|
onToggle={handleMicToggle}
|
||||||
|
volume={volume}
|
||||||
|
onVolumeChange={handleVolumeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Download, Play } from "lucide-react";
|
import { useState } from "react";
|
||||||
|
import { Download, Loader2, Play } from "lucide-react";
|
||||||
import { GlassCard } from "@/components/glass/card";
|
import { GlassCard } from "@/components/glass/card";
|
||||||
import type { VoiceRecording } from "@/lib/types";
|
import type { VoiceRecording } from "@/lib/types";
|
||||||
|
|
||||||
@@ -10,10 +11,36 @@ interface RecordingCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RecordingCard({ recording, onPlay }: RecordingCardProps) {
|
export function RecordingCard({ recording, onPlay }: RecordingCardProps) {
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
const durationStr = recording.duration_bytes
|
const durationStr = recording.duration_bytes
|
||||||
? `${Math.floor(recording.duration_bytes / 60)}:${String(recording.duration_bytes % 60).padStart(2, "0")}`
|
? `${Math.floor(recording.duration_bytes / 60)}:${String(recording.duration_bytes % 60).padStart(2, "0")}`
|
||||||
: "--:--";
|
: "--:--";
|
||||||
|
|
||||||
|
// Fetch the file (CORS is open on the uploader) → blob → force download with
|
||||||
|
// the real filename. Falls back to opening the URL in a new tab.
|
||||||
|
const handleDownload = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!recording.download_url || downloading) return;
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(recording.download_url);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const objUrl = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = objUrl;
|
||||||
|
a.download = recording.filename ?? `recording-${recording.id}.mp3`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(objUrl), 30_000);
|
||||||
|
} catch {
|
||||||
|
window.open(recording.download_url, "_blank", "noopener,noreferrer");
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassCard variant="interactive" className="p-4" onClick={() => onPlay(recording.id)}>
|
<GlassCard variant="interactive" className="p-4" onClick={() => onPlay(recording.id)}>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -50,9 +77,19 @@ export function RecordingCard({ recording, onPlay }: RecordingCardProps) {
|
|||||||
|
|
||||||
<div className="flex gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
<div className="flex gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
{recording.download_url && (
|
{recording.download_url && (
|
||||||
<a href={recording.download_url} target="_blank" rel="noopener noreferrer" className="size-7 flex items-center justify-center rounded glass hover:glass-elevated transition-all">
|
<button
|
||||||
<Download className="size-3 text-text-secondary/60" />
|
type="button"
|
||||||
</a>
|
onClick={handleDownload}
|
||||||
|
disabled={downloading}
|
||||||
|
title="Download"
|
||||||
|
className="size-7 flex items-center justify-center rounded glass hover:glass-elevated transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{downloading ? (
|
||||||
|
<Loader2 className="size-3 text-text-secondary/60 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="size-3 text-text-secondary/60" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,31 +1,57 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { GlassPanel } from "@/components/glass/panel";
|
import { GlassPanel } from "@/components/glass/panel";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
interface RecordingPlayerProps {
|
interface RecordingPlayerProps {
|
||||||
url?: string;
|
url?: string;
|
||||||
|
filename?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RecordingPlayer({ url, onClose }: RecordingPlayerProps) {
|
export function RecordingPlayer({ url, filename, onClose }: RecordingPlayerProps) {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (url && audioRef.current) {
|
const audio = audioRef.current;
|
||||||
audioRef.current?.play().catch(() => {});
|
if (!url || !audio) return;
|
||||||
}
|
setError(false);
|
||||||
|
// Fresh element state: reset src, load, then play (the click that opened
|
||||||
|
// the player counts as a user gesture, so autoplay is allowed).
|
||||||
|
audio.src = url;
|
||||||
|
audio.load();
|
||||||
|
const p = audio.play();
|
||||||
|
if (p) p.catch(() => setError(true));
|
||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassPanel dense className="fixed bottom-20 left-4 z-30 w-72 flex items-center gap-3">
|
<GlassPanel dense className="fixed bottom-20 left-4 z-30 w-80 flex flex-col gap-1.5">
|
||||||
<audio ref={audioRef} src={url} controls className="flex-1 h-8 [&::-webkit-media-controls-panel]:bg-transparent" autoPlay />
|
<div className="flex items-center gap-3">
|
||||||
<button type="button" onClick={onClose}>
|
<audio
|
||||||
<X className="size-3.5 text-text-secondary/60 hover:text-text-primary" />
|
ref={audioRef}
|
||||||
</button>
|
controls
|
||||||
|
preload="auto"
|
||||||
|
className="flex-1 h-8 [&::-webkit-media-controls-panel]:bg-transparent"
|
||||||
|
onError={() => setError(true)}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={onClose} className="shrink-0">
|
||||||
|
<X className="size-3.5 text-text-secondary/60 hover:text-text-primary" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-0.5">
|
||||||
|
<span className="truncate text-[10px] font-mono text-text-secondary/60">
|
||||||
|
{filename ?? "recording"}
|
||||||
|
</span>
|
||||||
|
{error && (
|
||||||
|
<span className="shrink-0 text-[10px] text-red-400/90">
|
||||||
|
playback failed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { GlassCard } from "@/components/glass/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { hashUserId } from "@/hooks";
|
||||||
|
import type { ActiveSpeaker } from "@/lib/types";
|
||||||
|
import { Headphones, HeadphoneOff } from "lucide-react";
|
||||||
|
|
||||||
|
interface ListenControlProps {
|
||||||
|
connected: boolean;
|
||||||
|
active: boolean;
|
||||||
|
levels: Map<number, number>;
|
||||||
|
speakers: ActiveSpeaker[];
|
||||||
|
onToggle: (active: boolean) => void;
|
||||||
|
volume: number;
|
||||||
|
onVolumeChange: (v: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live bar for one speaker — level 0..1 from the PCM player. */
|
||||||
|
function SpeakerLevel({
|
||||||
|
speaker,
|
||||||
|
level,
|
||||||
|
}: {
|
||||||
|
speaker: ActiveSpeaker;
|
||||||
|
level: number;
|
||||||
|
}) {
|
||||||
|
const [bars, setBars] = useState<number[]>(Array(24).fill(0.06));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setBars((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
for (let i = 0; i < next.length; i++) {
|
||||||
|
const target = level > 0.004 ? level * 0.9 + 0.08 : 0.05;
|
||||||
|
next[i] = next[i] + (target - next[i]) * 0.35;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 60);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [level]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-24 truncate text-[11px] text-text-secondary">
|
||||||
|
{speaker.username}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-1 items-end gap-[2px] h-6">
|
||||||
|
{bars.map((h, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 rounded-t-sm bg-primary/70 transition-[height]"
|
||||||
|
style={{ height: `${Math.max(6, h * 100)}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="w-8 text-right font-mono text-[10px] text-text-secondary/50">
|
||||||
|
{Math.round(level * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListenControl({
|
||||||
|
connected,
|
||||||
|
active,
|
||||||
|
levels,
|
||||||
|
speakers,
|
||||||
|
onToggle,
|
||||||
|
volume,
|
||||||
|
onVolumeChange,
|
||||||
|
}: ListenControlProps) {
|
||||||
|
const activeLevels = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const s of speakers) {
|
||||||
|
const lvl = levels.get(hashUserId(s.userId)) ?? 0;
|
||||||
|
map.set(s.userId, lvl);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [speakers, levels]);
|
||||||
|
|
||||||
|
const talking = useMemo(
|
||||||
|
() => [...activeLevels.values()].some((l) => l > 0.004),
|
||||||
|
[activeLevels],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassCard variant="base">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant={active ? "default" : "secondary"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onToggle(!active)}
|
||||||
|
disabled={!connected}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
{active ? (
|
||||||
|
<Headphones className="size-4 mr-1" />
|
||||||
|
) : (
|
||||||
|
<HeadphoneOff className="size-4 mr-1" />
|
||||||
|
)}
|
||||||
|
{active ? "Listening" : "Listen"}
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1 flex items-center gap-2">
|
||||||
|
<span className="text-[10px] text-text-secondary/60 font-mono">Vol</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => onVolumeChange(Number(e.target.value))}
|
||||||
|
className="flex-1 h-1 appearance-none bg-glass-border rounded-full accent-primary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-[0_0_8px] [&::-webkit-slider-thumb]:shadow-primary/60"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-mono text-text-secondary w-8 text-right">
|
||||||
|
{volume}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
{active && speakers.length > 0 ? (
|
||||||
|
speakers.map((s) => (
|
||||||
|
<SpeakerLevel
|
||||||
|
key={s.userId}
|
||||||
|
speaker={s}
|
||||||
|
level={activeLevels.get(s.userId) ?? 0}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-text-secondary/40">
|
||||||
|
{!connected
|
||||||
|
? "Connect to a voice channel first."
|
||||||
|
: active
|
||||||
|
? "Listening for Discord voice…"
|
||||||
|
: "Toggle Listen to hear Discord voice."}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{active && talking && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[10px] text-primary/80">
|
||||||
|
<span className="relative flex size-1.5">
|
||||||
|
<span className="absolute inline-flex size-full rounded-full bg-primary opacity-75 live-pulse-ring" />
|
||||||
|
<span className="relative inline-flex size-1.5 rounded-full bg-primary" />
|
||||||
|
</span>
|
||||||
|
live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,10 +34,12 @@ export {
|
|||||||
useRecordingsWsSync,
|
useRecordingsWsSync,
|
||||||
} from "./use-recordings";
|
} from "./use-recordings";
|
||||||
export {
|
export {
|
||||||
|
hashUserId,
|
||||||
useMicTransmit,
|
useMicTransmit,
|
||||||
useSpeakers,
|
useSpeakers,
|
||||||
useVoiceChannels,
|
useVoiceChannels,
|
||||||
useVoiceConnect,
|
useVoiceConnect,
|
||||||
useVoiceDisconnect,
|
useVoiceDisconnect,
|
||||||
|
useVoiceListen,
|
||||||
useVoiceStatus,
|
useVoiceStatus,
|
||||||
} from "./use-voice";
|
} from "./use-voice";
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import useSWR, { useSWRConfig } from "swr";
|
import useSWR, { useSWRConfig } from "swr";
|
||||||
import { useAction } from "@/hooks/use-action";
|
import { useAction } from "@/hooks/use-action";
|
||||||
import { voiceApi } from "@/lib/api";
|
import { voiceApi } from "@/lib/api";
|
||||||
import { MicTransmitter } from "@/lib/audio/mic-transmit";
|
import { MicTransmitter } from "@/lib/audio/mic-transmit";
|
||||||
|
import { PcmPlayer } from "@/lib/audio/pcm-player";
|
||||||
import type { ActiveSpeaker, Channel, VoiceStatus } from "@/lib/types";
|
import type { ActiveSpeaker, Channel, VoiceStatus } from "@/lib/types";
|
||||||
|
import type { PcmChunk } from "@/lib/ws/types";
|
||||||
import type { WsHook } from "@/lib/ws-hook";
|
import type { WsHook } from "@/lib/ws-hook";
|
||||||
|
|
||||||
|
/** FNV-1a 32-bit — same hash the gateway uses to tag PCM frames. */
|
||||||
|
export function hashUserId(userId: string): number {
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < userId.length; i++) {
|
||||||
|
hash ^= userId.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 0x01000193);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_KEY = ["voice-status"] as const;
|
const STATUS_KEY = ["voice-status"] as const;
|
||||||
|
|
||||||
export function useVoiceStatus() {
|
export function useVoiceStatus() {
|
||||||
@@ -90,3 +102,60 @@ export function useMicTransmit(ws: {
|
|||||||
|
|
||||||
return { ...action, setVolume };
|
return { ...action, setVolume };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receive + play Discord voice in the browser.
|
||||||
|
*
|
||||||
|
* Toggling on (from a user gesture) starts a PcmPlayer, subscribes to the WS
|
||||||
|
* binary PCM stream, and exposes per-user activity levels for the waveform UI.
|
||||||
|
* `toggle(false)` tears everything down.
|
||||||
|
*/
|
||||||
|
export function useVoiceListen(ws: {
|
||||||
|
onPcm: (handler: (chunk: PcmChunk) => void) => () => void;
|
||||||
|
}) {
|
||||||
|
const playerRef = useRef<PcmPlayer | null>(null);
|
||||||
|
const unsubRef = useRef<(() => void) | null>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const [levels, setLevels] = useState<Map<number, number>>(new Map());
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
unsubRef.current?.();
|
||||||
|
unsubRef.current = null;
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
playerRef.current?.stop();
|
||||||
|
playerRef.current = null;
|
||||||
|
setActive(false);
|
||||||
|
setLevels(new Map());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
(on: boolean) => {
|
||||||
|
if (on) {
|
||||||
|
const player = new PcmPlayer();
|
||||||
|
playerRef.current = player;
|
||||||
|
player.setVolume(0.75);
|
||||||
|
player.start(); // called from the click gesture
|
||||||
|
unsubRef.current = ws.onPcm((chunk) => {
|
||||||
|
player.push(chunk.userIdHash, chunk.samples);
|
||||||
|
});
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setLevels(player.getLevels());
|
||||||
|
}, 100);
|
||||||
|
setActive(true);
|
||||||
|
} else {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ws, stop],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setVolume = useCallback((v: number) => {
|
||||||
|
playerRef.current?.setVolume(v / 100);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => stop, [stop]);
|
||||||
|
|
||||||
|
return { active, levels, toggle, setVolume };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Receive Discord voice PCM over the WebSocket and play it through the
|
||||||
|
* browser audio stack.
|
||||||
|
*
|
||||||
|
* Binary frame format (from the gateway, via backend WS):
|
||||||
|
* Byte 0–3: FNV-1a 32-bit userId hash (UInt32LE)
|
||||||
|
* Byte 4+: PCM audio (24kHz mono Int16LE)
|
||||||
|
*
|
||||||
|
* The player keeps one ring buffer per user (2s @ 24kHz), upsamples
|
||||||
|
* 24k → 48k with linear interpolation inside the audio callback, and mixes
|
||||||
|
* every active user into a single mono output. A ScriptProcessorNode is used
|
||||||
|
* because it runs on the main thread — ring buffers need no SharedArrayBuffer
|
||||||
|
* and work without COOP/COEP headers. `start()` must be called from a user
|
||||||
|
* gesture (audio autoplay policy).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const INPUT_RATE = 24000;
|
||||||
|
const OUTPUT_RATE = 48000;
|
||||||
|
const RING_SECONDS = 2;
|
||||||
|
const RING_LEN = INPUT_RATE * RING_SECONDS;
|
||||||
|
|
||||||
|
interface UserRing {
|
||||||
|
data: Float32Array;
|
||||||
|
/** absolute write position (monotonic) */
|
||||||
|
write: number;
|
||||||
|
/** absolute read position as float — advances at 0.5× per output sample */
|
||||||
|
readPos: number;
|
||||||
|
/** last tick where this user produced audio (for stale-ring cleanup) */
|
||||||
|
lastActive: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PcmPlayer {
|
||||||
|
private ctx: AudioContext | null = null;
|
||||||
|
private processor: ScriptProcessorNode | null = null;
|
||||||
|
private master: GainNode | null = null;
|
||||||
|
private rings = new Map<number, UserRing>();
|
||||||
|
private levels = new Map<number, number>();
|
||||||
|
private volume = 0.75;
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
get isStarted(): boolean {
|
||||||
|
return this.started;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create the AudioContext + processor. MUST be called from a user gesture. */
|
||||||
|
start(): void {
|
||||||
|
if (this.started) return;
|
||||||
|
const Ctor =
|
||||||
|
window.AudioContext ??
|
||||||
|
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||||
|
.webkitAudioContext;
|
||||||
|
if (!Ctor) return;
|
||||||
|
this.ctx = new Ctor({ sampleRate: OUTPUT_RATE });
|
||||||
|
this.master = this.ctx.createGain();
|
||||||
|
this.master.gain.value = this.volume;
|
||||||
|
this.master.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
this.processor = this.ctx.createScriptProcessor(4096, 0, 1);
|
||||||
|
this.processor.onaudioprocess = (e) => this.tick(e);
|
||||||
|
this.processor.connect(this.master);
|
||||||
|
this.started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push one PCM chunk (s16 mono @24kHz) for a user. */
|
||||||
|
push(userIdHash: number, samples: Int16Array): void {
|
||||||
|
if (!this.started || samples.length === 0) return;
|
||||||
|
let ring = this.rings.get(userIdHash);
|
||||||
|
if (!ring) {
|
||||||
|
ring = { data: new Float32Array(RING_LEN), write: 0, readPos: 0, lastActive: Date.now() };
|
||||||
|
this.rings.set(userIdHash, ring);
|
||||||
|
}
|
||||||
|
ring.lastActive = Date.now();
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
ring.data[ring.write % RING_LEN] = samples[i] / 32768;
|
||||||
|
ring.write++;
|
||||||
|
}
|
||||||
|
// Overflow guard: never let the ring lag more than RING_LEN behind.
|
||||||
|
const lag = ring.write - ring.readPos;
|
||||||
|
if (lag > RING_LEN - 4096) {
|
||||||
|
ring.readPos = ring.write - RING_LEN + 4096;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(v: number): void {
|
||||||
|
this.volume = v;
|
||||||
|
if (this.master) this.master.gain.value = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Peak |sample| per user since the last call (drives the UI waveform). */
|
||||||
|
getLevels(): Map<number, number> {
|
||||||
|
return new Map(this.levels);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.processor) {
|
||||||
|
this.processor.disconnect();
|
||||||
|
this.processor.onaudioprocess = null;
|
||||||
|
}
|
||||||
|
if (this.ctx) {
|
||||||
|
void this.ctx.close().catch(() => {});
|
||||||
|
}
|
||||||
|
this.processor = null;
|
||||||
|
this.ctx = null;
|
||||||
|
this.master = null;
|
||||||
|
this.rings.clear();
|
||||||
|
this.levels.clear();
|
||||||
|
this.started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tick(e: AudioProcessingEvent): void {
|
||||||
|
const out = e.outputBuffer.getChannelData(0);
|
||||||
|
out.fill(0);
|
||||||
|
const n = out.length;
|
||||||
|
const step = INPUT_RATE / OUTPUT_RATE; // 0.5
|
||||||
|
const nextLevels = new Map<number, number>();
|
||||||
|
|
||||||
|
for (const [hash, ring] of this.rings) {
|
||||||
|
let level = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const pos = ring.readPos + i * step;
|
||||||
|
if (pos + 1 >= ring.write) break;
|
||||||
|
const i0 = Math.floor(pos);
|
||||||
|
const frac = pos - i0;
|
||||||
|
const a = ring.data[i0 % RING_LEN];
|
||||||
|
const b = ring.data[(i0 + 1) % RING_LEN];
|
||||||
|
const v = a + (b - a) * frac;
|
||||||
|
out[i] += v;
|
||||||
|
const av = Math.abs(v);
|
||||||
|
if (av > level) level = av;
|
||||||
|
}
|
||||||
|
ring.readPos += n * step;
|
||||||
|
if (ring.readPos > ring.write) ring.readPos = ring.write;
|
||||||
|
if (level > 0.001) {
|
||||||
|
ring.lastActive = Date.now();
|
||||||
|
nextLevels.set(hash, level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop users silent for >5s so the ring map doesn't grow unbounded.
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [hash, ring] of this.rings) {
|
||||||
|
if (now - ring.lastActive > 5000) this.rings.delete(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.levels = nextLevels;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user