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

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:
asepharyana
2026-08-01 16:30:56 +07:00
parent 6ce784471e
commit 762e78d6b6
8 changed files with 474 additions and 22 deletions
+2
View File
@@ -34,10 +34,12 @@ export {
useRecordingsWsSync,
} from "./use-recordings";
export {
hashUserId,
useMicTransmit,
useSpeakers,
useVoiceChannels,
useVoiceConnect,
useVoiceDisconnect,
useVoiceListen,
useVoiceStatus,
} from "./use-voice";
+70 -1
View File
@@ -1,11 +1,23 @@
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import useSWR, { useSWRConfig } from "swr";
import { useAction } from "@/hooks/use-action";
import { voiceApi } from "@/lib/api";
import { MicTransmitter } from "@/lib/audio/mic-transmit";
import { PcmPlayer } from "@/lib/audio/pcm-player";
import type { ActiveSpeaker, Channel, VoiceStatus } from "@/lib/types";
import type { PcmChunk } from "@/lib/ws/types";
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;
export function useVoiceStatus() {
@@ -90,3 +102,60 @@ export function useMicTransmit(ws: {
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 };
}