import { useEffect, useRef, useState } from "react"; import { getLang, useT } from "../i18n"; import { toast } from "../dialogs"; import { get } from "../api"; import { Icon } from "./Icon"; /* Web Speech API (Chrome/Edge/Safari; Firefox has none). */ type Recognition = { lang: string; continuous: boolean; interimResults: boolean; start(): void; stop(): void; onresult: ((e: any) => void) | null; onend: (() => void) | null; onerror: ((e: any) => void) | null; }; const Impl: (new () => Recognition) | undefined = (window as any).SpeechRecognition ?? (window as any).webkitSpeechRecognition; export const voiceSupported = () => !!Impl; /** Server-side transcription is on (server.transcribe_model) – for browsers without dictation. */ function useServerSpeech(): boolean { const [on, setOn] = useState(false); useEffect(() => { if (Impl) return; get<{ transcribe?: boolean }>("/config") .then((c) => setOn(!!c.transcribe)) .catch(() => undefined); }, []); return on; } /** Record with the microphone; the audio is transcribed by the server. */ function RecorderButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) { const t = useT(); const [state, setState] = useState<"idle" | "recording" | "sending">("idle"); const rec = useRef(null); useEffect(() => () => rec.current?.stream.getTracks().forEach((x) => x.stop()), []); const toggle = async () => { if (state === "recording") { rec.current?.stop(); return; } if (state === "sending") return; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const r = new MediaRecorder(stream); const chunks: Blob[] = []; r.ondataavailable = (e) => e.data.size && chunks.push(e.data); r.onstop = async () => { stream.getTracks().forEach((x) => x.stop()); setState("sending"); onInterim(t("voice.transcribing")); try { const blob = new Blob(chunks, { type: r.mimeType || "audio/webm" }); const res = await fetch(`/api/transcribe?lang=${getLang()}`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": blob.type }, body: blob }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error ?? data.message ?? `HTTP ${res.status}`); if (data.text) onText(String(data.text)); } catch (e: any) { toast(`${t("voice.error")}: ${e.message}`); } finally { onInterim(""); setState("idle"); } }; rec.current = r; r.start(); setState("recording"); } catch { toast(t("voice.denied")); } }; return ( ); } const LOCALES: Record = { de: "de-DE", en: "en-US" }; /** Microphone button: dictated text is appended to the message. */ export function VoiceButton(props: { onText: (text: string) => void; onInterim: (text: string) => void }) { const server = useServerSpeech(); if (!Impl) return server ? : null; return ; } function BrowserVoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) { const t = useT(); const [listening, setListening] = useState(false); const rec = useRef(null); useEffect(() => () => rec.current?.stop(), []); if (!Impl) return null; const toggle = () => { if (listening) { rec.current?.stop(); return; } const r = new Impl!(); r.lang = LOCALES[getLang()] ?? navigator.language; r.continuous = true; r.interimResults = true; r.onresult = (e: any) => { let interim = ""; for (let i = e.resultIndex; i < e.results.length; i++) { const res = e.results[i]; if (res.isFinal) onText(res[0].transcript.trim()); else interim += res[0].transcript; } onInterim(interim); }; r.onerror = (e: any) => { if (e.error === "not-allowed" || e.error === "service-not-allowed") toast(t("voice.denied")); else if (e.error !== "no-speech" && e.error !== "aborted") toast(`${t("voice.error")}: ${e.error}`); }; r.onend = () => { setListening(false); onInterim(""); }; rec.current = r; r.start(); setListening(true); }; return ( ); }