Add dictation and streamed read-aloud to the chat

- A mic in the composer records, transcribes with the bot's
  transcription model (api/admin/bots/transcribe) and writes the text
  into the input at the cursor to edit before sending. Escape cancels;
  recordings aren't stored.
- Every bot message has a read-aloud button that plays
  GET api/admin/bots/speak-message, streamed from the provider as the
  audio is made, so it starts in a couple of seconds. One message plays
  at a time; only the user's own sessions can be read.
- Both buttons stay visible but disabled while the bot has no model for
  them (bot_voice page prop).
- Transcription and speech no longer pick free models automatically:
  they're not available until a model is picked, and Settings says so.
  Transcription lists speech-to-text models only, so the chat-model
  transcript path and its missing-audio check are gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 21:21:04 +01:00
co-authored by Claude Opus 5.5
parent 727cbf53c8
commit 163d9a434c
37 changed files with 936 additions and 197 deletions
@@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from "react";
export type SpeakMessageState = "idle" | "loading" | "playing";
export type SpeakMessage = {
/**
* The bot has a speech model here; the play buttons are disabled
* without one.
*/
enabled: boolean;
/**
* The message being fetched or played, if any.
*/
message_id: number | null;
state: SpeakMessageState;
error?: { message_id: number; text: string };
/**
* Plays the message, or stops it when it's the one playing. Starting
* one stops any other.
*/
toggle: (params: { message_id: number }) => void;
};
/**
* The conversation's read-aloud: one audio element for every message,
* pointed at `api/admin/bots/speak-message`, which streams, so playing
* starts before the whole message is spoken. Stopping drops the stream.
*/
export default function useSpeakMessage({ enabled }: { enabled: boolean }): SpeakMessage {
const [message_id, setMessageId] = useState<number | null>(null);
const [state, setState] = useState<SpeakMessageState>("idle");
const [error, setError] = useState<SpeakMessage["error"]>();
const audio_ref = useRef<HTMLAudioElement | null>(null);
const current_ref = useRef<number | null>(null);
useEffect(() => () => release(), []);
function release() {
const audio = audio_ref.current;
if (!audio) return;
audio.pause();
audio.removeAttribute("src");
audio.load();
}
function reset() {
current_ref.current = null;
setMessageId(null);
setState("idle");
}
function stop() {
release();
reset();
}
function play({ message_id }: { message_id: number }) {
stop();
setError(undefined);
current_ref.current = message_id;
setMessageId(message_id);
setState("loading");
const src = `/api/admin/bots/speak-message?message_id=${message_id}`;
const audio = audio_ref.current || new Audio();
const isCurrent = () => current_ref.current == message_id;
audio_ref.current = audio;
audio.onplaying = () => {
if (isCurrent()) setState("playing");
};
audio.onended = () => {
if (isCurrent()) reset();
};
audio.onerror = () => {
if (!isCurrent()) return;
reset();
explainError({ src }).then((text) => {
setError({ message_id, text });
});
};
audio.src = src;
audio.play().catch(() => {});
}
function toggle({ message_id: id }: { message_id: number }) {
if (current_ref.current == id) {
stop();
return;
}
play({ message_id: id });
}
return { enabled, message_id, state, error, toggle };
}
/**
* The player only says it failed, so ask again for the reason: a failure
* answers in JSON, and audio coming back instead is dropped at once.
*/
async function explainError({ src }: { src: string }) {
const fallback = "Couldn't read it aloud. Try again.";
const controller = new AbortController();
try {
const res = await fetch(src, { signal: controller.signal, cache: "no-store" });
if (!res.headers.get("content-type")?.includes("json")) {
controller.abort();
return fallback;
}
const json = await res.json();
return json?.msg || fallback;
} catch {
return fallback;
}
}
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState } from "react";
import { AppData } from "@/src/data/app-data";
export type VoiceRecorderState = "idle" | "recording" | "transcribing";
type Params = {
/**
* Gets the finished recording. The hook stays `transcribing` until it
* resolves.
*/
onRecorded: (params: { audio: Blob }) => Promise<void>;
};
/**
* Types the browser may record in, best first. Safari only has mp4.
*/
const RecordingTypes = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg"];
/**
* Records the microphone for the composer's dictation. Stops on its own
* after `AppData.ComposerVoiceMaxMs`; Escape cancels. The mic is released whenever
* recording ends, and on unmount. `supported` is false without a secure
* context or `MediaRecorder`.
*/
export default function useVoiceRecorder({ onRecorded }: Params) {
const [state, setState] = useState<VoiceRecorderState>("idle");
const [elapsed_ms, setElapsedMs] = useState(0);
const [error, setError] = useState<string>();
const [supported, setSupported] = useState(false);
const recorder_ref = useRef<MediaRecorder | null>(null);
const cancelled_ref = useRef(false);
const on_recorded_ref = useRef(onRecorded);
on_recorded_ref.current = onRecorded;
useEffect(() => {
setSupported(
typeof MediaRecorder != "undefined" &&
Boolean(navigator.mediaDevices?.getUserMedia),
);
return () => {
cancelled_ref.current = true;
recorder_ref.current?.stop();
};
}, []);
useEffect(() => {
if (state != "recording") return;
const started_at = Date.now();
const timer = setInterval(() => {
const ms = Date.now() - started_at;
setElapsedMs(ms);
if (ms >= AppData.ComposerVoiceMaxMs) stop();
}, 250);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key != "Escape") return;
e.preventDefault();
cancel();
};
window.addEventListener("keydown", onKeyDown);
return () => {
clearInterval(timer);
window.removeEventListener("keydown", onKeyDown);
};
}, [state]);
async function start() {
if (state != "idle") return;
setError(undefined);
setElapsedMs(0);
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
setError("Microphone blocked. Allow it in the browser's site settings.");
return;
}
const type = RecordingTypes.find((t) => MediaRecorder.isTypeSupported(t));
const recorder = new MediaRecorder(stream, type ? { mimeType: type } : undefined);
const chunks: Blob[] = [];
cancelled_ref.current = false;
recorder.ondataavailable = (e) => {
if (e.data.size) chunks.push(e.data);
};
recorder.onstop = async () => {
stream.getTracks().forEach((track) => track.stop());
recorder_ref.current = null;
if (cancelled_ref.current || !chunks.length) {
setState("idle");
return;
}
setState("transcribing");
/**
* The bare type, without `;codecs=`, so it reads as a plain
* data URL.
*/
const audio = new Blob(chunks, {
type: (recorder.mimeType || type || "audio/webm").split(";")[0],
});
await on_recorded_ref.current({ audio }).catch(() => {});
setState("idle");
};
recorder_ref.current = recorder;
recorder.start();
setState("recording");
}
function stop() {
recorder_ref.current?.stop();
}
function cancel() {
cancelled_ref.current = true;
recorder_ref.current?.stop();
}
return { state, elapsed_ms, error, setError, supported, start, stop, cancel };
}