UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

398 lines (397 loc) • 21.1 kB
import { once } from "node:events"; import { Agent as HttpAgent, request as httpRequest } from "node:http"; import { Agent as HttpsAgent, request as httpsRequest } from "node:https"; import { Readable } from "node:stream"; import { z } from "zod"; import { BridgeError } from "./protocol.js"; import { readSpeechKey } from "./speech-key.js"; import { FALLBACK_SPEECH_MODEL, resolveSpeechModel, resolveSpeechRegion, resolveSpeechVoice, voiceId } from "./speech-voice.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 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 const SPEECH_FORMATS = ["pcm_44100", "mp3_44100_192", "mp3_44100_128", "pcm_24000"]; const BASE_FORMAT = "pcm_24000"; /** 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 const SPEECH_FORMAT_INFO = { pcm_44100: { audio: "pcm_s16le;rate=44100;channels=1", sampleRate: 44_100, contentType: "application/octet-stream" }, mp3_44100_192: { audio: "mp3;rate=44100;bitrate=192000;channels=1", sampleRate: 44_100, contentType: "audio/mpeg" }, mp3_44100_128: { audio: "mp3;rate=44100;bitrate=128000;channels=1", sampleRate: 44_100, contentType: "audio/mpeg" }, pcm_24000: { audio: SPEECH_AUDIO, sampleRate: 24_000, contentType: "application/octet-stream" }, }; /** 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 const SPEECH_SLOW_MS = 1_500; /** Only replies this short are timed: a long one is slow for its length, not * because the model is. */ const TIMED_TEXT = 200; /** How long a failing or slow model, or a refused format, is set aside. */ export const FALLBACK_HOLD_MS = 10 * 60_000; const FORMAT_HOLD_MS = 6 * 60 * 60_000; const MAX_TEXT = 2_000; export const speechRequest = z.object({ text: z.string().trim().min(1).max(MAX_TEXT), /** Answer JSON with the audio and when each character is spoken, so the * phone can highlight the word being read (talk mode's karaoke). */ timestamps: z.boolean().optional(), /** With `timestamps`, stream the audio and alignment as ElevenLabs makes * them, one JSON line per chunk, instead of one JSON reply once the whole * sentence is voiced (about 0.6 s later with v4 Turbo). */ stream: z.boolean().optional(), /** A voice the phone picked; otherwise this computer's setting. */ voice: voiceId.optional(), /** The output formats the phone plays (`SPEECH_FORMATS` names). Without * it, pcm_24000, which older phones assume. Names this Hook doesn't know * (a newer phone's) are dropped before validation, never refused. */ formats: z.preprocess(value => Array.isArray(value) ? [...new Set(value)].filter(name => SPEECH_FORMATS.includes(name)) : undefined, z.array(z.enum(SPEECH_FORMATS)).max(SPEECH_FORMATS.length).optional()), }); /** 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 class SpeechState { now; refused = new Map(); benched = new Map(); latency = new Map(); constructor(now = Date.now) { this.now = now; } formatAllowed(format) { return (this.refused.get(format) ?? 0) <= this.now(); } refuseFormat(format) { this.refused.set(format, this.now() + FORMAT_HOLD_MS); } benchedReason(model) { const entry = this.benched.get(model); return entry && entry.until > this.now() ? entry.reason : undefined; } bench(model, reason) { this.benched.set(model, { until: this.now() + FALLBACK_HOLD_MS, reason }); this.latency.delete(model); } /** 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, ms) { const samples = [...(this.latency.get(model) ?? []), ms].slice(-3); this.latency.set(model, samples); const median = [...samples].sort((a, b) => a - b)[Math.floor(samples.length / 2)]; if (samples.length === 3 && median > SPEECH_SLOW_MS) this.bench(model, "slow"); } } /** The Hook's own: /v1/speech and /v1/speech/live learn together. */ export const sharedSpeechState = new SpeechState(); /** Idle connections to ElevenLabs are kept this long, so the next sentence's * request skips the TCP and TLS handshake. fetch's own pool lets them go * after four seconds, shorter than talk mode's pause between replies. */ const KEEP_ALIVE_MS = 60_000; let pool; /** 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 const SPEECH_STALL_MS = 30_000; /** 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 function elevenLabsFetch(url, init, stallMs = SPEECH_STALL_MS) { const proxyEnv = process.env.NODE_USE_ENV_PROXY === "1" ? process.env : undefined; pool ??= { http: new HttpAgent({ keepAlive: true, timeout: KEEP_ALIVE_MS, proxyEnv }), https: new HttpsAgent({ keepAlive: true, timeout: KEEP_ALIVE_MS, proxyEnv }), }; const target = new URL(url), secure = target.protocol === "https:"; const body = typeof init.body === "string" ? init.body : ""; const attempt = (retried) => new Promise((resolve, reject) => { let answered = false; const request = (secure ? httpsRequest : httpRequest)(target, { method: init.method ?? "GET", signal: init.signal ?? undefined, agent: secure ? pool.https : pool.http, headers: { ...init.headers, "Content-Length": String(Buffer.byteLength(body)) }, }, upstream => { answered = true; const status = upstream.statusCode ?? 0; if (status < 200 || status > 599) { upstream.destroy(); reject(new Error(`HTTP ${status}`)); return; } // A Response with one of these statuses may not have a body. if (status === 204 || status === 205 || status === 304) { upstream.resume(); resolve(new Response(null, { status })); return; } resolve(new Response(Readable.toWeb(upstream), { status })); }); // Covers the wait for the headers and each gap in the body after them. request.setTimeout(stallMs, () => request.destroy(new Error("ElevenLabs stalled"))); request.on("error", error => { if (!retried && !answered && request.reusedSocket && error.code === "ECONNRESET") { resolve(attempt(true)); return; } reject(error); }); request.end(body); }); return attempt(false); } /** ElevenLabs' machine-readable reason (`detail.status`), never its text. */ async function upstreamCode(upstream) { try { return String((await upstream.json()).detail?.status ?? ""); } catch { return ""; } } /** A fixed message per ElevenLabs failure: its own response text is never * passed on. */ export async function speechError(upstream) { return speechErrorFor(upstream.status, await upstreamCode(upstream)); } function speechErrorFor(status, detail) { if (detail === "quota_exceeded") return new BridgeError(402, "The ElevenLabs quota on this computer's account is used up.", { code: "speech-quota" }); switch (status) { case 401: case 403: return new BridgeError(502, "ElevenLabs refused this computer's key.", { code: "speech-rejected" }); case 404: return new BridgeError(502, "ElevenLabs doesn't know the configured voice.", { code: "speech-voice" }); case 400: case 422: return new BridgeError(400, "ElevenLabs couldn't voice this text.", { code: "speech-invalid" }); case 429: return new BridgeError(429, "ElevenLabs is busy. Try again shortly.", { code: "speech-busy" }); default: return new BridgeError(502, `ElevenLabs failed (HTTP ${status}).`, { code: "speech-failed" }); } } /** 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 function speakableText(text) { return text .replace(/```[\s\S]*?(?:```|$)/g, " ") .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") .replace(/https?:\/\/\S+/g, "a link") .replace(/`([^`]*)`/g, "$1") .split(/\r?\n/) .map(line => { // A heading, bullet or table row ends where its line does; a // paragraph's soft-wrapped lines run on. const block = /^\s{0,3}(?:#{1,6}\s|[-*+]\s|\d+[.)]\s|\|)/.test(line); const words = line .replace(/^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/, "") .replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+(?:\[[ xX]\]\s+)?)/, "") .replace(/\s*\|\s*/g, (bar, offset, whole) => offset === 0 || offset + bar.length === whole.length ? "" : ", ") .replace(/(\*\*|__|~~)(.+?)\1/g, "$2") .replace(/(^|[^\w*])[*_](\S(?:.*?\S)?)[*_](?![\w*])/g, "$1$2") .trim(); return block && words && !/[.!?:;,]$/.test(words) ? `${words}.` : words; }) .filter(Boolean) .join(" ") .replace(/\s+/g, " ") .trim(); } /** Starts ElevenLabs' streaming synthesis and returns its body: the audio, * or with `stream/with-timestamps` JSON lines of audio and alignment. */ export async function synthesizeSpeech(text, signal, options = {}, endpoint = "stream") { const { upstream, model, format, started } = await elevenLabs(endpoint, text, signal, options); if (!upstream.body) throw await speechError(upstream); timed(text, model, started, options); return { body: upstream.body, model, format }; } /** Voices the text in one piece with ElevenLabs' character alignment. */ export async function synthesizeTimedSpeech(text, signal, options = {}) { const { upstream, model, format, started } = await elevenLabs("with-timestamps", text, signal, options); let body; try { body = (await upstream.json()); } catch { throw new BridgeError(502, "ElevenLabs sent an unreadable reply.", { code: "speech-failed" }); } if (typeof body.audio_base64 !== "string") throw new BridgeError(502, "ElevenLabs sent an unreadable reply.", { code: "speech-failed" }); // Talk mode plays nothing until this whole reply is in. timed(text, model, started, options); return { audio: body.audio_base64, alignment: alignmentOf(body.alignment), model, format }; } /** Times a short reply from the chosen model (never the fallback, which has * nothing to fall back to). */ function timed(text, model, started, options) { if (model === FALLBACK_SPEECH_MODEL || text.length > TIMED_TEXT) return; (options.state ?? sharedSpeechState).recordLatency(model, (options.now ?? Date.now)() - started); } /** ElevenLabs' alignment, kept only when its three lists line up. */ export function alignmentOf(raw) { const characters = raw?.characters, starts = raw?.character_start_times_seconds, ends = raw?.character_end_times_seconds; if (!Array.isArray(characters) || !Array.isArray(starts) || !Array.isArray(ends)) return null; if (characters.length !== starts.length || characters.length !== ends.length) return null; if (!characters.every(c => typeof c === "string") || ![...starts, ...ends].every(t => typeof t === "number" && Number.isFinite(t))) return null; return { characters: characters, starts: starts, ends: ends }; } /** ElevenLabs' streamed timestamps, one JSON object per line, as the phone's * lines: `{audio, alignment}`, the alignment's times in seconds from the * start of the whole reply (as ElevenLabs counts them) and null for a chunk * with no characters. A chunk without audio is dropped. */ async function* timedLines(body) { const decoder = new TextDecoder(); let pending = ""; for await (const bytes of body) { pending += decoder.decode(bytes, { stream: true }); const lines = pending.split("\n"); pending = lines.pop(); for (const line of lines) { const out = timedLine(line); if (out) yield out; } } const out = timedLine(pending + decoder.decode()); if (out) yield out; } function timedLine(line) { if (!line.trim()) return undefined; const raw = JSON.parse(line); if (typeof raw.audio_base64 !== "string" || !raw.audio_base64) return undefined; const alignment = alignmentOf(raw.alignment); return JSON.stringify({ audio: raw.audio_base64, alignment: alignment?.characters.length ? alignment : null }) + "\n"; } /** The plan doesn't allow this output format (Creator: "Output format * 'pcm_44100' is only available on the Pro tier and above", 403 * `output_format_not_allowed`). */ function formatRefused(status, code) { return (status === 400 || status === 403 || status === 422) && /output_format/.test(code); } /** A failure another model may not have: the model itself refused or * unknown, or ElevenLabs erroring. Not the key, quota, voice or rate limit. */ function modelFailed(status, code) { if (status === 429 || code === "quota_exceeded" || code === "invalid_api_key") return false; return status >= 500 || /model/.test(code); } /** Asks ElevenLabs for the best format the phone plays and the plan allows, * with the chosen model, then Flash v2.5 when that model fails or has been * slow. A refused format is skipped for FORMAT_HOLD_MS, a failed or slow * model for FALLBACK_HOLD_MS. */ async function elevenLabs(endpoint, text, signal, options) { const key = await (options.key ?? readSpeechKey)(); if (!key) throw new BridgeError(503, "Spoken replies aren't set up on this computer: it has no ElevenLabs key.", { code: "speech-unconfigured" }); const { voice } = await resolveSpeechVoice(options.voice); const state = options.state ?? sharedSpeechState, now = options.now ?? Date.now; const chosen = options.model ?? (await resolveSpeechModel()).model; const origin = options.origin ?? (await resolveSpeechRegion()).origin; const models = chosen === FALLBACK_SPEECH_MODEL ? [chosen] : state.benchedReason(chosen) ? [FALLBACK_SPEECH_MODEL] : [chosen, FALLBACK_SPEECH_MODEL]; const accepted = new Set(options.formats ?? []); let failure; models: for (const model of models) { // Per model, so a format refused under one isn't tried again under the next. const formats = SPEECH_FORMATS.filter(format => format === BASE_FORMAT || (accepted.has(format) && state.formatAllowed(format))); for (const format of formats) { const url = `${origin}/v1/text-to-speech/${encodeURIComponent(voice)}/${endpoint}?output_format=${format}`; const started = now(); let upstream; try { upstream = await (options.fetch ?? elevenLabsFetch)(url, { method: "POST", signal, headers: { "xi-api-key": key, "Content-Type": "application/json" }, body: JSON.stringify({ text, model_id: model, voice_settings: { stability: 0.6, similarity_boost: 0.75 } }), }); } catch { throw new BridgeError(502, "Couldn't reach ElevenLabs from this computer.", { code: "speech-unreachable" }); } if (upstream.ok) return { upstream, model, format, started }; const code = await upstreamCode(upstream); if (format !== BASE_FORMAT && formatRefused(upstream.status, code)) { state.refuseFormat(format); continue; } failure = speechErrorFor(upstream.status, code); if (model !== FALLBACK_SPEECH_MODEL && modelFailed(upstream.status, code)) { state.bench(model, "failed"); continue models; } throw failure; } } throw failure ?? new BridgeError(502, "ElevenLabs failed.", { code: "speech-failed" }); } /** 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 async function streamSpeech(data, response, options = {}) { const request = speechRequest.parse(data); const text = speakableText(request.text); if (!text) throw new BridgeError(400, "There is nothing to say in this reply.", { code: "speech-invalid" }); // With timestamps, the alignment covers these spoken words, not the markdown. const { timestamps } = request; if (request.voice) options = { ...options, voice: request.voice }; options = { ...options, formats: request.formats ?? [] }; const abort = new AbortController(); response.once("close", () => { if (!response.writableEnded) abort.abort(); }); if (timestamps && !request.stream) { const { audio, alignment, model, format } = await synthesizeTimedSpeech(text, abort.signal, options); const info = SPEECH_FORMAT_INFO[format]; response.statusCode = 200; response.setHeader("Content-Type", "application/json"); response.end(JSON.stringify({ audio, audioFormat: info.audio, sampleRate: info.sampleRate, format, model, alignment })); return; } const { body: audio, model, format } = await synthesizeSpeech(text, abort.signal, options, timestamps ? "stream/with-timestamps" : "stream"); const info = SPEECH_FORMAT_INFO[format]; response.statusCode = 200; response.setHeader("Content-Type", timestamps ? "application/x-ndjson" : info.contentType); response.setHeader("X-Phren-Audio", info.audio); response.setHeader("X-Phren-Audio-Rate", String(info.sampleRate)); response.setHeader("X-Phren-Speech-Model", model); // The phone sets up its player from these headers while the first audio is still on its way. response.flushHeaders(); try { for await (const chunk of timestamps ? timedLines(audio) : audio) { if (!response.write(chunk)) await once(response, "drain", { signal: abort.signal }); } response.end(); } catch { response.destroy(); } }