318 lines · 9.6 KB · TypeScript
RawDownload
import { Schema } from "effect";import {  CHATTERBOX_BACKEND,  CHATTERBOX_MODEL_REVISION,  CHATTERBOX_PACKAGE_VERSION,  type SpeechStatus,  type SpeechVoiceProfile,} from "@local-studio/contracts/speech";import type { ApiCore } from "./core"; const SpeechInstallPhaseSchema = Schema.Union([  Schema.Literal("missing"),  Schema.Literal("installing"),  Schema.Literal("ready"),  Schema.Literal("failed"),]); const SpeechWorkerPhaseSchema = Schema.Union([  Schema.Literal("stopped"),  Schema.Literal("starting"),  Schema.Literal("ready"),  Schema.Literal("busy"),  Schema.Literal("failed"),]); export const SpeechVoiceProfileSchema = Schema.Struct({  id: Schema.String,  name: Schema.String,  duration_ms: Schema.Number,  created_at: Schema.String,}); export const SpeechStatusSchema = Schema.Struct({  backend: Schema.Literal(CHATTERBOX_BACKEND),  package_version: Schema.Literal(CHATTERBOX_PACKAGE_VERSION),  model_revision: Schema.Literal(CHATTERBOX_MODEL_REVISION),  install: Schema.Struct({    phase: SpeechInstallPhaseSchema,    progress: Schema.Number,    message: Schema.String,    error: Schema.NullOr(Schema.String),  }),  worker: Schema.Struct({    phase: SpeechWorkerPhaseSchema,    queue_depth: Schema.Number,    error: Schema.NullOr(Schema.String),  }),  gpu: Schema.NullOr(    Schema.Struct({      uuid: Schema.String,      name: Schema.String,      pci_bus_id: Schema.optional(Schema.String),    }),  ),  prerequisites: Schema.Struct({    ffmpeg: Schema.Boolean,    python_311: Schema.Boolean,    storage: Schema.Struct({      available_bytes: Schema.NullOr(Schema.Number),      required_bytes: Schema.Number,      ready: Schema.Boolean,    }),  }),  voice_count: Schema.Number,}); const SpeechStatusResponseSchema = Schema.Struct({ status: SpeechStatusSchema });const SpeechVoicesResponseSchema = Schema.Struct({  voices: Schema.Array(SpeechVoiceProfileSchema),});const SpeechVoiceResponseSchema = Schema.Struct({ voice: SpeechVoiceProfileSchema });const ErrorResponseSchema = Schema.Struct({  code: Schema.optional(Schema.String),  error: Schema.optional(Schema.String),  detail: Schema.optional(Schema.String),  message: Schema.optional(Schema.String),}); const MAX_REFERENCE_BYTES = 20 * 1024 * 1024;const MAX_PREVIEW_BYTES = 20 * 1024 * 1024;const VOICE_UPLOAD_TIMEOUT_MS = 130_000;const SPEECH_PREVIEW_TIMEOUT_MS = 370_000; export class SpeechApiError extends Error {  constructor(    readonly status: number,    readonly code: string | null,    message: string,  ) {    super(message);    this.name = "SpeechApiError";  }} function decodeStatus(input: unknown): SpeechStatus {  return Schema.decodeUnknownSync(SpeechStatusResponseSchema)(input).status;} function decodeVoices(input: unknown): readonly SpeechVoiceProfile[] {  return Schema.decodeUnknownSync(SpeechVoicesResponseSchema)(input).voices;} function decodeVoice(input: unknown): SpeechVoiceProfile {  return Schema.decodeUnknownSync(SpeechVoiceResponseSchema)(input).voice;} function responseError(input: unknown, status: number): Error {  try {    const body = Schema.decodeUnknownSync(ErrorResponseSchema)(input);    return new SpeechApiError(      status,      body.code ?? null,      body.error ?? body.detail ?? body.message ?? `Request failed (${status})`,    );  } catch {    return new SpeechApiError(status, null, `Request failed (${status})`);  }} async function checkedResponse(response: Response): Promise<Response> {  if (response.ok) return response;  const body: unknown = await response.json().catch(() => null);  throw responseError(body, response.status);} async function statusResponse(response: Response): Promise<SpeechStatus> {  return decodeStatus(await (await checkedResponse(response)).json());} async function timedFetch(  url: string,  init: RequestInit,  timeoutMs: number,  label: string,  signal?: AbortSignal,): Promise<Response> {  try {    const timeout = AbortSignal.timeout(timeoutMs);    return await fetch(url, {      ...init,      signal: signal ? AbortSignal.any([signal, timeout]) : timeout,    });  } catch (error) {    if (error instanceof DOMException && error.name === "TimeoutError") {      throw new Error(`${label} timed out`);    }    throw error;  }} function multipartHeaders(core: ApiCore): Record<string, string> {  const headers = core.buildHeaders();  for (const key of Object.keys(headers)) {    if (key.toLowerCase() === "content-type") delete headers[key];  }  return headers;} async function readBoundedAudio(response: Response): Promise<Blob> {  const length = Number(response.headers.get("content-length") ?? 0);  if (Number.isFinite(length) && length > MAX_PREVIEW_BYTES) {    throw new Error("Speech preview exceeded the 20 MB response limit");  }  if (!response.body) throw new Error("Speech preview returned no audio");  const reader = response.body.getReader();  const chunks: Uint8Array[] = [];  let size = 0;  while (true) {    const result = await reader.read();    if (result.done) break;    size += result.value.byteLength;    if (size > MAX_PREVIEW_BYTES) {      await reader.cancel();      throw new Error("Speech preview exceeded the 20 MB response limit");    }    chunks.push(result.value);  }  if (size < 44) throw new Error("Speech preview returned invalid WAV audio");  const buffer = new ArrayBuffer(size);  const bytes = new Uint8Array(buffer);  let offset = 0;  for (const chunk of chunks) {    bytes.set(chunk, offset);    offset += chunk.byteLength;  }  const signature = String.fromCharCode(...bytes.subarray(0, 4));  const format = String.fromCharCode(...bytes.subarray(8, 12));  if (signature !== "RIFF" || format !== "WAVE") {    throw new Error("Speech preview returned invalid WAV audio");  }  return new Blob([buffer], { type: "audio/wav" });} export function createSpeechApi(core: ApiCore) {  return {    getSpeechStatus: async (): Promise<SpeechStatus> =>      decodeStatus(        await core.request<unknown>("/v1/audio/status", {          cache: "no-store",          retries: 0,        }),      ),    installSpeechRuntime: async (      input: {        repair?: boolean;        signal?: AbortSignal;      } = {},    ): Promise<SpeechStatus> =>      statusResponse(        await timedFetch(          core.buildUrl("/v1/audio/install"),          {            method: "POST",            headers: core.buildHeaders(),            body: JSON.stringify({ repair: input.repair ?? false }),            credentials: "include",          },          25_000,          "Voice runtime setup",          input.signal,        ),      ),    listSpeechVoices: async (): Promise<readonly SpeechVoiceProfile[]> =>      decodeVoices(        await core.request<unknown>("/v1/audio/voices", {          cache: "no-store",          retries: 0,        }),      ),    createSpeechVoice: async (input: {      name: string;      consent: "self_voice_v1";      reference: File;      signal?: AbortSignal;    }): Promise<SpeechVoiceProfile> => {      if (!input.reference.size) throw new Error("Voice reference is empty");      if (input.reference.size > MAX_REFERENCE_BYTES) {        throw new Error("Voice reference must be 20 MB or smaller");      }      const form = new FormData();      form.set("name", input.name);      form.set("consent", input.consent);      form.set("reference", input.reference, input.reference.name);      const response = await checkedResponse(        await timedFetch(          core.buildUrl("/v1/audio/voices"),          {            method: "POST",            headers: multipartHeaders(core),            body: form,            credentials: "include",          },          VOICE_UPLOAD_TIMEOUT_MS,          "Voice reference upload",          input.signal,        ),      );      return decodeVoice(await response.json());    },    deleteSpeechVoice: async (voiceId: string, signal?: AbortSignal): Promise<void> => {      await checkedResponse(        await timedFetch(          core.buildUrl(`/v1/audio/voices/${encodeURIComponent(voiceId)}`),          { method: "DELETE", headers: core.buildHeaders(), credentials: "include" },          15_000,          "Voice profile deletion",          signal,        ),      );    },    synthesizeSpeechPreview: async (input: {      text: string;      voiceId: string;      signal?: AbortSignal;    }): Promise<Blob> => {      const response = await checkedResponse(        await timedFetch(          core.buildUrl("/v1/audio/speech"),          {            method: "POST",            headers: core.buildHeaders({ Accept: "audio/wav" }),            body: JSON.stringify({              model: CHATTERBOX_BACKEND,              input: input.text,              voice: input.voiceId,              response_format: "wav",            }),            credentials: "include",          },          SPEECH_PREVIEW_TIMEOUT_MS,          "Speech preview",          input.signal,        ),      );      return readBoundedAudio(response);    },    cancelSpeechInstall: async (signal?: AbortSignal): Promise<SpeechStatus> =>      statusResponse(        await timedFetch(          core.buildUrl("/v1/audio/install/cancel"),          { method: "POST", headers: core.buildHeaders(), credentials: "include" },          25_000,          "Voice runtime cancellation",          signal,        ),      ),    stopSpeechRuntime: async (signal?: AbortSignal): Promise<SpeechStatus> =>      statusResponse(        await timedFetch(          core.buildUrl("/v1/audio/runtime/stop"),          { method: "POST", headers: core.buildHeaders(), credentials: "include" },          25_000,          "Voice engine stop",          signal,        ),      ),  };}