Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 44 additions & 11 deletions frontend/src/Pages/DebateRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { useAtom } from "jotai";
import { userAtom } from "@/state/userAtom";
import { DEFAULT_AVATAR_URL } from "@/constants/avatar";
import { useToast } from "@/hooks/use-toast";
import { useSpeechSynthesis } from "@/hooks/useSpeechSynthesis";
import BotVoiceControls from "@/components/BotVoiceControls";

// Bot type definition (same as in BotSelection)
interface Bot {
Expand Down Expand Up @@ -391,6 +393,10 @@ const DebateRoom: React.FC = () => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const recognitionRef = useRef<SpeechRecognition | null>(null);

// Text-to-Speech: single hook instance shared across all bot messages
// so only one message can play at a time.
const tts = useSpeechSynthesis();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const bot = debateData
? allBots.find((b) => b.name === debateData.botName) || allBots[0]
: allBots[0];
Expand All @@ -416,6 +422,8 @@ const DebateRoom: React.FC = () => {

setIsConceding(true);
setIsConcedeDialogOpen(false);
// Stop any running TTS when the debate ends via concession.
tts.stop();
try {
if (debateData?.debateId) {
await concedeDebate(debateData.debateId, state.messages);
Expand All @@ -438,6 +446,15 @@ const DebateRoom: React.FC = () => {
}
};

// Stop TTS when debate ends, judgment data becomes available, or concession is in progress
const { stop } = tts;

useEffect(() => {
if (state.isDebateEnded || judgmentData !== null || isConceding) {
stop();
}
}, [state.isDebateEnded, judgmentData, isConceding, stop]);

// Initialize SpeechRecognition
useEffect(() => {
if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) {
Expand Down Expand Up @@ -829,17 +846,33 @@ const DebateRoom: React.FC = () => {
const phaseMessages = state.messages.filter((msg) => msg.sender === sender);
return (
<div className="space-y-4">
{phaseMessages.map((msg, idx) => (
<div
key={idx}
className="p-3 bg-muted rounded-lg shadow-sm text-foreground break-words"
>
<span className="text-xs text-muted-foreground block mb-1">
{msg.phase}
</span>
{msg.text}
</div>
))}
{phaseMessages.map((msg, idx) => {
// Stable message ID: combination of sender, phase, and index
const msgId = `${sender}-${msg.phase}-${idx}`;
return (
<div
key={idx}
className="p-3 bg-muted rounded-lg shadow-sm text-foreground break-words"
>
<span className="text-xs text-muted-foreground inline-flex items-center mb-1">
{msg.phase}
{/* Voice controls are only shown on bot messages */}
{sender === "Bot" &&
!state.isDebateEnded &&
judgmentData === null &&
!isConceding && (
<BotVoiceControls
messageId={msgId}
text={msg.text}
botName={debateData?.botName ?? "Bot"}
tts={tts}
/>
)}
</span>
{msg.text}
</div>
);
})}
<div ref={messagesEndRef} />
</div>
);
Expand Down
125 changes: 125 additions & 0 deletions frontend/src/components/BotVoiceControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* BotVoiceControls
*
* A compact, accessible Play / Pause / Stop control strip for a single
* bot message in DebateAI's DebateRoom.
*
* Props:
* messageId – stable unique ID for this message (used to identify
* which message is currently active in the hook).
* text – the bot message text to speak.
* botName – the bot's display name (used for voice-personality lookup).
* tts – the UseSpeechSynthesisReturn object from useSpeechSynthesis(),
* passed in from the parent so a single hook instance manages
* all messages (single-active-playback guarantee).
*
* Behaviour:
* - If TTS is unsupported, renders nothing.
* - Shows a play button when idle (or when another message is active).
* - When THIS message is active:
* • Shows pause button while playing.
* • Shows play (resume) button while paused.
* • Always shows stop button when active.
* - Clicking play on a different message stops any existing playback first
* (handled inside useSpeechSynthesis.play()).
*/

import React from "react";
import { Play, Pause, Square, Volume2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { UseSpeechSynthesisReturn } from "@/hooks/useSpeechSynthesis";

interface BotVoiceControlsProps {
messageId: string;
text: string;
botName: string;
tts: UseSpeechSynthesisReturn;
}

const BotVoiceControls: React.FC<BotVoiceControlsProps> = ({
messageId,
text,
botName,
tts,
}) => {
if (!tts.isSupported) return null;

const isThisActive = tts.activeMessageId === messageId;
const isPlaying = isThisActive && tts.playbackState === "playing";
const isPaused = isThisActive && tts.playbackState === "paused";

const handlePlayPause = () => {
if (isPlaying) {
tts.pause();
} else if (isPaused) {
tts.resume();
} else {
// Either idle or a different message is active — start this one.
tts.play(messageId, text, botName);
}
};

const handleStop = () => {
tts.stop();
};

return (
<div
className="inline-flex items-center gap-1 ml-2"
role="group"
aria-label={`Voice controls for ${botName}'s message`}
>
{/* Speaker icon — decorative, indicates TTS feature */}
<Volume2
className={`w-3.5 h-3.5 ${
isThisActive
? "text-primary animate-pulse"
: "text-muted-foreground opacity-60"
}`}
aria-hidden="true"
/>

{/* Play / Pause toggle */}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full hover:bg-primary/10 transition-colors"
onClick={handlePlayPause}
aria-label={
isPlaying
? `Pause ${botName}'s message`
: isPaused
? `Resume ${botName}'s message`
: `Listen to ${botName}'s message`
}
title={isPlaying ? "Pause" : isPaused ? "Resume" : "Listen"}
>
{isPlaying ? (
<Pause className="w-3.5 h-3.5 text-primary" />
) : (
<Play
className={`w-3.5 h-3.5 ${
isPaused ? "text-primary" : "text-muted-foreground"
}`}
/>
)}
</Button>

{/* Stop button — only visible when this message is active */}
{isThisActive && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full hover:bg-destructive/10 transition-colors"
onClick={handleStop}
aria-label={`Stop listening to ${botName}'s message`}
title="Stop"
>
<Square className="w-3 h-3 text-destructive" />
</Button>
)}
</div>
);
};

export default BotVoiceControls;
Loading