summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/speak.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/routes/speak.tsx')
-rw-r--r--makima/frontend/src/routes/speak.tsx159
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>
+ );
+}