evOo-local/apps/api/src/voice.ts
Claude c380e7820a
feat(media): transcribe audio and video into subtitles as a media job
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
2026-10-03 13:47:49 +00:00

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);
}