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 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-08 22:55:11 +07:00
co-authored by Claude Opus 4.8
parent 218ad2f353
commit 52f2968d8f
3 changed files with 59 additions and 15 deletions
@@ -1,4 +1,5 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { publishCommandNoReply } from "../../shared/redis/index.js";
import { import {
connectVoice, connectVoice,
disconnectVoice, disconnectVoice,
@@ -40,3 +41,26 @@ export async function handleGetVoiceChannels(req: Request, res: Response) {
const channels = await getVoiceChannels(guildId); const channels = await getVoiceChannels(guildId);
res.json(channels); 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",
});
}
}
@@ -5,6 +5,7 @@ import {
handleDisconnectVoice, handleDisconnectVoice,
handleGetVoiceChannels, handleGetVoiceChannels,
handleGetVoiceStatus, handleGetVoiceStatus,
handleVoiceCommand,
} from "./voice.controller.js"; } from "./voice.controller.js";
export function createVoiceRouter(): Router { export function createVoiceRouter(): Router {
@@ -22,5 +23,8 @@ export function createVoiceRouter(): Router {
// GET /api/guilds/:guildId/voice-channels // GET /api/guilds/:guildId/voice-channels
router.get("/guilds/:guildId/voice-channels", handleGetVoiceChannels); router.get("/guilds/:guildId/voice-channels", handleGetVoiceChannels);
// POST /api/command — send arbitrary voice command (transmit start/stop)
router.post("/command", handleVoiceCommand);
return router; return router;
} }
@@ -1,8 +1,36 @@
// ─── Audio transmit hook — captures mic, encodes to PCM, sends via WebSocket ── // ─── Audio transmit hook — captures mic, encodes to PCM, sends via WebSocket ──
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { getAPIURL } from "../api/client";
const SAMPLE_RATE = 24000; const SAMPLE_RATE = 24000;
async function sendTransmitCommand(command: string): Promise<void> {
// 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<void>((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: { export function useAudioTransmit(socketRef: {
readonly current: WebSocket | null; readonly current: WebSocket | null;
}) { }) {
@@ -12,13 +40,7 @@ export function useAudioTransmit(socketRef: {
const processorRef = useRef<ScriptProcessorNode | null>(null); const processorRef = useRef<ScriptProcessorNode | null>(null);
const stop = useCallback(() => { const stop = useCallback(() => {
// Send voice:transmit:stop command to backend sendTransmitCommand("voice:transmit:stop").catch(() => {});
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({
type: 'voice_command',
command: 'voice:transmit:stop'
}));
}
setIsStreaming(false); setIsStreaming(false);
if (processorRef.current) { if (processorRef.current) {
@@ -33,16 +55,10 @@ 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;
} }
}, [socketRef]); }, []);
const start = useCallback(async () => { const start = useCallback(async () => {
// Send voice:transmit:start command to backend await sendTransmitCommand("voice:transmit:start");
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({
type: 'voice_command',
command: 'voice:transmit:start'
}));
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream; streamRef.current = stream;