@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
151 lines (150 loc) • 7.68 kB
JavaScript
import { mkdir, readFile, rm } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { atomic, BridgeError, bridgeRoot } from "./protocol.js";
/** The ElevenLabs voice talk mode speaks with. A new computer uses the
* built-in default; `phren bridge speech-voice set <id>` stores another in
* the Hook's own directory, next to the speech key, where install and update
* leave it alone. The phone may name a voice per request, which wins. */
/** River: relaxed, neutral and informative, ElevenLabs' premade voice. */
export const DEFAULT_SPEECH_VOICE = "SAz9YHcvj6GT2YYXdXww";
/** The environment override from before the setting existed. It is read only
* when nothing is stored, and copied into the setting then. */
export const SPEECH_VOICE_ENV = "PHREN_SPEECH_VOICE";
/** An ElevenLabs voice id: letters and digits (20 today). */
export const voiceId = z.string().trim().regex(/^[A-Za-z0-9]{10,40}$/, "An ElevenLabs voice id is 10 to 40 letters and digits.");
export function speechVoiceFile() {
return path.join(bridgeRoot(), "speech.json");
}
/** Everything `speech.json` holds; a write keeps the keys it doesn't touch. */
async function readSettings(file) {
try {
const parsed = JSON.parse(await readFile(file, "utf8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
}
catch {
return {};
}
}
/** Sets or removes one key of `speech.json`; the file goes when it is empty. */
async function updateSettings(file, key, value) {
const settings = await readSettings(file);
if (value === undefined)
delete settings[key];
else
settings[key] = value;
if (!Object.keys(settings).length) {
await rm(file, { force: true });
return;
}
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
await atomic(file, JSON.stringify(settings) + "\n", 0o600);
}
export async function readStoredVoice(file = speechVoiceFile()) {
const parsed = voiceId.safeParse((await readSettings(file)).voice);
return parsed.success ? parsed.data : undefined;
}
export async function writeSpeechVoice(voice, file = speechVoiceFile()) {
const value = voiceId.parse(voice);
await updateSettings(file, "voice", value);
return value;
}
export async function clearSpeechVoice(file = speechVoiceFile()) {
await updateSettings(file, "voice", undefined);
}
/** The ElevenLabs model talk mode speaks with. It sits in the same
* `speech.json` as the voice (`phren bridge speech-model set <id>`), so
* install and update leave it alone too. Unset, it is v4 Turbo; speech.ts
* drops to Flash v2.5 on its own while the chosen model fails or is slow. */
export const DEFAULT_SPEECH_MODEL = "eleven_v4_turbo";
/** ElevenLabs' lowest-latency model: the fallback. */
export const FALLBACK_SPEECH_MODEL = "eleven_flash_v2_5";
/** An ElevenLabs model id, e.g. eleven_v4_turbo or eleven_flash_v2_5. */
export const speechModelId = z.string().trim().regex(/^[a-z0-9][a-z0-9_]{2,63}$/, "An ElevenLabs model id is 3 to 64 lowercase letters, digits and underscores, e.g. eleven_v4_turbo.");
export async function readStoredModel(file = speechVoiceFile()) {
const parsed = speechModelId.safeParse((await readSettings(file)).model);
return parsed.success ? parsed.data : undefined;
}
export async function writeSpeechModel(model, file = speechVoiceFile()) {
const value = speechModelId.parse(model);
await updateSettings(file, "model", value);
return value;
}
export async function clearSpeechModel(file = speechVoiceFile()) {
await updateSettings(file, "model", undefined);
}
/** The stored model, else v4 Turbo. Read on every reply, so a change needs
* no Hook restart. */
export async function resolveSpeechModel(file = speechVoiceFile()) {
const stored = await readStoredModel(file);
return stored ? { model: stored, source: "setting" } : { model: DEFAULT_SPEECH_MODEL, source: "default" };
}
/** Where this computer reaches ElevenLabs. `global` is api.elevenlabs.io,
* which routes to the nearest region; `us` is ElevenLabs' US-only endpoint.
* `phren bridge speech-region us|global` stores it in the same `speech.json`
* as the voice and model; unset is global. Read on every reply. */
export const SPEECH_REGIONS = { global: "https://api.elevenlabs.io", us: "https://api.us.elevenlabs.io" };
export const DEFAULT_SPEECH_REGION = "global";
export const speechRegion = z.enum(Object.keys(SPEECH_REGIONS));
export async function resolveSpeechRegion(file = speechVoiceFile()) {
const parsed = speechRegion.safeParse((await readSettings(file)).region);
const region = parsed.success ? parsed.data : DEFAULT_SPEECH_REGION;
return { region, origin: SPEECH_REGIONS[region], source: parsed.success ? "setting" : "default" };
}
/** Global is the default, so choosing it removes the key. */
export async function writeSpeechRegion(region, file = speechVoiceFile()) {
const value = speechRegion.parse(region);
await updateSettings(file, "region", value === DEFAULT_SPEECH_REGION ? undefined : value);
return value;
}
/** The voice to speak with: the request's own, the stored setting, the old
* environment override (copied into the setting on first use), else the
* default. */
export async function resolveSpeechVoice(requested, paths = {}) {
if (requested)
return { voice: voiceId.parse(requested), source: "request" };
const file = paths.file ?? speechVoiceFile();
const stored = await readStoredVoice(file);
if (stored)
return { voice: stored, source: "setting" };
const legacy = voiceId.safeParse((paths.env ?? process.env)[SPEECH_VOICE_ENV]);
if (legacy.success) {
await writeSpeechVoice(legacy.data, file).catch(() => { });
return { voice: legacy.data, source: "environment" };
}
return { voice: DEFAULT_SPEECH_VOICE, source: "default" };
}
/** The voices this computer's ElevenLabs account can speak with, for the
* phone's picker: id, name and a short description, never the account's
* other details. */
export async function listSpeechVoices(key, fetcher = fetch, signal, origin) {
let upstream;
try {
upstream = await fetcher(`${origin ?? (await resolveSpeechRegion()).origin}/v1/voices`, { headers: { "xi-api-key": key }, signal });
}
catch {
throw new BridgeError(502, "Couldn't reach ElevenLabs from this computer.", { code: "speech-unreachable" });
}
if (upstream.status === 401 || upstream.status === 403)
throw new BridgeError(502, "ElevenLabs refused this computer's key.", { code: "speech-rejected" });
if (!upstream.ok)
throw new BridgeError(502, `ElevenLabs failed (HTTP ${upstream.status}).`, { code: "speech-failed" });
let body;
try {
body = (await upstream.json());
}
catch {
throw new BridgeError(502, "ElevenLabs sent an unreadable reply.", { code: "speech-failed" });
}
const text = (value, max) => typeof value === "string" && value.trim() ? value.replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, max) : undefined;
const voices = [];
for (const raw of Array.isArray(body.voices) ? body.voices.slice(0, 500) : []) {
const item = raw;
const id = voiceId.safeParse(item.voice_id), name = text(item.name, 120);
if (!id.success || !name)
continue;
const category = text(item.category, 40), description = text(item.description, 240);
voices.push({ id: id.data, name, ...(category ? { category } : {}), ...(description ? { description } : {}) });
}
return voices.sort((a, b) => a.name.localeCompare(b.name));
}