From 52f2968d8f7d6c30c8b6fcd4223858bbaa67c581 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 8 Jun 2026 22:55:11 +0700 Subject: [PATCH] fix(voice): add HTTP API fallback for transmit commands - Add POST /api/voice/command endpoint in backend - Frontend transmit now sends commands via dedicated WebSocket connection with HTTP API fallback if WebSocket fails - Fixes issue where transmit start command was never received by discord-gateway Previously commands were sent via the existing dashboard WebSocket, which may not be connected when the Transmit button is pressed. Now each command opens a fresh WebSocket connection with HTTP fallback. Co-Authored-By: Claude Opus 4.8 --- .../src/modules/voice/voice.controller.ts | 24 ++++++++++ .../backend/src/modules/voice/voice.routes.ts | 4 ++ .../src/shared/hooks/useAudioTransmit.ts | 46 +++++++++++++------ 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/services/backend/src/modules/voice/voice.controller.ts b/services/backend/src/modules/voice/voice.controller.ts index b8f7b42..8df2a75 100644 --- a/services/backend/src/modules/voice/voice.controller.ts +++ b/services/backend/src/modules/voice/voice.controller.ts @@ -1,4 +1,5 @@ import type { Request, Response } from "express"; +import { publishCommandNoReply } from "../../shared/redis/index.js"; import { connectVoice, disconnectVoice, @@ -40,3 +41,26 @@ export async function handleGetVoiceChannels(req: Request, res: Response) { const channels = await getVoiceChannels(guildId); res.json(channels); } + +export async function handleVoiceCommand(req: Request, res: Response) { + const command = Array.isArray(req.body.command) + ? req.body.command[0] + : req.body.command; + + if (!command) { + return res.status(400).json({ + error: "VALIDATION_ERROR", + message: "command is required", + }); + } + + try { + await publishCommandNoReply(command as string); + res.json({ success: true, command }); + } catch (err) { + res.status(500).json({ + error: "COMMAND_FAILED", + message: err instanceof Error ? err.message : "Unknown error", + }); + } +} diff --git a/services/backend/src/modules/voice/voice.routes.ts b/services/backend/src/modules/voice/voice.routes.ts index 8905db3..640bfb9 100644 --- a/services/backend/src/modules/voice/voice.routes.ts +++ b/services/backend/src/modules/voice/voice.routes.ts @@ -5,6 +5,7 @@ import { handleDisconnectVoice, handleGetVoiceChannels, handleGetVoiceStatus, + handleVoiceCommand, } from "./voice.controller.js"; export function createVoiceRouter(): Router { @@ -22,5 +23,8 @@ export function createVoiceRouter(): Router { // GET /api/guilds/:guildId/voice-channels router.get("/guilds/:guildId/voice-channels", handleGetVoiceChannels); + // POST /api/command — send arbitrary voice command (transmit start/stop) + router.post("/command", handleVoiceCommand); + return router; } diff --git a/services/frontend/src/shared/hooks/useAudioTransmit.ts b/services/frontend/src/shared/hooks/useAudioTransmit.ts index e17fabd..ac823e3 100644 --- a/services/frontend/src/shared/hooks/useAudioTransmit.ts +++ b/services/frontend/src/shared/hooks/useAudioTransmit.ts @@ -1,8 +1,36 @@ // ─── Audio transmit hook — captures mic, encodes to PCM, sends via WebSocket ── import { useCallback, useRef, useState } from "react"; +import { getAPIURL } from "../api/client"; const SAMPLE_RATE = 24000; +async function sendTransmitCommand(command: string): Promise { + // Send via WebSocket (primary) + try { + const wsUrl = import.meta.env.VITE_BE_WS_URL || + `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`; + const ws = new WebSocket(wsUrl); + ws.binaryType = "arraybuffer"; + await new Promise((resolve, reject) => { + ws.onopen = () => { + ws.send(JSON.stringify({ type: "voice_command", command })); + ws.close(); + resolve(); + }; + ws.onerror = () => reject(new Error("WS failed")); + setTimeout(() => reject(new Error("WS timeout")), 3000); + }); + } catch (err) { + console.warn("WebSocket command failed, trying HTTP:", err); + // Fallback: send via HTTP API + await fetch(`${getAPIURL()}/voice/command`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ command }), + }); + } +} + export function useAudioTransmit(socketRef: { readonly current: WebSocket | null; }) { @@ -12,13 +40,7 @@ export function useAudioTransmit(socketRef: { const processorRef = useRef(null); const stop = useCallback(() => { - // Send voice:transmit:stop command to backend - if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { - socketRef.current.send(JSON.stringify({ - type: 'voice_command', - command: 'voice:transmit:stop' - })); - } + sendTransmitCommand("voice:transmit:stop").catch(() => {}); setIsStreaming(false); if (processorRef.current) { @@ -33,16 +55,10 @@ export function useAudioTransmit(socketRef: { for (const track of streamRef.current.getTracks()) track.stop(); streamRef.current = null; } - }, [socketRef]); + }, []); const start = useCallback(async () => { - // Send voice:transmit:start command to backend - if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { - socketRef.current.send(JSON.stringify({ - type: 'voice_command', - command: 'voice:transmit:start' - })); - } + await sendTransmitCommand("voice:transmit:start"); const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); streamRef.current = stream;