/* Dictation and read-aloud. Both halves are progressive: without this file the composer and the message bubbles still work, they simply have two buttons that do nothing. Neither feature's markup is rendered at all unless an administrator has configured an endpoint for it, so that state is rare rather than normal. */ (function () { "use strict"; function notify(message, kind) { if (window.lembas && window.lembas.notify) { window.lembas.notify(message, { kind: kind || "info" }); } } /* --- Dictation --------------------------------------------------------- MediaRecorder writes whatever container the browser prefers -- webm/opus almost everywhere, mp4 on Safari. The file is passed upstream with the type the browser reported rather than being converted here: whisper.cpp and friends decode through ffmpeg and take all of them, and converting in the browser would mean shipping an encoder. */ var recorder = null; var chunks = []; var micButton = null; function setMicState(button, state) { if (!button) return; button.dataset.micState = state; button.disabled = state === "working"; button.setAttribute( "aria-label", state === "recording" ? "Stop recording" : "Dictate a message" ); button.title = button.getAttribute("aria-label"); } function composerInput() { return document.querySelector("[data-composer-input]"); } function insertTranscript(text) { var input = composerInput(); if (!input || !text) return; // Appended rather than replacing: dictation is usually finishing a thought // that was already half typed. var existing = input.value.trim(); input.value = existing ? existing + " " + text : text; if (window.lembas && window.lembas.autosize) window.lembas.autosize(input); if (window.lembas && window.lembas.paintComposer) window.lembas.paintComposer(); input.focus(); input.selectionStart = input.selectionEnd = input.value.length; } function upload(blob, button) { var body = new FormData(); // The extension only has to be something the server can name the part; // the endpoint sniffs the container itself. var extension = (blob.type.indexOf("mp4") !== -1) ? "mp4" : "webm"; body.append("file", blob, "dictation." + extension); setMicState(button, "working"); fetch("/api/audio/transcribe", { method: "POST", body: body, credentials: "same-origin", }) .then(function (response) { if (!response.ok) { return response.json() .catch(function () { return {}; }) .then(function (payload) { throw new Error(payload.detail || "Transcription failed."); }); } return response.text(); }) .then(function (text) { setMicState(button, "idle"); if (!text.trim()) { notify("Nothing was heard in that recording.", "info"); return; } insertTranscript(text.trim()); }) .catch(function (error) { setMicState(button, "idle"); notify(error.message || "Transcription failed.", "error"); }); } function startRecording(button) { /* getUserMedia is undefined on plain http, which a self-hosted install on a LAN address often is. Saying so beats a button that silently does nothing -- the fix is not something the page can apply for them. */ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof MediaRecorder === "undefined") { notify( "The microphone needs HTTPS or localhost. This page is served over " + "plain HTTP, so the browser will not grant it.", "error" ); return; } navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) { chunks = []; recorder = new MediaRecorder(stream); micButton = button; recorder.addEventListener("dataavailable", function (event) { if (event.data && event.data.size) chunks.push(event.data); }); recorder.addEventListener("stop", function () { // Release the microphone immediately: leaving the track live keeps the // browser's recording indicator on long after anyone is talking. stream.getTracks().forEach(function (track) { track.stop(); }); var blob = new Blob(chunks, { type: recorder.mimeType || "audio/webm" }); recorder = null; if (blob.size) upload(blob, button); else setMicState(button, "idle"); }); recorder.start(); setMicState(button, "recording"); }).catch(function () { notify("The microphone could not be opened. Permission may be blocked.", "error"); }); } function stopRecording() { if (recorder && recorder.state !== "inactive") recorder.stop(); } /* --- Reading a reply aloud --------------------------------------------- One