@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
143 lines (142 loc) • 7.3 kB
JavaScript
import { WebSocket } from "ws";
import { readSpeechKey } from "./speech-key.js";
import { resolveSpeechRegion, SPEECH_REGIONS } from "./speech-voice.js";
/** Dictation through ElevenLabs Scribe v2 Realtime, for the phone's opt-in
* Scribe input. The phone streams 16 kHz mono PCM as binary frames; this
* computer forwards it to ElevenLabs with its own key, which never leaves
* here, and sends back `{type: "partial"|"committed", text}` frames. Errors
* are fixed messages by code: ElevenLabs' own text is never passed on. No
* close is silent: `{type: "ready"}` says ElevenLabs answered, and every close
* the phone did not ask for follows an `error` frame or `{type: "end", reason}`
* (`session-limit` after ten minutes, `upstream-closed` when ElevenLabs hung
* up), so the phone reopens or reports without guessing from run length. */
export const TRANSCRIBE_MODEL = "scribe_v2_realtime";
export const TRANSCRIBE_RATE = 16_000;
const ENDPOINT = "/v1/speech-to-text/realtime";
/** Ten minutes of dictation per socket; the phone opens a fresh one after. */
const MAX_SESSION_MS = 10 * 60_000;
/** Audio held while the upstream socket opens: about eight seconds. */
const MAX_PENDING_BYTES = TRANSCRIBE_RATE * 2 * 8;
const MAX_KEYTERMS = 50;
const OPEN = 1;
/** The upstream URL in the given region: VAD commits, the phone's language
* and its vocabulary as keyterms. */
export function transcribeURL(query, origin = SPEECH_REGIONS.global) {
const url = new URL(ENDPOINT, origin.replace(/^http/, "ws"));
url.searchParams.set("model_id", TRANSCRIBE_MODEL);
url.searchParams.set("audio_format", "pcm_16000");
url.searchParams.set("commit_strategy", "vad");
const language = query.get("language") ?? "";
if (/^[a-z]{2,3}$/.test(language))
url.searchParams.set("language_code", language);
const terms = query.getAll("keyterm").map(term => term.trim()).filter(term => term.length > 0 && term.length <= 50);
for (const term of [...new Set(terms)].slice(0, MAX_KEYTERMS))
url.searchParams.append("keyterms", term);
return url.toString();
}
const ERRORS = {
auth_error: { code: "transcribe-rejected", message: "ElevenLabs refused this computer's key." },
quota_exceeded: { code: "transcribe-quota", message: "The ElevenLabs quota on this computer's account is used up." },
rate_limited: { code: "transcribe-busy", message: "ElevenLabs is busy. Try again shortly." },
resource_exhausted: { code: "transcribe-busy", message: "ElevenLabs is busy. Try again shortly." },
queue_overflow: { code: "transcribe-busy", message: "ElevenLabs is busy. Try again shortly." },
session_time_limit_exceeded: { code: "transcribe-limit", message: "This dictation reached ElevenLabs' time limit." },
};
/** Ends a socket whose relay threw before it could start (no key read, no
* region, no upstream socket), with the reason first. */
export function failRelay(client) {
if (client.readyState === OPEN)
client.send(JSON.stringify({ type: "error", code: "transcribe-failed", error: "Couldn't reach ElevenLabs." }));
client.close(1011, "Transcription unavailable");
}
export function relayTranscription(client, query, options = {}) {
const connect = options.connect ?? ((url, key) => new WebSocket(url, { headers: { "xi-api-key": key } }));
return (async () => {
const key = await (options.key ?? readSpeechKey)();
const tell = (frame) => { if (client.readyState === OPEN)
client.send(JSON.stringify(frame)); };
if (!key) {
tell({ type: "error", code: "transcribe-unconfigured", error: "This computer has no ElevenLabs key." });
client.close(1008, "No ElevenLabs key");
return;
}
const origin = options.origin ?? (await resolveSpeechRegion()).origin;
const upstream = connect(transcribeURL(query, origin), key);
let pending = [], pendingBytes = 0, closed = false;
const finish = (code = 1000, reason = "") => {
if (closed)
return;
closed = true;
clearTimeout(limit);
try {
upstream.close();
}
catch { /* already closed */ }
if (client.readyState === OPEN)
client.close(code, reason);
};
const end = (reason) => { if (!closed)
tell({ type: "end", reason }); };
const limit = setTimeout(() => { end("session-limit"); finish(1000, "Session limit"); }, options.maxSessionMs ?? MAX_SESSION_MS);
limit.unref?.();
const chunk = (audio, commit = false) => JSON.stringify({
message_type: "input_audio_chunk", audio_base_64: audio.toString("base64"), commit, sample_rate: TRANSCRIBE_RATE
});
client.on("message", (data, isBinary) => {
if (closed)
return;
if (!isBinary) {
// The phone's only text frame: the person stopped, commit what's left.
if (upstream.readyState === OPEN)
upstream.send(chunk(Buffer.alloc(0), true));
return;
}
if (upstream.readyState === OPEN) {
upstream.send(chunk(data));
return;
}
pendingBytes += data.length;
if (pendingBytes > MAX_PENDING_BYTES) {
tell({ type: "error", code: "transcribe-failed", error: "ElevenLabs didn't answer." });
finish(1011);
return;
}
pending.push(data);
});
client.on("close", () => finish());
client.on("error", () => finish());
upstream.on("open", () => {
for (const audio of pending)
upstream.send(chunk(audio));
pending = [];
pendingBytes = 0;
if (!closed)
tell({ type: "ready" });
});
upstream.on("message", data => {
let message;
try {
message = JSON.parse(data.toString("utf8"));
}
catch {
return;
}
const type = String(message.message_type ?? "");
const text = typeof message.text === "string" ? message.text : "";
if (type === "partial_transcript")
tell({ type: "partial", text });
else if (type === "committed_transcript")
tell({ type: "committed", text });
else if (type in ERRORS || /error|exceeded|invalid|throttled/.test(type)) {
const known = ERRORS[type] ?? { code: "transcribe-failed", message: "ElevenLabs couldn't transcribe this audio." };
// Too little speech is not a failure worth stopping for.
if (type === "insufficient_audio_activity" || type === "commit_throttled")
return;
tell({ type: "error", code: known.code, error: known.message });
finish(1011);
}
});
upstream.on("error", () => { tell({ type: "error", code: "transcribe-failed", error: "Couldn't reach ElevenLabs." }); finish(1011); });
upstream.on("close", () => { end("upstream-closed"); finish(1000, "ElevenLabs closed"); });
})();
}