feat(fe): real mic capture for voice transmit (kirim suara)

Sebelumnya tombol Live/Muted cuma kirim voice:transmit:start/stop ke
gateway — TIDAK ADA audio yang dikirim (0 getUserMedia/AudioContext di
frontend). MicControl cuma toggle state kosong.

- lib/audio/mic-transmit.ts (BARU): getUserMedia → AudioContext 48kHz →
  AudioWorklet (downsample 24kHz mono s16le + volume + chunk 20ms) →
  frame 'PCM\0' + Int16LE → ws.sendBinary. Worklet inline via Blob URL
  (aman untuk static export).
- useMicTransmit(ws): aktif = start capture + voice:transmit:start;
  nonaktif = stop capture + voice:transmit:stop; setVolume untuk slider.
- voice page: volume slider sekarang beneran ngatur gain mic; disconnect
  ikut matiin mic.

Verifikasi: tsc PASS, biome 0 error, next build PASS. Test mic butuh
real device (headless browser tidak punya mic) — protokol: connect voice
→ Live → ngomong → orang di channel denger.
This commit is contained in:
asepharyana
2026-08-01 11:26:18 +07:00
parent 9d60f00934
commit 189ab1c1f6
3 changed files with 203 additions and 15 deletions
@@ -28,7 +28,7 @@ export default function VoicePage() {
const { speakers, subscribe } = useSpeakers();
const connectMut = useVoiceConnect();
const disconnectMut = useVoiceDisconnect();
const micMut = useMicTransmit();
const micMut = useMicTransmit(ws);
const [selectedChannel, setSelectedChannel] = useState("");
const [micActive, setMicActive] = useState(false);
const [volume, setVolume] = useState(75);
@@ -41,16 +41,33 @@ export default function VoicePage() {
const handleMicToggle = useCallback(
async (checked: boolean) => {
setMicActive(checked);
try {
await micMut.mutateAsync(checked);
} catch {
setMicActive(!checked);
if (checked) {
try {
await micMut.mutateAsync(true);
setMicActive(true);
} catch {
setMicActive(false);
}
} else {
setMicActive(false);
try {
await micMut.mutateAsync(false);
} catch {
// Stop already tore down the local transmitter — ignore remote errors
}
}
},
[micMut],
);
const handleVolumeChange = useCallback(
(v: number) => {
setVolume(v);
micMut.setVolume(v);
},
[micMut],
);
const handleGuildChange = useCallback((guildId: string | null) => {
if (!guildId) {
setSelectedGuild("");
@@ -89,7 +106,13 @@ export default function VoicePage() {
channelId: selectedChannel,
})
}
onDisconnect={() => disconnectMut.mutate(undefined)}
onDisconnect={() => {
if (micActive) {
setMicActive(false);
void micMut.mutateAsync(false).catch(() => {});
}
disconnectMut.mutate(undefined);
}}
connecting={connectMut.isPending}
/>
@@ -101,7 +124,7 @@ export default function VoicePage() {
active={micActive}
onToggle={handleMicToggle}
volume={volume}
onVolumeChange={setVolume}
onVolumeChange={handleVolumeChange}
/>
</div>
)}