With the media runtime in place, transcription becomes an operation like the others: bots call the transcribe tool, the job runs in the background and the card shows the subtitle files. - createTranscriptionOperations registers transcribe: available when a transcription server is configured, refuses artifacts the user cannot read before the job exists, and writes <name>.srt and <name>.vtt next to the source (a .txt when the server returns no timings). With ffmpeg the audio is converted to 16 kHz mono WAV and long media is transcribed in upload-sized ranges on one timeline; without it audio is sent as stored and video is refused with a clear message. A server that is down or busy (unreachable, 429, 502, 503) defers the job instead of failing it, since nothing has been stored yet. The bot gets the transcript, cut at a word. - The transcribe tool definition uses the shared media schema helper and continue_after; dispatch stays generic. - The main bot's run creates Studio once a media server is configured, right before the specialist directory is built. - Dictation: when the user's voice provider cannot transcribe (or there is none), /api/voice/transcribe uses the self-hosted transcription server, and the voice status reports server dictation so the web app uses it instead of the browser's speech recognition. A voice provider that transcribes keeps being used as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
import { ORPCError } from "@orpc/server";
|
|
import type { AdapterContext } from "@rakazo/adapter-kit";
|
|
import {
|
|
createOpenAiCompatibleTranscriptionProvider,
|
|
createVoiceProvider,
|
|
type EncryptedSecretStore,
|
|
isVoiceProviderId,
|
|
listVoiceCatalog,
|
|
loadMediaSettings,
|
|
MAX_SPEAK_CHARS,
|
|
MAX_TRANSCRIBE_BYTES,
|
|
NoVoiceConfigured,
|
|
voiceCatalogEntry,
|
|
} from "@rakazo/adapters";
|
|
import type { Actor, VoiceCredential, VoiceStatus } from "@rakazo/contracts";
|
|
import { toUtterances } from "@rakazo/core";
|
|
import {
|
|
deleteUnreferencedCredentialSecret,
|
|
findDefaultVoiceCredential,
|
|
findVoiceCredential,
|
|
IsolationError,
|
|
newestVoiceCredentialOrder,
|
|
Prisma,
|
|
type PrismaClient,
|
|
selectSpaceVoicePreference,
|
|
} from "@rakazo/db";
|
|
import type { Context, Hono } from "hono";
|
|
import { readBoundedBody } from "./http-body.js";
|
|
import { withSerializableRetry } from "./serializable-retry.js";
|
|
|
|
export interface VoiceDeps {
|
|
prisma: PrismaClient;
|
|
secrets: EncryptedSecretStore;
|
|
}
|
|
|
|
export { listVoiceCatalog };
|
|
|
|
const SPEAK_TIMEOUT_MS = 60_000;
|
|
export const MAX_SPEAK_REQUEST_BYTES = 16 * 1024;
|
|
export const MAX_TRANSCRIBE_REQUEST_BYTES = 4 * Math.ceil(MAX_TRANSCRIBE_BYTES / 3) + 1024;
|
|
|
|
export function voiceContext(actor: Actor, signal?: AbortSignal): AdapterContext {
|
|
return {
|
|
operationId: "voice",
|
|
traceId: "voice",
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
signal: signal ?? new AbortController().signal,
|
|
};
|
|
}
|
|
|
|
export function catalogEntry(provider: string) {
|
|
return voiceCatalogEntry(provider);
|
|
}
|
|
|
|
/** The deployment's self-hosted transcription server, when the owner set one up. */
|
|
export async function mediaTranscriptionSettings(deps: VoiceDeps) {
|
|
return (await loadMediaSettings(deps).catch(() => null))?.transcription ?? null;
|
|
}
|
|
|
|
export function toVoiceStatus(
|
|
cred: { provider: string; voiceId: string } | null,
|
|
options: { mediaTranscription?: boolean } = {},
|
|
): VoiceStatus {
|
|
const entry = cred ? catalogEntry(cred.provider) : undefined;
|
|
return {
|
|
configured: Boolean(cred),
|
|
ready: Boolean(cred?.voiceId),
|
|
transcribe: Boolean(entry?.transcribe && cred) || Boolean(options.mediaTranscription),
|
|
provider: cred?.provider ?? null,
|
|
voiceId: cred?.voiceId ?? "",
|
|
};
|
|
}
|
|
|
|
export function toVoiceCredential(row: {
|
|
id: string;
|
|
provider: string;
|
|
isDefault: boolean;
|
|
voiceId: string;
|
|
}): VoiceCredential {
|
|
return {
|
|
id: row.id,
|
|
provider: row.provider,
|
|
hasKey: true,
|
|
isDefault: row.isDefault,
|
|
voiceId: row.voiceId,
|
|
transcribe: Boolean(catalogEntry(row.provider)?.transcribe),
|
|
};
|
|
}
|
|
|
|
export async function loadDefaultVoiceCredential(deps: VoiceDeps, actor: Actor) {
|
|
return loadVoiceCredential(deps, actor);
|
|
}
|
|
|
|
export async function loadVoiceCredential(deps: VoiceDeps, actor: Actor, provider?: string) {
|
|
const cred = provider
|
|
? await findVoiceCredential(deps.prisma, actor, provider)
|
|
: await findDefaultVoiceCredential(deps.prisma, actor);
|
|
if (!cred) return null;
|
|
const secret = await deps.prisma.secret.findFirst({
|
|
where: { id: cred.secretId, userId: actor.userId, spaceId: null },
|
|
});
|
|
if (!secret) return null;
|
|
return { cred, apiKey: deps.secrets.load(secret.ciphertext, secret.id) };
|
|
}
|
|
|
|
export async function resolveVoiceTarget(
|
|
deps: VoiceDeps,
|
|
actor: Actor,
|
|
input: { botId?: string; voiceId?: string },
|
|
) {
|
|
let botVoiceId: string | null = null;
|
|
if (input.botId) {
|
|
const bot = await deps.prisma.bot.findFirst({
|
|
where: { id: input.botId, spaceId: actor.spaceId, userId: actor.userId },
|
|
select: { voiceId: true },
|
|
});
|
|
if (!bot) throw new IsolationError();
|
|
botVoiceId = bot.voiceId;
|
|
}
|
|
const loaded = await loadDefaultVoiceCredential(deps, actor);
|
|
if (!loaded) throw new NoVoiceConfigured("key");
|
|
const voiceId = input.voiceId || botVoiceId || loaded.cred.voiceId;
|
|
if (!voiceId) throw new NoVoiceConfigured("voice");
|
|
return { ...loaded, voiceId };
|
|
}
|
|
|
|
export async function persistVoiceCredential(
|
|
deps: VoiceDeps,
|
|
actor: Actor,
|
|
input: {
|
|
provider: string;
|
|
plaintext: string;
|
|
voiceId?: string;
|
|
signal?: AbortSignal;
|
|
},
|
|
): Promise<VoiceCredential> {
|
|
if (!isVoiceProviderId(input.provider)) {
|
|
throw new ORPCError("BAD_REQUEST", { message: "Unknown voice provider." });
|
|
}
|
|
const provider = createVoiceProvider(input.provider);
|
|
const verified = await provider.verify(input.plaintext, voiceContext(actor, input.signal));
|
|
if (!verified.ok) {
|
|
throw new ORPCError("BAD_REQUEST", { message: verified.message ?? "That key was rejected." });
|
|
}
|
|
let voiceId = input.voiceId?.trim() ?? "";
|
|
if (!voiceId) {
|
|
const voices = await provider.listVoices(input.plaintext, voiceContext(actor, input.signal));
|
|
voiceId = voices[0]?.id ?? "";
|
|
}
|
|
const stored = await deps.secrets.put(input.plaintext, voiceContext(actor, input.signal));
|
|
const cred = await withSerializableRetry(() =>
|
|
deps.prisma.$transaction(
|
|
async (tx) => {
|
|
const existing = await tx.userVoiceCredential.findFirst({
|
|
where: { userId: actor.userId, provider: input.provider },
|
|
orderBy: newestVoiceCredentialOrder,
|
|
});
|
|
const secret = await tx.secret.create({
|
|
data: {
|
|
id: stored.id,
|
|
userId: actor.userId,
|
|
spaceId: null,
|
|
kind: "voice",
|
|
ciphertext: stored.ciphertext,
|
|
},
|
|
});
|
|
const credential = !existing
|
|
? await tx.userVoiceCredential.create({
|
|
data: {
|
|
userId: actor.userId,
|
|
provider: input.provider,
|
|
secretId: secret.id,
|
|
},
|
|
})
|
|
: await tx.userVoiceCredential.update({
|
|
where: { id: existing.id },
|
|
data: { secretId: secret.id },
|
|
});
|
|
const previousPreference = existing
|
|
? await tx.spaceVoicePreference.findUnique({
|
|
where: {
|
|
spaceId_userId_credentialId: {
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
credentialId: existing.id,
|
|
},
|
|
},
|
|
})
|
|
: null;
|
|
const selectedVoiceId = voiceId || previousPreference?.voiceId || "";
|
|
await selectSpaceVoicePreference(tx, actor, credential.id, selectedVoiceId);
|
|
if (existing) {
|
|
await deleteUnreferencedCredentialSecret(tx, {
|
|
credentialKind: "voice",
|
|
credentialId: existing.id,
|
|
secretId: existing.secretId,
|
|
});
|
|
}
|
|
return { ...credential, isDefault: true, voiceId: selectedVoiceId };
|
|
},
|
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
|
),
|
|
);
|
|
return toVoiceCredential(cred);
|
|
}
|
|
|
|
export async function disconnectVoiceCredential(
|
|
deps: VoiceDeps,
|
|
actor: Actor,
|
|
input: { provider: string },
|
|
): Promise<{ ok: true }> {
|
|
const provider = input.provider.trim();
|
|
if (!provider) {
|
|
throw new ORPCError("BAD_REQUEST", { message: "Unknown voice provider." });
|
|
}
|
|
await withSerializableRetry(() =>
|
|
deps.prisma.$transaction(
|
|
async (tx) => {
|
|
const existing = await tx.userVoiceCredential.findMany({
|
|
where: { userId: actor.userId, provider },
|
|
});
|
|
if (existing.length === 0) return;
|
|
const ids = existing.map((row) => row.id);
|
|
await tx.spaceVoicePreference.deleteMany({
|
|
where: { userId: actor.userId, credentialId: { in: ids } },
|
|
});
|
|
await tx.userVoiceCredential.deleteMany({
|
|
where: { userId: actor.userId, id: { in: ids } },
|
|
});
|
|
for (const row of existing) {
|
|
await deleteUnreferencedCredentialSecret(tx, {
|
|
credentialKind: "voice",
|
|
credentialId: row.id,
|
|
secretId: row.secretId,
|
|
});
|
|
}
|
|
},
|
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
|
),
|
|
);
|
|
return { ok: true as const };
|
|
}
|
|
|
|
export async function prepareVoice(
|
|
deps: VoiceDeps,
|
|
actor: Actor,
|
|
input: { text: string; voiceId?: string; botId?: string },
|
|
) {
|
|
try {
|
|
await resolveVoiceTarget(deps, actor, input);
|
|
} catch (error) {
|
|
if (error instanceof NoVoiceConfigured) {
|
|
return { ready: false, utterances: [] as string[] };
|
|
}
|
|
throw error;
|
|
}
|
|
return { ready: true, utterances: toUtterances(input.text) };
|
|
}
|
|
|
|
export async function synthesizeVoice(
|
|
deps: VoiceDeps,
|
|
actor: Actor,
|
|
input: { text: string; voiceId?: string; botId?: string; signal?: AbortSignal },
|
|
) {
|
|
const target = await resolveVoiceTarget(deps, actor, input);
|
|
const text = input.text.trim();
|
|
if (!text) throw new ORPCError("BAD_REQUEST", { message: "Nothing to speak." });
|
|
if (text.length > MAX_SPEAK_CHARS) {
|
|
throw new ORPCError("BAD_REQUEST", { message: "That utterance is too long to speak." });
|
|
}
|
|
const provider = createVoiceProvider(target.cred.provider);
|
|
return provider.synthesize(
|
|
{
|
|
text,
|
|
voiceId: target.voiceId,
|
|
apiKey: target.apiKey,
|
|
signal: input.signal,
|
|
},
|
|
voiceContext(actor, input.signal),
|
|
);
|
|
}
|
|
|
|
export async function transcribeVoice(
|
|
deps: VoiceDeps,
|
|
actor: Actor,
|
|
input: { audio: Uint8Array; mimeType: string; signal?: AbortSignal },
|
|
) {
|
|
const loaded = await loadDefaultVoiceCredential(deps, actor);
|
|
const provider = loaded ? createVoiceProvider(loaded.cred.provider) : null;
|
|
if (!loaded || !provider?.transcribe) {
|
|
// Without a voice provider that transcribes, the self-hosted media server can.
|
|
const server = await mediaTranscriptionSettings(deps);
|
|
if (server) {
|
|
if (input.audio.byteLength === 0 || input.audio.byteLength > MAX_TRANSCRIBE_BYTES) {
|
|
throw new ORPCError("BAD_REQUEST", { message: "That recording is empty or too large." });
|
|
}
|
|
const transcript = await createOpenAiCompatibleTranscriptionProvider(server).transcribe({
|
|
audio: input.audio,
|
|
mimeType: input.mimeType || "audio/webm",
|
|
signal: input.signal,
|
|
});
|
|
return { text: transcript.text };
|
|
}
|
|
if (!loaded) throw new NoVoiceConfigured("key");
|
|
throw new ORPCError("BAD_REQUEST", {
|
|
message: "This voice provider does not transcribe audio. Use on-device dictation instead.",
|
|
});
|
|
}
|
|
if (input.audio.byteLength === 0 || input.audio.byteLength > MAX_TRANSCRIBE_BYTES) {
|
|
throw new ORPCError("BAD_REQUEST", { message: "That recording is empty or too large." });
|
|
}
|
|
return provider.transcribe(
|
|
{
|
|
audio: input.audio,
|
|
mimeType: input.mimeType || "audio/webm",
|
|
apiKey: loaded.apiKey,
|
|
signal: input.signal,
|
|
},
|
|
voiceContext(actor, input.signal),
|
|
);
|
|
}
|
|
|
|
export function mountVoiceHttpRoutes(
|
|
app: Hono,
|
|
deps: VoiceDeps,
|
|
authenticate: (c: Context) => Promise<Actor | null>,
|
|
) {
|
|
app.post("/api/voice/speak", async (c) => {
|
|
const actor = await authenticate(c);
|
|
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
const raw = await readBoundedBody(c.req.raw, MAX_SPEAK_REQUEST_BYTES);
|
|
if (raw === null) return c.json({ error: "Request body is too large." }, 413);
|
|
const body = parseVoiceRequestBody(raw);
|
|
try {
|
|
const clip = await synthesizeVoice(deps, actor, {
|
|
text: String((body as { text?: unknown }).text ?? ""),
|
|
voiceId: optionalString((body as { voiceId?: unknown }).voiceId),
|
|
botId: optionalString((body as { botId?: unknown }).botId),
|
|
signal: AbortSignal.any(
|
|
[c.req.raw.signal, AbortSignal.timeout(SPEAK_TIMEOUT_MS)].filter(
|
|
Boolean,
|
|
) as AbortSignal[],
|
|
),
|
|
});
|
|
// Copy into a fresh ArrayBuffer-backed view: DOM-lib BodyInit rejects
|
|
// Uint8Array<ArrayBufferLike> since TS 5.7.
|
|
return new Response(new Uint8Array(clip.bytes), {
|
|
headers: {
|
|
"content-type": clip.mimeType,
|
|
"cache-control": "no-store",
|
|
},
|
|
});
|
|
} catch (error) {
|
|
return voiceHttpError(c, error);
|
|
}
|
|
});
|
|
|
|
app.post("/api/voice/transcribe", async (c) => {
|
|
const actor = await authenticate(c);
|
|
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
const raw = await readBoundedBody(c.req.raw, MAX_TRANSCRIBE_REQUEST_BYTES);
|
|
if (raw === null) return c.json({ error: "Request body is too large." }, 413);
|
|
const body = parseVoiceRequestBody(raw);
|
|
const audioBase64 = String((body as { audioBase64?: unknown }).audioBase64 ?? "");
|
|
try {
|
|
const audio = decodeAudioBase64(audioBase64);
|
|
const result = await transcribeVoice(deps, actor, {
|
|
audio,
|
|
mimeType: String((body as { mimeType?: unknown }).mimeType ?? "audio/webm"),
|
|
signal: c.req.raw.signal,
|
|
});
|
|
return c.json({ text: result.text });
|
|
} catch (error) {
|
|
return voiceHttpError(c, error);
|
|
}
|
|
});
|
|
}
|
|
|
|
function parseVoiceRequestBody(raw: string): Record<string, unknown> {
|
|
try {
|
|
const parsed: unknown = JSON.parse(raw);
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
? (parsed as Record<string, unknown>)
|
|
: {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function optionalString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
}
|
|
|
|
function decodeAudioBase64(value: string): Uint8Array {
|
|
if (!value.trim()) throw new ORPCError("BAD_REQUEST", { message: "Recording is empty." });
|
|
try {
|
|
return new Uint8Array(Buffer.from(value, "base64"));
|
|
} catch {
|
|
throw new ORPCError("BAD_REQUEST", { message: "Recording is not valid audio." });
|
|
}
|
|
}
|
|
|
|
function voiceHttpError(c: Context, error: unknown) {
|
|
if (error instanceof IsolationError) {
|
|
return c.json({ error: "Resource not found" }, 404);
|
|
}
|
|
if (error instanceof NoVoiceConfigured) {
|
|
return c.json({ error: error.message }, 409);
|
|
}
|
|
if (error instanceof ORPCError) {
|
|
const code = String(error.code ?? "BAD_REQUEST");
|
|
const status =
|
|
code === "UNAUTHORIZED" ? 401 : code === "NOT_FOUND" ? 404 : code === "CONFLICT" ? 409 : 400;
|
|
return c.json({ error: error.message }, status);
|
|
}
|
|
const message = error instanceof Error ? error.message : "Voice request failed.";
|
|
return c.json({ error: message }, 502);
|
|
}
|