* Pick the voice from a carousel in the app language, local voice only Voice settings now list only the voices that speak the app language (German shows German voices, English shows English ones) as a horizontal carousel: one card per voice, a play button next to the name, tap to make it the active voice. Changing the app language moves the saved voice to that language. The hosted voice providers (ElevenLabs, OpenAI, Cartesia, Fish Audio) are removed; Kokoro on the server is the only voice. E2E runs against a stub Kokoro server so the carousel is covered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57 * test(e2e): measure the workspace panel width after its transition settles Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57 --------- Co-authored-by: Claude <noreply@anthropic.com>
111 lines
3.7 KiB
TypeScript
111 lines
3.7 KiB
TypeScript
import { readBodyCapped } from "./web-ssrf.js";
|
|
|
|
/** A 2,000-character utterance should stay far below this, even at high MP3 bitrates. */
|
|
export const MAX_SYNTHESIZED_AUDIO_BYTES = 16 * 1024 * 1024;
|
|
export const MAX_VOICE_JSON_BYTES = 1024 * 1024;
|
|
|
|
/** Client disconnect plus a hard deadline — `context.signal` is always set. */
|
|
export function voiceDeadline(signal: AbortSignal, ms: number): AbortSignal {
|
|
return AbortSignal.any([signal, AbortSignal.timeout(ms)]);
|
|
}
|
|
|
|
export async function readVoiceAudio(res: Response, signal?: AbortSignal): Promise<Uint8Array> {
|
|
return readVoiceBytes(res, MAX_SYNTHESIZED_AUDIO_BYTES, signal);
|
|
}
|
|
|
|
async function readVoiceBytes(
|
|
res: Response,
|
|
maxBytes: number,
|
|
signal?: AbortSignal,
|
|
): Promise<Uint8Array> {
|
|
const declared = Number(res.headers?.get("content-length") ?? 0);
|
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
cancelResponseBody(res);
|
|
throw new Error("Voice response is too large.");
|
|
}
|
|
try {
|
|
return await readBodyCapped(res, maxBytes, signal);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === "Response is too large") {
|
|
throw new Error("Voice response is too large.");
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function cancelResponseBody(res: Response): void {
|
|
try {
|
|
void Promise.resolve(res.body?.cancel()).catch(() => undefined);
|
|
} catch {
|
|
// Provider response cleanup is best-effort and must not delay the size error.
|
|
}
|
|
}
|
|
|
|
export async function readVoiceJson(
|
|
res: Response,
|
|
options: { requireValid?: boolean } = {},
|
|
): Promise<unknown> {
|
|
let bytes: Uint8Array;
|
|
try {
|
|
bytes = await readVoiceBytes(res, MAX_VOICE_JSON_BYTES);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === "Voice response is too large.") throw error;
|
|
if (options.requireValid) throw new Error("Voice provider response could not be read.");
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(new TextDecoder().decode(bytes));
|
|
} catch {
|
|
if (options.requireValid) throw new Error("Voice provider returned invalid JSON.");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function detail(body: unknown): string {
|
|
if (!body || typeof body !== "object") return "";
|
|
const record = body as Record<string, unknown>;
|
|
if (typeof record.detail === "string" && record.detail.trim()) return record.detail.trim();
|
|
if (
|
|
record.detail &&
|
|
typeof record.detail === "object" &&
|
|
typeof (record.detail as { message?: unknown }).message === "string"
|
|
) {
|
|
return String((record.detail as { message: string }).message).trim();
|
|
}
|
|
if (typeof record.message === "string" && record.message.trim()) return record.message.trim();
|
|
if (typeof record.error === "string" && record.error.trim()) return record.error.trim();
|
|
if (
|
|
record.error &&
|
|
typeof record.error === "object" &&
|
|
typeof (record.error as { message?: unknown }).message === "string"
|
|
) {
|
|
return String((record.error as { message: string }).message).trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
export function voiceHttpError(
|
|
status: number,
|
|
provider: string,
|
|
what: string,
|
|
body: unknown,
|
|
): string {
|
|
const theirs = detail(body);
|
|
if (status === 401 || status === 403) {
|
|
return `${provider} rejected that key. Check the key and that it has speech permissions.`;
|
|
}
|
|
if (status === 429)
|
|
return theirs || `${provider} is rate-limiting this account. Wait a moment and try again.`;
|
|
if (status === 402) return theirs || `${provider} says this account is out of credit.`;
|
|
return theirs ? `${what} failed: ${theirs}` : `${what} failed (${status})`;
|
|
}
|
|
|
|
export async function requireOk(res: Response, provider: string, what: string): Promise<Response> {
|
|
if (res.ok) return res;
|
|
throw new Error(voiceHttpError(statusFor(res), provider, what, await readVoiceJson(res)));
|
|
}
|
|
|
|
function statusFor(res: Response) {
|
|
return res.status;
|
|
}
|