Let admins hear a voice sample before picking it
The Speech section of every model settings form now has "Hear a sample", which streams a short sentence (AppData.VoicePreviewText) in the form's model and voice before they're saved, through GET api/admin/harness/preview-voice (super admins and admins, that model only via grabModelCandidate). pickVoice is shared with speakText, and explainAudioError reads why a streamed player failed for both read-aloud and the sample. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import explainAudioError from "@/src/functions/frontend/explain-audio-error";
|
||||
|
||||
export type SpeakMessageState = "idle" | "loading" | "playing";
|
||||
|
||||
@@ -78,7 +79,7 @@ export default function useSpeakMessage({ enabled }: { enabled: boolean }): Spea
|
||||
if (!isCurrent()) return;
|
||||
|
||||
reset();
|
||||
explainError({ src }).then((text) => {
|
||||
explainAudioError({ src, fallback: "Couldn't read it aloud. Try again." }).then((text) => {
|
||||
setError({ message_id, text });
|
||||
});
|
||||
};
|
||||
@@ -97,27 +98,3 @@ export default function useSpeakMessage({ enabled }: { enabled: boolean }): Spea
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import SettingsGroup from "../../(partials)/settings-group";
|
||||
import HarnessCurrentModel from "./harness-current-model";
|
||||
import HarnessModelSelect from "./harness-model-select";
|
||||
import HarnessProviderSelect from "./harness-provider-select";
|
||||
import HarnessVoicePreview from "./harness-voice-preview";
|
||||
import HarnessVoiceSelect from "./harness-voice-select";
|
||||
|
||||
type Props = {
|
||||
@@ -15,8 +16,8 @@ type Props = {
|
||||
/**
|
||||
* One harness: its provider, then its model (narrowed to that provider),
|
||||
* whether it falls back to the automatic picks (for a harness that has
|
||||
* them), whether it's on, and its
|
||||
* voice for speech. On a bot's page the provider select can also use the
|
||||
* them), whether it's on, and its voice for speech, with a sample to hear
|
||||
* it before saving. On a bot's page the provider select can also use the
|
||||
* default, which hides the rest.
|
||||
*/
|
||||
export default function HarnessSettingGroup({ harness, value, onChange }: Props) {
|
||||
@@ -86,6 +87,9 @@ export default function HarnessSettingGroup({ harness, value, onChange }: Props)
|
||||
onChange={(voice) => onChange({ ...value, voice: voice || undefined })}
|
||||
/>
|
||||
) : null}
|
||||
{!value.inherit && harness.harness == "speech" && picked ? (
|
||||
<HarnessVoicePreview model_id={picked.id} voice={value.voice} />
|
||||
) : null}
|
||||
{value.inherit || harness.required ? null : (
|
||||
<Checkbox
|
||||
title={`Turn ${harness.title.toLowerCase()} on`}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { Square, Volume2 } from "lucide-react";
|
||||
import explainAudioError from "@/src/functions/frontend/explain-audio-error";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The speech model picked in the form, saved or not.
|
||||
*/
|
||||
model_id: number;
|
||||
voice?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Plays a short sample in the form's speech model and voice, before
|
||||
* they're saved (`api/admin/harness/preview-voice`, streamed). Stops when
|
||||
* clicked again or when the pick changes.
|
||||
*/
|
||||
export default function HarnessVoicePreview({ model_id, voice }: Props) {
|
||||
const [state, setState] = useState<"idle" | "loading" | "playing">("idle");
|
||||
const [error, setError] = useState<string>();
|
||||
const audio_ref = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
function stop() {
|
||||
const audio = audio_ref.current;
|
||||
|
||||
if (audio) {
|
||||
audio.onerror = null;
|
||||
audio.pause();
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
}
|
||||
|
||||
setState("idle");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setError(undefined);
|
||||
return stop;
|
||||
}, [model_id, voice]);
|
||||
|
||||
function play() {
|
||||
stop();
|
||||
setError(undefined);
|
||||
setState("loading");
|
||||
|
||||
const src = `/api/admin/harness/preview-voice?${new URLSearchParams({
|
||||
model_id: String(model_id),
|
||||
...(voice ? { voice } : {}),
|
||||
})}`;
|
||||
const audio = audio_ref.current || new Audio();
|
||||
|
||||
audio_ref.current = audio;
|
||||
audio.onplaying = () => setState("playing");
|
||||
audio.onended = () => setState("idle");
|
||||
audio.onerror = () => {
|
||||
setState("idle");
|
||||
explainAudioError({ src, fallback: "Couldn't play the sample. Try again." }).then(setError);
|
||||
};
|
||||
audio.src = src;
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
|
||||
const playing = state == "playing";
|
||||
|
||||
return (
|
||||
<Row className="gap-2! flex-nowrap items-center">
|
||||
<Button
|
||||
title={playing ? "Stop the sample" : "Hear a short sample in this voice"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
loading={state == "loading"}
|
||||
beforeIcon={
|
||||
playing ? <Square size={13} fill="currentColor" /> : <Volume2 size={15} />
|
||||
}
|
||||
onClick={playing ? stop : play}
|
||||
>
|
||||
{playing ? "Stop" : "Hear a sample"}
|
||||
</Button>
|
||||
{error ? (
|
||||
<Span role="status" className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{error}
|
||||
</Span>
|
||||
) : null}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabModelCandidate from "@/src/functions/backend/db/harness/grab-model-candidate";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { pickVoice } from "@/src/harness/audio/speak-text";
|
||||
import HarnessError from "@/src/harness/harness-error";
|
||||
import grabHarnessAdapter from "@/src/harness/providers/grab-harness-adapter";
|
||||
import { PreviewVoiceRequestSchema } from "@/src/schemas/harness-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* `GET ?model_id=&voice=`: a short sample (`AppData.VoicePreviewText`) in
|
||||
* a speech model and voice, picked or not, streamed for an `<audio>`, so
|
||||
* a voice can be heard before it's saved. For super admins and admins,
|
||||
* who set speech models. No fallback: it's that model or an error.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, query }) => {
|
||||
try {
|
||||
if (req.method != "GET") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
if (!canManageCrew({ user_types })) {
|
||||
throw new AppError(`Only admins can try voices.`);
|
||||
}
|
||||
|
||||
const { model_id, voice } = parseWithSchema({
|
||||
schema: PreviewVoiceRequestSchema,
|
||||
data: query,
|
||||
});
|
||||
|
||||
const candidate = await grabModelCandidate({ model_id, harness: "speech" });
|
||||
|
||||
const audio = await grabHarnessAdapter({ kind: candidate.provider.kind }).speak({
|
||||
candidate,
|
||||
text: AppData.VoicePreviewText,
|
||||
voice: pickVoice({ candidate, voice }),
|
||||
signal: req.signal,
|
||||
});
|
||||
|
||||
return new Response(audio.stream, {
|
||||
headers: {
|
||||
"Content-Type": audio.media_type,
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error instanceof HarnessError ? error.message : safeErrorMessage({ error }),
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user