@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
143 lines (142 loc) • 7.79 kB
TypeScript
import { type ServerResponse } from "node:http";
import { z } from "zod";
import { BridgeError, type Json } from "./protocol.js";
export { DEFAULT_SPEECH_MODEL, DEFAULT_SPEECH_VOICE, FALLBACK_SPEECH_MODEL } from "./speech-voice.js";
/** Spoken replies for the phone's talk mode. The phone sends a sentence; the
* Hook voices it with ElevenLabs and streams the audio back. The API key is
* read on this computer, used only in the request to ElevenLabs, and never
* appears in a response, including errors. */
/** What a phone that names no `formats` gets, as every phone did before
* `speechFormats`: raw signed 16-bit little-endian mono PCM at 24 kHz, which
* it queues straight into its audio engine without decoding. */
export declare const SPEECH_AUDIO = "pcm_s16le;rate=24000;channels=1";
/** ElevenLabs output formats the Hook can serve, best first. Each account
* plan allows some of them (pcm_44100 is Pro and above, mp3_44100_192 is
* Creator and above); the Hook learns which from ElevenLabs' refusal rather
* than a hard-coded plan table. pcm_24000 works on every plan and every
* phone, so it is the base every request falls back to. */
export declare const SPEECH_FORMATS: readonly ["pcm_44100", "mp3_44100_192", "mp3_44100_128", "pcm_24000"];
export type SpeechFormat = typeof SPEECH_FORMATS[number];
/** How each format is described to the phone: `audioFormat` in the JSON reply
* and `X-Phren-Audio` on the streamed one, plus the sample rate on its own. */
export declare const SPEECH_FORMAT_INFO: Record<SpeechFormat, {
audio: string;
sampleRate: number;
contentType: string;
}>;
/** Talk mode waits for the whole sentence on the timestamped path, so a model
* whose recent replies took longer than this to start playing is set aside
* for FALLBACK_HOLD_MS. Measured 2026-09-28 on a 95-character reply with the
* owner's voice: v4 Turbo ~200-400 ms to first streamed byte and ~1.1 s for
* the timestamped reply; Flash v2.5 ~180-460 ms and ~0.4 s. 1.5 s leaves v4
* Turbo headroom on a normal day and catches a slow one. */
export declare const SPEECH_SLOW_MS = 1500;
/** How long a failing or slow model, or a refused format, is set aside. */
export declare const FALLBACK_HOLD_MS: number;
export declare const speechRequest: z.ZodObject<{
text: z.ZodString;
timestamps: z.ZodOptional<z.ZodBoolean>;
stream: z.ZodOptional<z.ZodBoolean>;
voice: z.ZodOptional<z.ZodString>;
formats: z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodEnum<{
mp3_44100_128: "mp3_44100_128";
mp3_44100_192: "mp3_44100_192";
pcm_24000: "pcm_24000";
pcm_44100: "pcm_44100";
}>>>, unknown>;
}, z.core.$strip>;
/** What the Hook learned about this account: formats its plan refused and
* models set aside for failing or being slow, each until a time, and the
* recent start-of-audio times per model. One per Hook process. */
export declare class SpeechState {
private readonly now;
private readonly refused;
private readonly benched;
private readonly latency;
constructor(now?: () => number);
formatAllowed(format: SpeechFormat): boolean;
refuseFormat(format: SpeechFormat): void;
benchedReason(model: string): "failed" | "slow" | undefined;
bench(model: string, reason: "failed" | "slow"): void;
/** Records how long a reply took to start; benches the model when the median
* of its last three is over SPEECH_SLOW_MS. */
recordLatency(model: string, ms: number): void;
}
/** The Hook's own: /v1/speech and /v1/speech/live learn together. */
export declare const sharedSpeechState: SpeechState;
/** When each character of the voiced text starts and ends, in seconds from
* the start of the audio. */
export interface SpeechAlignment {
characters: string[];
starts: number[];
ends: number[];
}
export interface SpeechOptions {
fetch?: typeof fetch;
/** Resolves the ElevenLabs key; defaults to ELEVENLABS_API_KEY, then the stored key (speech-key.ts). */
key?: () => Promise<string | undefined>;
voice?: string;
/** Formats the phone plays, from the request; pcm_24000 always. */
formats?: readonly string[];
/** Defaults to this computer's setting (speech-voice.ts). */
model?: string;
/** Where ElevenLabs is, e.g. https://api.elevenlabs.io. Defaults to this
* computer's region setting (speech-voice.ts). */
origin?: string;
/** Defaults to the Hook's own. */
state?: SpeechState;
now?: () => number;
}
/** What was voiced: the model and format actually used. */
export interface SpeechResult {
model: string;
format: SpeechFormat;
}
/** How long ElevenLabs may send nothing, before its headers or between two
* chunks of audio, before the request is given up on: fetch's own limits were
* 300 s, and the pool's timeout only closes idle sockets. */
export declare const SPEECH_STALL_MS = 30000;
/** POSTs to ElevenLabs over the Hook's kept-alive pool and answers a fetch
* Response whose body is the upstream socket, so audio is passed on chunk by
* chunk as it arrives. Honours HTTPS_PROXY as fetch does, when
* NODE_USE_ENV_PROXY is set. A pooled socket ElevenLabs closed while it sat
* idle fails on its first write; that request is sent once more on a fresh
* one. */
export declare function elevenLabsFetch(url: string, init: RequestInit, stallMs?: number): Promise<Response>;
/** A fixed message per ElevenLabs failure: its own response text is never
* passed on. */
export declare function speechError(upstream: Response): Promise<BridgeError>;
/** A reply as it should sound: markdown the agent wrote for the chat bubble
* (emphasis, headings, bullets, links, inline code, tables) is read aloud
* literally by ElevenLabs, so it is reduced to its words. A code block is
* skipped, a link keeps its text and a bare URL becomes "a link". Empty when
* nothing speakable is left. */
export declare function speakableText(text: string): string;
/** Starts ElevenLabs' streaming synthesis and returns its body: the audio,
* or with `stream/with-timestamps` JSON lines of audio and alignment. */
export declare function synthesizeSpeech(text: string, signal: AbortSignal, options?: SpeechOptions, endpoint?: "stream" | "stream/with-timestamps"): Promise<SpeechResult & {
body: ReadableStream<Uint8Array>;
}>;
/** Voices the text in one piece with ElevenLabs' character alignment. */
export declare function synthesizeTimedSpeech(text: string, signal: AbortSignal, options?: SpeechOptions): Promise<SpeechResult & {
audio: string;
alignment: SpeechAlignment | null;
}>;
/** ElevenLabs' alignment, kept only when its three lists line up. */
export declare function alignmentOf(raw: {
characters?: unknown;
character_start_times_seconds?: unknown;
character_end_times_seconds?: unknown;
} | null | undefined): SpeechAlignment | null;
/** POST /v1/speech: writes the audio to the phone as ElevenLabs produces it.
* With `timestamps`, answers JSON instead: `{ audio, audioFormat, sampleRate,
* format, model, alignment }`, the audio base64 in the format served and the
* alignment null when ElevenLabs sent none. The streamed reply names its
* format in `X-Phren-Audio` and `X-Phren-Audio-Rate`. A phone that sends no
* `formats` always gets pcm_24000. With `timestamps` and `stream`, the
* streamed reply is `application/x-ndjson`, one `{audio, alignment}` line per
* chunk (see timedLines), with the same headers.
* Failures before the first byte are thrown for the route's JSON error; a
* failure mid-stream cuts the response off, which the phone treats as an
* error. The phone hanging up cancels the ElevenLabs request. */
export declare function streamSpeech(data: Json, response: ServerResponse, options?: SpeechOptions): Promise<void>;