cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

251 lines
7.8 KiB
TypeScript

import { ensureAiDataConsent, readBoundedResponseBytes } from "@rakazo/core";
import { File, Paths } from "expo-file-system";
import { promptAiConsent } from "./ai-consent";
import type { ApiRequestContext } from "./api";
import { captureApiRequestContext, rpc } from "./api";
import { t } from "./i18n";
type SpeechOptions = { voiceId?: string; botId?: string };
export const VOICE_RESPONSE_TIMEOUT_MS = 70_000;
export const MAX_VOICE_AUDIO_BYTES = 16 * 1024 * 1024;
const MAX_VOICE_ERROR_BYTES = 64 * 1024;
export async function speakText(text: string, opts: SpeechOptions = {}): Promise<boolean> {
const requestContext = await captureApiRequestContext();
const prepared = await rpc<{ ready: boolean; utterances: string[] }>(
"voice/prepare",
{ text, voiceId: opts.voiceId, botId: opts.botId },
{ requestContext },
);
if (!prepared.ready) return false;
for (const utterance of prepared.utterances) {
await playMpeg(await renderUtterance(utterance, opts, requestContext));
}
return true;
}
export async function speakUtterance(
text: string,
opts: SpeechOptions & { requestContext?: ApiRequestContext } = {},
): Promise<Uint8Array> {
const requestContext = opts.requestContext ?? (await captureApiRequestContext());
await ensureAiDataConsent({
uses: ["voice"],
status: () => rpc("aiConsent/status", { uses: ["voice"] }, { requestContext }),
prompt: promptAiConsent,
allow: (input) => rpc("aiConsent/allow", input, { requestContext }),
});
return renderUtterance(text, opts, requestContext);
}
async function renderUtterance(
text: string,
opts: SpeechOptions,
requestContext: ApiRequestContext,
): Promise<Uint8Array> {
const deadline = requestDeadline(VOICE_RESPONSE_TIMEOUT_MS);
try {
const res = await withAbort(
fetch(`${requestContext.apiBase}/api/voice/speak`, {
method: "POST",
headers: {
"content-type": "application/json",
origin: "rakazo://",
...requestContext.headers,
},
body: JSON.stringify({ text, voiceId: opts.voiceId, botId: opts.botId }),
signal: deadline.signal,
}),
deadline.signal,
);
if (!res.ok) {
const body = await readVoiceError(res, deadline.signal);
throw new Error(body.error ?? `Voice failed (${res.status})`);
}
return await readResponseBytes(res, MAX_VOICE_AUDIO_BYTES, deadline.signal);
} finally {
deadline.dispose();
}
}
export async function playMpeg(bytes: Uint8Array): Promise<void> {
const AudioCtor = (globalThis as { Audio?: typeof Audio }).Audio;
if (typeof AudioCtor === "function") {
await playWithHtmlAudio(AudioCtor, bytes);
return;
}
await playWithNativeAudio(bytes);
}
async function playWithHtmlAudio(AudioCtor: typeof Audio, bytes: Uint8Array): Promise<void> {
const blob = new Blob([new Uint8Array(bytes)], { type: "audio/mpeg" });
const url = URL.createObjectURL(blob);
try {
const audio = new AudioCtor(url);
await new Promise<void>((resolve, reject) => {
let settled = false;
const finish = (error?: Error) => {
if (settled) return;
settled = true;
audio.onended = null;
audio.onerror = null;
if (error) reject(error);
else resolve();
};
audio.onended = () => finish();
audio.onerror = () => finish(new Error(t("Could not play that clip.")));
try {
void audio
.play()
.catch((error: unknown) =>
finish(error instanceof Error ? error : new Error(t("Could not play that clip."))),
);
} catch (error) {
finish(error instanceof Error ? error : new Error(t("Could not play that clip.")));
}
});
} finally {
URL.revokeObjectURL(url);
}
}
async function playWithNativeAudio(bytes: Uint8Array): Promise<void> {
const { createAudioPlayer, setAudioModeAsync } = await import("expo-audio");
await setAudioModeAsync({
playsInSilentMode: true,
interruptionMode: "mixWithOthers",
shouldPlayInBackground: false,
});
const file = new File(Paths.cache, `rakazo-voice-${Date.now()}.mp3`);
file.create({ overwrite: true });
file.write(bytesToBase64(bytes), { encoding: "base64" });
const player = createAudioPlayer({ uri: file.uri });
try {
await new Promise<void>((resolve, reject) => {
let settled = false;
let timer = setTimeout(() => finish(new Error(t("Could not play that clip."))), 15_000);
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
sub.remove();
if (error) reject(error);
else resolve();
};
const sub = player.addListener("playbackStatusUpdate", (status) => {
if (status.error) {
finish(new Error(status.error));
return;
}
if (status.playbackState === "failed") {
finish(new Error(t("Could not play that clip.")));
return;
}
if (status.didJustFinish) {
finish();
return;
}
if (status.playing && status.duration > 0) {
clearTimeout(timer);
timer = setTimeout(
() => finish(new Error(t("Could not play that clip."))),
Math.min(120_000, Math.ceil(status.duration * 1000) + 8_000),
);
}
});
try {
player.play();
} catch (error) {
finish(error instanceof Error ? error : new Error(t("Could not play that clip.")));
}
});
} finally {
player.release();
try {
file.delete();
} catch {
// already gone
}
}
}
function bytesToBase64(bytes: Uint8Array): string {
if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
function requestDeadline(timeoutMs: number) {
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(new Error("Voice request timed out.")),
timeoutMs,
);
return {
signal: controller.signal,
dispose() {
clearTimeout(timer);
},
};
}
async function readVoiceError(
response: Response,
signal: AbortSignal,
): Promise<{ error?: string }> {
const bytes = await readResponseBytes(response, MAX_VOICE_ERROR_BYTES, signal);
try {
const parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
return parsed && typeof parsed === "object" ? (parsed as { error?: string }) : {};
} catch {
return {};
}
}
async function readResponseBytes(
response: Response,
maxBytes: number,
signal: AbortSignal,
): Promise<Uint8Array> {
const declared = Number(response.headers.get("content-length") ?? 0);
if (Number.isFinite(declared) && declared > maxBytes) {
cancelResponse(response);
throw new Error("Voice response is too large.");
}
return readBoundedResponseBytes(response, {
maxBytes,
tooLargeMessage: "Voice response is too large.",
read: (operation) => withAbort(operation(), signal),
});
}
function withAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(signal.reason ?? new Error("Voice request aborted."));
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(signal.reason ?? new Error("Voice request aborted."));
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(error: unknown) => {
signal.removeEventListener("abort", onAbort);
reject(error);
},
);
});
}
function cancelResponse(response: Response): void {
try {
void Promise.resolve(response.body?.cancel()).catch(() => undefined);
} catch {
// Response cleanup is best-effort.
}
}