Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
146 lines
5.0 KiB
TypeScript
146 lines
5.0 KiB
TypeScript
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<MediaRecorder | null>(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 (
|
||
<button
|
||
type="button"
|
||
className={`ghost small voice-btn ${state === "recording" ? "on" : ""}`}
|
||
disabled={state === "sending"}
|
||
onClick={() => void toggle()}
|
||
title={t(state === "recording" ? "voice.stop" : "voice.start")}
|
||
aria-label={t(state === "recording" ? "voice.stop" : "voice.start")}
|
||
aria-pressed={state === "recording"}
|
||
>
|
||
<Icon name="mic" />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const LOCALES: Record<string, string> = { 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 ? <RecorderButton {...props} /> : null;
|
||
return <BrowserVoiceButton {...props} />;
|
||
}
|
||
|
||
function BrowserVoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
|
||
const t = useT();
|
||
const [listening, setListening] = useState(false);
|
||
const rec = useRef<Recognition | null>(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 (
|
||
<button type="button" className={`ghost small voice-btn ${listening ? "on" : ""}`} onClick={toggle} title={t(listening ? "voice.stop" : "voice.start")} aria-label={t(listening ? "voice.stop" : "voice.start")} aria-pressed={listening}>
|
||
<Icon name="mic" />
|
||
</button>
|
||
);
|
||
}
|