const start = document.querySelector("#start"); const mute = document.querySelector("#mute"); const end = document.querySelector("#end"); const retry = document.querySelector("#retry"); const status = document.querySelector("#status"); const usage = document.querySelector("#usage"); const captions = document.querySelector("#captions"); const download = document.querySelector("#download"); let peer; let events; let microphone; let transcript = []; let conversationId; let ready = false; let muted = false; let terminalState; let saving = false; let saved = false; let closeTimer; let sessionTimer; let sessionMaxSeconds = 600; function setStatus(message) { status.textContent = message; } function eventId(prefix) { return `${prefix}-${crypto.randomUUID()}`; } function cleanup() { clearTimeout(closeTimer); clearTimeout(sessionTimer); microphone?.getTracks().forEach((track) => track.stop()); events?.close(); peer?.close(); microphone = undefined; events = undefined; peer = undefined; ready = false; muted = false; start.disabled = false; mute.disabled = true; end.disabled = true; } function addCaption(speaker, delta) { if (!delta) return; const previous = transcript.at(-1); if (previous?.speaker === speaker && previous.text.length < 3500) { previous.text += delta; previous.element.lastChild.textContent = previous.text; return; } const element = document.createElement("div"); element.className = "caption"; const label = document.createElement("strong"); label.textContent = `${speaker}: `; const value = document.createTextNode(delta); element.append(label, value); captions.append(element); transcript.push({ speaker, text: delta, element }); captions.scrollTop = captions.scrollHeight; } function send(event) { if (events?.readyState === "open") events.send(JSON.stringify(event)); } async function waitForIce(connection) { if (connection.iceGatheringState === "complete") return; await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error("ICE-Sammlung hat zu lange gedauert.")), 10_000); connection.addEventListener("icegatheringstatechange", function stateChanged() { if (connection.iceGatheringState !== "complete") return; clearTimeout(timeout); connection.removeEventListener("icegatheringstatechange", stateChanged); resolve(); }); }); } async function saveTranscript() { if (saved || saving || transcript.length === 0 || !conversationId || !terminalState) { if (transcript.length === 0 && terminalState) setStatus("Kein Transkript empfangen; es konnte keine Datei gespeichert werden."); return; } saving = true; retry.hidden = true; setStatus(terminalState === "confirmed" ? "Speichere Transkript lokal auf pinix …" : "Speichere unbestätigt beendetes Transkript lokal auf pinix …"); try { const response = await fetch("/api/transcripts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ conversationId, transcript: transcript.map(({ speaker, text }) => ({ speaker, text })), finalization: terminalState, }), }); const result = await response.json(); if (!response.ok) throw new Error(result.error ?? "Speichern fehlgeschlagen."); saved = true; download.href = result.url; download.hidden = false; setStatus(terminalState === "confirmed" ? "Transkript gespeichert. Über den Link kannst du die gespeicherte transcript.md herunterladen." : "Transkript trotz unbestätigtem Sessionende gespeichert. Der Abschlussstatus steht in transcript.md."); } catch (error) { setStatus(`Transkript konnte nicht gespeichert werden: ${error instanceof Error ? error.message : String(error)}`); retry.hidden = false; } finally { saving = false; } } function finishAndSave(state) { if (terminalState) return; terminalState = state; cleanup(); void saveTranscript(); } function finishConversation() { if (!ready) return; end.disabled = true; setStatus("Beende Gespräch und warte auf die finale Session-Bestätigung …"); send({ type: "session.close", event_id: eventId("close") }); closeTimer = setTimeout(() => finishAndSave("unconfirmed"), 15_000); } function handleEvent(event) { if (event.type === "session.started") { ready = true; mute.disabled = false; end.disabled = false; setStatus("Verbunden. Du kannst jetzt sprechen."); sessionTimer = setTimeout(() => { setStatus("Zeitlimit erreicht; Gespräch wird beendet."); finishConversation(); }, sessionMaxSeconds * 1000); } else if (event.type === "session.input_transcript.delta") { addCaption("Nutzer", event.delta); } else if (event.type === "session.output_transcript.delta") { addCaption("Assistent", event.delta); } else if (event.type === "session.usage.updated") { usage.textContent = `Bisherige GPT-Live-Dauer: ${event.usage?.seconds ?? 0} s. Responses-Delegation während des Gesprächs ist separat kostenpflichtig.`; } else if (event.type === "session.input_audio.muted") { muted = true; mute.textContent = "Mikrofon aktivieren"; } else if (event.type === "session.input_audio.unmuted") { muted = false; mute.textContent = "Stummschalten"; } else if (event.type === "session.closed") { finishAndSave("confirmed"); } else if (event.type === "error") { setStatus(event.error?.message ?? "Live-Session meldete einen Fehler."); } } start.addEventListener("click", async () => { start.disabled = true; transcript = []; conversationId = `${new Date().toISOString().slice(0, 10)}-${crypto.randomUUID()}`; terminalState = undefined; saved = false; captions.replaceChildren(); download.hidden = true; retry.hidden = true; setStatus("Verbinde …"); try { const config = await fetch("/api/config").then((response) => response.json()); sessionMaxSeconds = Number.isSafeInteger(config.sessionMaxSeconds) ? config.sessionMaxSeconds : 600; peer = new RTCPeerConnection(); peer.addEventListener("track", (event) => { const audio = new Audio(); audio.autoplay = true; audio.srcObject = new MediaStream([event.track]); audio.play().catch(() => setStatus("Wiedergabe wurde blockiert. Bitte Audio im Browser erlauben.")); }); microphone = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); microphone.getAudioTracks().forEach((track) => peer.addTrack(track, microphone)); events = peer.createDataChannel("oai-events"); events.addEventListener("message", ({ data }) => { try { handleEvent(JSON.parse(data)); } catch { setStatus("Ungültiges Session-Ereignis empfangen."); } }); events.addEventListener("close", () => { if (!terminalState) finishAndSave("unconfirmed"); }); const offer = await peer.createOffer(); await peer.setLocalDescription(offer); await waitForIce(peer); const sdp = peer.localDescription?.sdp; if (!sdp) throw new Error("Lokales SDP-Angebot fehlt."); const response = await fetch("/api/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sdp }), }); const result = await response.json(); if (!response.ok) throw new Error(result.error ?? "Session konnte nicht gestartet werden."); await peer.setRemoteDescription({ type: "answer", sdp: result.transport.sdp }); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); terminalState = "aborted"; cleanup(); } }); mute.addEventListener("click", () => { if (ready) send({ type: muted ? "session.input_audio.unmute" : "session.input_audio.mute", event_id: eventId("mute") }); }); end.addEventListener("click", finishConversation); retry.addEventListener("click", () => void saveTranscript());