Config, data and state are rooted at <project>/.agent/local/lembas (or .lembas/), kept out of git; a local directory the repository carries is refused. The user's logins and connections.yaml are read, never written; the user's config.yaml is not read. The TUI is started with TMPDIR inside it (the compiled binary unpacks its native library there) and OpenTUI's cache goes there too; the agent's commands keep the user's TMPDIR. An instance serves models, chat, speech and the balance only: the link, /remote, the service and the hub are off, deletes are not sent, and the server library, search, fetch, embeddings and personalization are not used. Self-update and uninstall are refused. hardline_disable is ignored, and tools treat the local directory as outside the project. The smoke test runs a whole local session in the compiled binary and checks that HOME, the XDG directories and TMPDIR gained nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
5.5 KiB
TypeScript
112 lines
5.5 KiB
TypeScript
// Speech to text and text to speech, over the providers voice.stt / voice.tts name. `webui` is a
|
|
// LLeMbas instance's own (OpenAI's shapes at <url>/v1, filled in by the loader); a half with a
|
|
// `fallback` uses it when its provider cannot be reached.
|
|
import { readFileSync, rmSync } from "node:fs"
|
|
import { join } from "node:path"
|
|
import type { Voice } from "../config/schema.ts"
|
|
import { expandHome, makeTempDir } from "../config/paths.ts"
|
|
import { runKeyCmd } from "../config/substitute.ts"
|
|
import { joinUrl, request, tlsFor } from "../provider/http.ts"
|
|
|
|
type Endpoint = { base_url?: string; api_key?: string; key_cmd?: string; tls?: { ca?: string; insecure?: boolean }; timeout?: number }
|
|
|
|
const keys = new Map<string, string>()
|
|
function keyOf(e: Endpoint, where: string): string | undefined {
|
|
if (e.api_key) return e.api_key
|
|
if (!e.key_cmd) return undefined
|
|
if (!keys.has(e.key_cmd)) keys.set(e.key_cmd, runKeyCmd(e.key_cmd, `${where}.key_cmd`))
|
|
return keys.get(e.key_cmd)
|
|
}
|
|
|
|
function endpoint(e: Endpoint, where: string): { base: string; headers: Record<string, string>; tls: ReturnType<typeof tlsFor>; timeoutMs: number } {
|
|
if (!e.base_url) throw new Error(`${where}.base_url is not set`)
|
|
const key = keyOf(e, where)
|
|
return { base: e.base_url, headers: key ? { authorization: `Bearer ${key}` } : {}, tls: tlsFor(e), timeoutMs: (e.timeout ?? 60) * 1000 }
|
|
}
|
|
|
|
type Stt = NonNullable<Voice["stt"]>
|
|
type Tts = NonNullable<Voice["tts"]>
|
|
|
|
/** The primary, then the fallback when the primary failed for any reason but a cancel. */
|
|
async function withFallback<T, H extends { fallback?: unknown }>(h: H, run: (half: H) => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
try {
|
|
return await run(h)
|
|
} catch (e) {
|
|
if (signal?.aborted || !h.fallback) throw e
|
|
return run(h.fallback as H)
|
|
}
|
|
}
|
|
|
|
/** What was said in a WAV. */
|
|
export async function transcribe(v: Voice | undefined, audio: Uint8Array, signal?: AbortSignal): Promise<string> {
|
|
const s = v?.stt
|
|
if (!s) throw new Error("voice input is not set up — add voice.stt to ~/.config/lembas/config.yaml")
|
|
return withFallback(s, (h) => transcribeWith(h, audio, signal), signal)
|
|
}
|
|
|
|
async function transcribeWith(s: Stt, audio: Uint8Array, signal?: AbortSignal): Promise<string> {
|
|
const e = endpoint(s, "voice.stt")
|
|
const form = new FormData()
|
|
form.append("file", new Blob([audio], { type: "audio/wav" }), "speech.wav")
|
|
form.append("response_format", "json")
|
|
if (s.language) form.append("language", s.language)
|
|
if (s.prompt) form.append("prompt", s.prompt)
|
|
let url: string
|
|
if ((s.provider ?? "openai") === "whispercpp") {
|
|
url = joinUrl(e.base, "inference")
|
|
form.append("temperature", "0.0")
|
|
form.append("temperature_inc", "0.2")
|
|
} else {
|
|
url = joinUrl(e.base, "audio/transcriptions")
|
|
// webui: the instance's own model, whatever is sent.
|
|
if (s.provider !== "webui" || s.model) form.append("model", s.model ?? "whisper-1")
|
|
}
|
|
const res = await request(url, { method: "POST", headers: e.headers, body: form, signal, timeoutMs: e.timeoutMs, tls: e.tls }, "voice.stt")
|
|
const body = await res.text()
|
|
try {
|
|
const j = JSON.parse(body)
|
|
return String(j.text ?? "").trim()
|
|
} catch {
|
|
return body.trim()
|
|
}
|
|
}
|
|
|
|
/** A WAV of `text` spoken. */
|
|
export async function synthesize(v: Voice | undefined, text: string, signal?: AbortSignal): Promise<Uint8Array> {
|
|
const t = v?.tts
|
|
if (!t) throw new Error("voice output is not set up — add voice.tts to ~/.config/lembas/config.yaml")
|
|
return withFallback(t, (h) => synthesizeWith(h, text, signal), signal)
|
|
}
|
|
|
|
async function synthesizeWith(t: Tts, text: string, signal?: AbortSignal): Promise<Uint8Array> {
|
|
const provider = t.provider ?? "openai"
|
|
if (provider === "piper-cli") {
|
|
if (!t.model_path) throw new Error("voice.tts.model_path (a piper .onnx voice) is not set")
|
|
const dir = makeTempDir("lembas-piper-")
|
|
const out = join(dir, "say.wav")
|
|
try {
|
|
const args = [t.command ?? "piper", "-m", expandHome(t.model_path), "-f", out, ...(t.speed && t.speed !== 1 ? ["--length-scale", String(1 / t.speed)] : [])]
|
|
const p = Bun.spawn(args, { stdin: new TextEncoder().encode(text), stdout: "ignore", stderr: "pipe" })
|
|
const onAbort = () => p.kill()
|
|
signal?.addEventListener("abort", onAbort, { once: true })
|
|
const [code, err] = await Promise.all([p.exited, new Response(p.stderr).text()])
|
|
signal?.removeEventListener("abort", onAbort)
|
|
if (code !== 0) throw new Error(`piper exited ${code}: ${err.trim().split("\n").slice(-2).join(" ")}`)
|
|
return new Uint8Array(readFileSync(out))
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
const e = endpoint(t, "voice.tts")
|
|
const body =
|
|
provider === "piper-http"
|
|
? { text, ...(t.voice ? { voice: t.voice } : {}), ...(t.speed && t.speed !== 1 ? { length_scale: 1 / t.speed } : {}) }
|
|
: provider === "webui"
|
|
? // The account's own voice and speed on the instance, unless this config names them.
|
|
{ input: text, response_format: "wav", ...(t.voice ? { voice: t.voice } : {}), ...(t.speed ? { speed: t.speed } : {}) }
|
|
: { model: t.model ?? "tts-1", voice: t.voice ?? "alloy", input: text, response_format: "wav", ...(t.speed && t.speed !== 1 ? { speed: t.speed } : {}) }
|
|
const url = provider === "piper-http" ? e.base : joinUrl(e.base, "audio/speech")
|
|
const res = await request(url, { method: "POST", headers: { ...e.headers, "content-type": "application/json" }, body: JSON.stringify(body), signal, timeoutMs: e.timeoutMs, tls: e.tls }, "voice.tts")
|
|
return new Uint8Array(await res.arrayBuffer())
|
|
}
|