diff options
Diffstat (limited to 'makima/frontend/src/routes/speak.tsx')
| -rw-r--r-- | makima/frontend/src/routes/speak.tsx | 159 |
1 files changed, 159 insertions, 0 deletions
diff --git a/makima/frontend/src/routes/speak.tsx b/makima/frontend/src/routes/speak.tsx new file mode 100644 index 0000000..c4692ff --- /dev/null +++ b/makima/frontend/src/routes/speak.tsx @@ -0,0 +1,159 @@ +import { useState, useCallback } from "react"; +import { Masthead } from "../components/Masthead"; +import { useSpeakWebSocket } from "../hooks/useSpeakWebSocket"; + +export default function SpeakPage() { + const [text, setText] = useState(""); + const tts = useSpeakWebSocket(); + + const handleSpeak = useCallback(() => { + if (!text.trim()) return; + tts.speak(text); + }, [text, tts]); + + const handleCancel = useCallback(() => { + tts.cancel(); + }, [tts]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent<HTMLTextAreaElement>) => { + // Ctrl/Cmd + Enter to speak + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + handleSpeak(); + } + }, + [handleSpeak] + ); + + const statusLabel = (() => { + switch (tts.status) { + case "disconnected": + return "DISCONNECTED"; + case "connecting": + return "CONNECTING..."; + case "connected": + return "CONNECTED"; + case "loading_model": + return "LOADING TTS MODEL..."; + case "speaking": + return "SPEAKING"; + case "error": + return "ERROR"; + default: + return "IDLE"; + } + })(); + + const statusColor = (() => { + switch (tts.status) { + case "connected": + case "speaking": + return "border-[#3f6fb3] text-[#75aafc]"; + case "error": + return "border-red-400/50 text-red-400"; + default: + return "border-[rgba(117,170,252,0.25)] text-[#9bc3ff]"; + } + })(); + + const dotColor = (() => { + switch (tts.status) { + case "connected": + case "speaking": + return "bg-[#75aafc]"; + case "error": + return "bg-red-400"; + default: + return "bg-[#3f6fb3]"; + } + })(); + + return ( + <div className="relative z-10 h-screen flex flex-col overflow-hidden"> + <Masthead showTicker={false} showNav /> + + <main className="flex-1 flex flex-col items-center justify-center p-4 md:p-8 gap-6 min-h-0 overflow-auto"> + {/* Text input area */} + <div className="w-full max-w-2xl"> + <textarea + value={text} + onChange={(e) => setText(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Enter text to speak..." + disabled={tts.isSpeaking || tts.isModelLoading} + className="w-full h-48 p-4 font-mono text-sm text-[#dbe7ff] bg-[#0d1b2d] border border-[#0f3c78] focus:border-[#3f6fb3] focus:outline-none placeholder-[#3f6fb3] resize-none transition-colors disabled:opacity-50" + /> + <div className="mt-1 text-right font-mono text-xs text-[#3f6fb3]"> + Ctrl+Enter to speak + </div> + </div> + + {/* Controls row */} + <div className="w-full max-w-2xl flex items-center gap-4"> + {/* Speak / Cancel button */} + {tts.isSpeaking || tts.isModelLoading ? ( + <button + onClick={handleCancel} + className="px-6 py-2 font-mono text-sm text-red-400 bg-[#0d1b2d] border border-red-400/50 hover:border-red-400 transition-colors uppercase tracking-wide" + > + Cancel + </button> + ) : ( + <button + onClick={handleSpeak} + disabled={!text.trim()} + className="px-6 py-2 font-mono text-sm text-[#dbe7ff] bg-[#0d1b2d] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase tracking-wide disabled:opacity-50 disabled:cursor-not-allowed" + > + Speak + </button> + )} + + {/* Status indicator */} + <div + className={`inline-flex items-center gap-1.5 px-2 py-1 border font-mono text-xs tracking-wide uppercase ${statusColor}`} + > + <span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} /> + {statusLabel} + </div> + </div> + + {/* Loading bar (indeterminate) */} + {tts.isModelLoading && ( + <div className="w-full max-w-2xl"> + <div className="w-full h-1.5 bg-[#0f1c2f] overflow-hidden"> + <div + className="h-full w-1/3 bg-[#75aafc]" + style={{ + animation: "loading-slide 1.5s ease-in-out infinite", + }} + /> + </div> + <div className="mt-2 font-mono text-xs text-[#9bc3ff] text-center tracking-wide uppercase"> + Loading TTS model... This may take a moment on first use. + </div> + </div> + )} + + {/* Speaking animation bar */} + {tts.isSpeaking && ( + <div className="w-full max-w-2xl"> + <div className="w-full h-1.5 bg-[#0f1c2f] overflow-hidden"> + <div + className="h-full w-full bg-[#75aafc] animate-pulse" + /> + </div> + </div> + )} + + {/* Error display */} + {tts.error && ( + <div className="w-full max-w-2xl font-mono text-xs text-red-400 text-center px-4 py-2 border border-red-400/50 bg-red-400/10"> + {tts.error} + </div> + )} + </main> + + </div> + ); +} |
