summaryrefslogblamecommitdiff
path: root/makima/frontend/src/routes/speak.tsx
blob: c4692ff12a20d43bd505299fc9a59b31c452f8de (plain) (tree)






























































































































































                                                                                                                                                                                                                               
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>
  );
}