"use client"; import * as React from "react"; import { Mic, Square, Trash2 } from "lucide-react"; import { trpc } from "@/lib/trpc/client"; import { Button } from "@/components/ui/button"; import { dateiHochladen } from "@/lib/hochladen"; import { KiVorschlag, KiFehltHinweis } from "@/components/ki/kennzeichnung"; import { alsLaenge } from "@/lib/ki/texte"; /** * Diktieren statt tippen. * * Der Weg, den es in jeder Wehr schon gibt: jemand spricht auf der Rückfahrt * in sein Telefon, und daraus wird abends ein Bericht. Neu ist nur, dass * abends schon ein Text dasteht. * * Drei Dinge sind an der Bedienung wichtig, und alle drei kommen daher, dass * hier jemand mit einer Hand am Steuer sitzt: * * 1. **Ein Knopf.** Aufnehmen, stoppen. Keine Einstellungen, keine Auswahl * des Mikrofons. * 2. **Die Aufnahme bleibt.** Auch nach der Erkennung. Solange das * Transkript nicht bestätigt ist, ist sie das einzige Original — und wer * dem Text nicht traut, hört nach. * 3. **Es wird nichts automatisch eingetragen.** Der Text kommt als * Vorschlag mit zwei Knöpfen. „Übernehmen" reicht ihn an die Ansicht * weiter, die ihn angefordert hat. */ export interface DiktatProps { /** Woran das Diktat hängt: "Einsatz", "Uebung". */ entitaet: string; entitaetId: string; /** Wird mit dem bestätigten Text aufgerufen. */ onUebernehmen: (text: string) => void; } type Zustand = "bereit" | "nimmt-auf" | "laedt-hoch" | "erkennt" | "fertig"; export function Diktat({ entitaet, entitaetId, onUebernehmen }: DiktatProps) { const werkzeuge = trpc.useUtils(); const [zustand, setZustand] = React.useState("bereit"); const [sekunden, setSekunden] = React.useState(0); const [diktatId, setDiktatId] = React.useState(null); const [meldung, setMeldung] = React.useState(null); const rekorderRef = React.useRef(null); const stueckeRef = React.useRef([]); const stromRef = React.useRef(null); const moeglichkeiten = trpc.assistent.moeglichkeiten.useQuery(); const einreichen = trpc.assistent.diktatEinreichen.useMutation({ onSuccess: (ergebnis) => { setDiktatId(ergebnis.id); setZustand("erkennt"); }, onError: (fehler) => { setMeldung(fehler.message); setZustand("bereit"); }, }); // Solange erkannt wird, in Abständen nachfragen. Kein Ereignisstrom: die // Erkennung dauert einmalig ein bis zwei Minuten, und dafür eine offene // Verbindung zu halten wäre Aufwand ohne Gewinn. const diktat = trpc.assistent.diktat.useQuery( { id: diktatId ?? "" }, { enabled: Boolean(diktatId) && (zustand === "erkennt" || zustand === "fertig"), refetchInterval: zustand === "erkennt" ? 3000 : false, }, ); const entscheiden = trpc.assistent.diktatEntscheiden.useMutation({ onSuccess: () => { void werkzeuge.assistent.invalidate(); zuruecksetzen(); }, onError: (fehler) => setMeldung(fehler.message), }); React.useEffect(() => { if (zustand !== "erkennt" || !diktat.data) return; if (diktat.data.transkript) setZustand("fertig"); else if (diktat.data.stand === "FEHLGESCHLAGEN") { setMeldung(diktat.data.kiVorgang?.fehler ?? "Die Erkennung ist fehlgeschlagen."); setZustand("bereit"); setDiktatId(null); } }, [zustand, diktat.data]); // Der Zähler läuft über die Uhr, nicht über gezählte Sekunden: ein Fenster // im Hintergrund bekommt weniger Zeitgeber, und die Anzeige liefe nach. React.useEffect(() => { if (zustand !== "nimmt-auf") return; const beginn = Date.now(); const zeitgeber = setInterval(() => { const vergangen = Math.floor((Date.now() - beginn) / 1000); setSekunden(vergangen); const grenze = moeglichkeiten.data?.maxSekunden ?? 600; if (vergangen >= grenze) stoppen(); }, 500); return () => clearInterval(zeitgeber); // eslint-disable-next-line react-hooks/exhaustive-deps }, [zustand, moeglichkeiten.data?.maxSekunden]); function zuruecksetzen() { setZustand("bereit"); setSekunden(0); setDiktatId(null); setMeldung(null); stueckeRef.current = []; } async function starten() { setMeldung(null); try { const strom = await navigator.mediaDevices.getUserMedia({ audio: true }); stromRef.current = strom; stueckeRef.current = []; // Das Format bestimmt der Browser: Chrome nimmt webm/opus auf, Safari // mp4/aac. Beides wird angenommen — sonst könnte die Hälfte der Wehr // nicht diktieren. const rekorder = new MediaRecorder(strom); rekorderRef.current = rekorder; rekorder.ondataavailable = (ereignis) => { if (ereignis.data.size > 0) stueckeRef.current.push(ereignis.data); }; rekorder.onstop = () => { strom.getTracks().forEach((spur) => spur.stop()); void hochladen(); }; rekorder.start(); setZustand("nimmt-auf"); setSekunden(0); } catch { setMeldung( "Auf das Mikrofon lässt sich nicht zugreifen. Der Browser fragt nur über HTTPS " + "oder auf localhost — und die Freigabe muss erteilt sein.", ); } } function stoppen() { if (rekorderRef.current?.state === "recording") { rekorderRef.current.stop(); setZustand("laedt-hoch"); } } async function hochladen() { const stuecke = stueckeRef.current; if (stuecke.length === 0) { setMeldung("Es wurde nichts aufgenommen."); setZustand("bereit"); return; } const typ = rekorderRef.current?.mimeType ?? "audio/webm"; const aufnahme = new Blob(stuecke, { type: typ }); const endung = typ.includes("mp4") ? "m4a" : typ.includes("ogg") ? "ogg" : "webm"; try { const datei = await dateiHochladen( aufnahme, entitaet, entitaetId, "diktat", `diktat-${Date.now()}.${endung}`, ); einreichen.mutate({ audioDateiId: datei.id, dauerSekunden: sekunden, entitaet, entitaetId, }); } catch (fehler) { setMeldung(fehler instanceof Error ? fehler.message : String(fehler)); setZustand("bereit"); } } if (moeglichkeiten.isLoading) return null; if (!moeglichkeiten.data?.diktat) { return ( ); } return (
{meldung && (

{meldung}

)} {zustand === "bereit" && (
Höchstens {alsLaenge(moeglichkeiten.data.maxSekunden)} Minuten am Stück.
)} {zustand === "nimmt-auf" && (
Nimmt auf … {alsLaenge(sekunden)}
)} {(zustand === "laedt-hoch" || zustand === "erkennt") && (

{zustand === "laedt-hoch" ? "Aufnahme wird übertragen …" : "Wird erkannt …"}

{zustand === "erkennt" ? "Das dauert bei einer langen Aufnahme ein bis zwei Minuten. Die Seite kann " + "solange offen bleiben — der Text erscheint von selbst." : "Die Aufnahme wird abgelegt und bleibt erhalten."}

)} {zustand === "fertig" && diktat.data?.transkript && ( Aufnahmelänge {alsLaenge(diktat.data.dauerSekunden)}. Die Aufnahme bleibt abgelegt, bis über den Text entschieden ist — Fachbegriffe und Ortsnamen bitte nachlesen.

} onUebernehmen={() => { onUebernehmen(diktat.data!.transkript!); entscheiden.mutate({ id: diktat.data!.id, uebernommen: true }); }} onVerwerfen={() => entscheiden.mutate({ id: diktat.data!.id, uebernommen: false })} /> )} {zustand === "fertig" && ( )}
); }