LLeMbas CLI 1.0.0
ci / check (push) Canceled after 0s

The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM
API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a
link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64
and arm64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-09 21:59:03 +00:00
commit f9bad01ed7
355 files changed
+47028

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
// Recording and playing, through whatever the system has. Every recorder is asked for the same
// thing — raw 16 kHz mono signed 16-bit little-endian on stdout — so loudness can be measured as
// it comes (the level meter, and stopping on silence, as Hermes Agent's voice mode does:
// tools/voice_mode.py, MIT, © Nous Research) and the WAV file is written here.
import { spawn, type ChildProcess } from "node:child_process"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { Voice } from "../config/schema.ts"
export const RATE = 16000
/** Recorders and players still running, ended with LLeMbas CLI however it ends; and the players'
* temporary files. */
const live = new Set<{ kill(sig?: number | NodeJS.Signals): void }>()
const tempDirs = new Set<string>()
process.once("exit", () => {
for (const p of live)
try {
p.kill("SIGKILL")
} catch {}
for (const d of tempDirs) rmSync(d, { recursive: true, force: true })
})
const BLOCK = 480 // samples: 30 ms
/** What each player is, for someone who has not met it. */
export const PLAYER_NAMES: Record<string, string> = {
"pw-play": "PipeWire's player",
paplay: "PulseAudio's player",
aplay: "ALSA's player",
ffplay: "FFmpeg's player",
mpv: "the mpv media player",
afplay: "macOS's player",
}
export const RECORDERS: Record<string, () => string[]> = {
// --raw: without it pw-cat writes stdout as a Sun AU file (a header, then big-endian samples).
"pw-record": () => ["pw-record", "--raw", "--rate", String(RATE), "--channels", "1", "--format", "s16", "-"],
parec: () => ["parec", "--format=s16le", `--rate=${RATE}`, "--channels=1", "--raw"],
arecord: () => ["arecord", "-q", "-t", "raw", "-f", "S16_LE", "-r", String(RATE), "-c", "1"],
sox: () => [process.platform === "darwin" && Bun.which("rec") ? "rec" : "sox", "-q", ...(process.platform === "darwin" && Bun.which("rec") ? [] : ["-d"]), "-t", "raw", "-r", String(RATE), "-c", "1", "-b", "16", "-e", "signed-integer", "-"],
ffmpeg: () => ["ffmpeg", "-loglevel", "error", ...(process.platform === "darwin" ? ["-f", "avfoundation", "-i", ":0"] : ["-f", "pulse", "-i", "default"]), "-ac", "1", "-ar", String(RATE), "-f", "s16le", "-"],
}
const RECORDER_ORDER = ["pw-record", "parec", "arecord", "sox", "ffmpeg"]
const PLAYERS: Record<string, string[]> = {
afplay: ["afplay"],
"pw-play": ["pw-play"],
paplay: ["paplay"],
aplay: ["aplay", "-q"],
ffplay: ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet"],
mpv: ["mpv", "--no-video", "--really-quiet"],
}
const PLAYER_ORDER = ["afplay", "pw-play", "paplay", "aplay", "ffplay", "mpv"]
/** The recording command, or why there is none. */
export function recorderFor(v: Voice | undefined): string[] | string {
if (v?.recorder_command) return v.recorder_command
const want = v?.recorder && v.recorder !== "auto" ? [v.recorder] : RECORDER_ORDER
for (const name of want) {
const cmd = RECORDERS[name]!()
if (Bun.which(cmd[0]!)) return cmd
}
return v?.recorder && v.recorder !== "auto" ? `${v.recorder} is not installed` : "no recorder found — install pipewire (pw-record), pulseaudio-utils (parec), alsa-utils (arecord), sox or ffmpeg"
}
export function playerFor(v: Voice | undefined): string[] | string {
if (v?.player_command) return v.player_command
const want = v?.player && v.player !== "auto" ? [v.player] : PLAYER_ORDER
for (const name of want) if (Bun.which(PLAYERS[name]![0]!)) return PLAYERS[name]!
return v?.player && v.player !== "auto" ? `${v.player} is not installed` : "no audio player found — install pipewire (pw-play), pulseaudio-utils (paplay), alsa-utils (aplay), ffmpeg (ffplay) or mpv"
}
/** A WAV file around raw 16-bit mono samples. */
export function wav(pcm: Uint8Array, rate = RATE): Uint8Array {
const out = new Uint8Array(44 + pcm.length)
const d = new DataView(out.buffer)
const text = (at: number, s: string) => [...s].forEach((c, i) => d.setUint8(at + i, c.charCodeAt(0)))
text(0, "RIFF")
d.setUint32(4, 36 + pcm.length, true)
text(8, "WAVE")
text(12, "fmt ")
d.setUint32(16, 16, true)
d.setUint16(20, 1, true) // PCM
d.setUint16(22, 1, true) // mono
d.setUint32(24, rate, true)
d.setUint32(28, rate * 2, true)
d.setUint16(32, 2, true)
d.setUint16(34, 16, true)
text(36, "data")
d.setUint32(40, pcm.length, true)
out.set(pcm, 44)
return out
}
/** Root mean square of 16-bit little-endian samples. */
export function rms(pcm: Uint8Array): number {
const n = Math.floor(pcm.length / 2)
if (!n) return 0
const d = new DataView(pcm.buffer, pcm.byteOffset, n * 2)
let sum = 0
for (let i = 0; i < n; i++) {
const s = d.getInt16(i * 2, true)
sum += s * s
}
return Math.sqrt(sum / n)
}
export type StopReason = "stopped" | "silence" | "no-speech" | "max" | "cancelled" | "error"
export interface Recording {
/** 16-bit mono WAV; empty when nothing worth transcribing was heard. */
wav: Uint8Array
seconds: number
reason: StopReason
/** Why nothing came of it, when nothing did. */
empty?: string
}
export interface RecorderOptions {
command: string[]
silenceSeconds?: number
maxSeconds?: number
threshold?: number
/** Seconds to wait for any speech before giving up (Hermes: 15). */
maxWait?: number
/** Called when the recording ends by itself (silence, no speech, the limit, the recorder dying). */
onAutoStop?: (reason: StopReason) => void
}
/** One recording: start it, read `level` for a meter, stop it (or let silence stop it).
* The recorder runs in a process group of its own, so a pipeline (`sh -c "arecord | sox …"`)
* is stopped whole, and a stop never waits more than a couple of seconds for it. */
export class Recorder {
level = 0
private chunks: Uint8Array[] = []
private bytes = 0
private pending = new Uint8Array(0)
private proc: ChildProcess
private done: Promise<Recording>
private resolveDone!: (r: Recording) => void
private speechRun = 0 // seconds above the threshold, running
private dipRun = 0
private speech = false
private silentFor = 0
private peak = 0
private stderr = ""
private problem?: string
private ended?: StopReason
private finished = false
private exited = false
private drained = false
constructor(private o: RecorderOptions) {
this.done = new Promise((r) => (this.resolveDone = r))
const [cmd, ...args] = o.command
this.proc = spawn(cmd!, args, { stdio: ["ignore", "pipe", "pipe"], detached: true, env: { ...process.env } })
live.add(this.group)
this.proc.stdout!.on("data", (c: Buffer) => this.data(new Uint8Array(c)))
this.proc.stdout!.on("end", () => ((this.drained = true), this.settle()))
this.proc.stderr!.on("data", (c: Buffer) => (this.stderr = (this.stderr + c.toString()).slice(-2000)))
this.proc.on("error", (e) => ((this.stderr += e.message), (this.exited = true), (this.drained = true), this.settle()))
this.proc.on("exit", () => ((this.exited = true), this.settle()))
}
/** Signals go to the whole group. */
private group = {
kill: (sig: NodeJS.Signals | number = "SIGTERM") => {
try {
if (this.proc.pid) process.kill(-this.proc.pid, sig)
} catch {}
},
}
get seconds() {
return this.bytes / 2 / RATE
}
private data(chunk: Uint8Array) {
if (this.finished || this.ended === "error") return
// A recorder writing a sound file rather than raw samples would be heard as noise: refuse it.
if (!this.bytes && chunk.length >= 4) {
const magic = String.fromCharCode(...chunk.subarray(0, 4))
const kind = magic === ".snd" ? "an AU file" : magic === "RIFF" ? "a WAV file" : magic === "OggS" ? "an Ogg file" : magic === "fLaC" ? "a FLAC file" : ""
if (kind) {
this.problem = `it writes ${kind}, not raw 16 kHz mono s16le samples${this.o.command[0] === "pw-record" ? " (pw-record needs --raw)" : ""}`
this.end("error", true)
return
}
}
this.chunks.push(chunk)
this.bytes += chunk.length
const joined = new Uint8Array(this.pending.length + chunk.length)
joined.set(this.pending)
joined.set(chunk, this.pending.length)
let at = 0
for (; at + BLOCK * 2 <= joined.length; at += BLOCK * 2) this.block(joined.subarray(at, at + BLOCK * 2))
this.pending = joined.slice(at)
}
/** Hermes' auto-stop: speech counts after 0.3 s above the threshold (dips up to 0.3 s allowed);
* after speech, `silenceSeconds` below it ends the recording; no speech in `maxWait` ends it too. */
private block(b: Uint8Array) {
const threshold = this.o.threshold ?? 200
const dt = BLOCK / RATE
this.level = rms(b)
this.peak = Math.max(this.peak, this.level)
if (this.level > threshold) {
this.speechRun += dt
this.dipRun = 0
if (this.speechRun >= 0.3) {
this.speech = true
this.silentFor = 0
}
} else {
this.dipRun += dt
if (this.dipRun > 0.3) this.speechRun = 0
if (this.speech) this.silentFor += dt
}
if (this.speech && this.silentFor >= (this.o.silenceSeconds ?? 3)) this.end("silence", true)
else if (!this.speech && this.seconds >= (this.o.maxWait ?? 15)) this.end("no-speech", true)
else if (this.seconds >= (this.o.maxSeconds ?? 120)) this.end("max", true)
}
/** Ask the recorder to stop, then make sure: SIGKILL to the group after 1.5 s, and after 2.5 s
* stop waiting for its output whatever it does. */
private end(reason: StopReason, auto: boolean) {
if (this.ended) return
this.ended = reason
this.group.kill("SIGINT")
setTimeout(() => this.group.kill("SIGKILL"), 1500).unref?.()
setTimeout(() => ((this.exited = true), (this.drained = true), this.settle()), 2500).unref?.()
if (auto) this.o.onAutoStop?.(reason)
}
/** Stop and get the audio. */
stop(reason: StopReason = "stopped"): Promise<Recording> {
this.end(reason, false)
return this.done
}
private settle() {
if (this.finished || !this.exited || !this.drained) return
// Ended by itself (a missing device, a crash): say so, or whoever waits never learns.
if (!this.ended) {
this.ended = "error"
this.o.onAutoStop?.("error")
}
this.finished = true
live.delete(this.group)
this.finish(this.ended)
}
private finish(reason: StopReason) {
const pcm = new Uint8Array(this.bytes)
let at = 0
for (const c of this.chunks) {
pcm.set(c, at)
at += c.length
}
const seconds = this.seconds
const threshold = this.o.threshold ?? 200
let empty: string | undefined
if (reason === "error" || (!this.bytes && reason !== "cancelled")) empty = this.problem ? `the recorder stopped: ${this.problem}` : `the recorder stopped${this.stderr.trim() ? `: ${this.stderr.trim().split("\n").slice(-2).join(" ")}` : " without any audio"}`
else if (reason === "cancelled") empty = "cancelled"
else if (reason === "no-speech" || seconds < 0.3 || this.peak < threshold) empty = "no speech heard"
this.resolveDone({ wav: empty ? new Uint8Array(0) : wav(pcm), seconds, reason: this.bytes || reason === "cancelled" ? reason : "error", empty })
}
}
/** Play a WAV; `stop()` cuts it off. */
export function play(command: string[], audio: Uint8Array): { done: Promise<void>; stop(): void } {
const dir = mkdtempSync(join(tmpdir(), "lembas-say-"))
tempDirs.add(dir)
const clean = () => {
tempDirs.delete(dir)
rmSync(dir, { recursive: true, force: true })
}
const file = join(dir, "say.wav")
let p: ReturnType<typeof Bun.spawn>
try {
writeFileSync(file, audio)
p = Bun.spawn([...command, file], { stdout: "ignore", stderr: "ignore", stdin: "ignore", env: { ...process.env } })
} catch (e) {
clean()
throw e
}
live.add(p)
const done = p.exited.then(() => {
live.delete(p)
clean()
})
return { done, stop: () => p.kill() }
}
+197
View File
@@ -0,0 +1,197 @@
// Voice: talk to the agent, and have it talk back. One controller per app; the TUI binds a key
// to `toggle()` and shows `state`, `level` and `seconds`. After Hermes Agent's voice mode
// (tools/voice_mode.py, hermes_cli/cli_voice_mixin.py; MIT, © Nous Research), without its
// continuous conversation loop: a coding agent's replies are to be read, then maybe heard.
import type { Voice } from "../config/schema.ts"
import { play, playerFor, Recorder, recorderFor, type StopReason } from "./audio.ts"
import { synthesize, transcribe } from "./speech.ts"
import { believable, sentences, spokenText } from "./text.ts"
export type VoiceState = "idle" | "recording" | "transcribing"
export interface VoiceEvents {
/** Anything the UI shows changed (state, speaking, level). */
changed?: () => void
/** What was said, believable and non-empty. */
heard?: (text: string) => void
/** Something to tell the user. */
notice?: (message: string, warn?: boolean) => void
}
/** The longest reply read out; beyond it the rest is left to the screen. */
const MAX_SPOKEN = 3000
export class VoiceController {
state: VoiceState = "idle"
speaking = false
/** Speak every reply. */
speakReplies: boolean
private rec?: Recorder
private speech?: { abort: AbortController; stop?: () => void }
constructor(
readonly cfg: Voice | undefined,
private ev: VoiceEvents = {},
) {
this.speakReplies = cfg?.speak === true
}
get canListen() {
return !!this.cfg?.stt
}
get canSpeak() {
return !!this.cfg?.tts
}
get level() {
return this.rec?.level ?? 0
}
get seconds() {
return this.rec?.seconds ?? 0
}
/** The record key: start, or stop and transcribe. */
async toggle(): Promise<void> {
if (this.state === "recording") return this.finish("stopped")
if (this.state === "transcribing") return
this.start()
}
start() {
if (this.state !== "idle") return
if (!this.canListen) return this.ev.notice?.("voice input is not set up — add voice.stt to ~/.config/lembas/config.yaml (/voice shows how)", true)
const cmd = recorderFor(this.cfg)
if (typeof cmd === "string") return this.ev.notice?.(cmd, true)
// Talking over the agent cuts it off.
this.stopSpeaking()
try {
this.rec = new Recorder({
command: cmd,
silenceSeconds: this.cfg?.silence_seconds,
maxSeconds: this.cfg?.max_seconds,
threshold: this.cfg?.silence_threshold,
onAutoStop: (reason) => void this.finish(reason),
})
} catch (e) {
return this.ev.notice?.(`could not start ${cmd[0]}: ${(e as Error).message}`, true)
}
this.state = "recording"
this.ev.changed?.()
}
/** Stop recording and throw it away. */
async cancel() {
if (this.state !== "recording" || !this.rec) return
await this.rec.stop("cancelled")
this.rec = undefined
this.state = "idle"
this.ev.changed?.()
}
private async finish(reason: StopReason) {
const rec = this.rec
if (this.state !== "recording" || !rec) return
this.state = "transcribing"
this.ev.changed?.()
try {
const r = await rec.stop(reason)
if (r.empty) {
if (r.empty !== "cancelled") this.ev.notice?.(r.reason === "no-speech" ? "no speech heard" : r.empty, r.reason === "error")
return
}
const text = believable(await transcribe(this.cfg, r.wav))
if (!text) this.ev.notice?.("nothing recognisable was said")
else this.ev.heard?.(text)
} catch (e) {
this.ev.notice?.(`transcription failed: ${(e as Error).message}`, true)
} finally {
this.rec = undefined
this.state = "idle"
this.ev.changed?.()
}
}
/** Read a reply aloud, sentence by sentence, making the next while the last one plays. */
async speak(markdown: string): Promise<void> {
// Never into an open microphone: the reply would end up in the transcript.
if (this.state !== "idle") return
if (!this.canSpeak) return this.ev.notice?.("voice output is not set up — add voice.tts to ~/.config/lembas/config.yaml", true)
const player = playerFor(this.cfg)
if (typeof player === "string") return this.ev.notice?.(player, true)
this.stopSpeaking()
let text = spokenText(markdown)
if (!text) return
if (text.length > MAX_SPOKEN) text = `${text.slice(0, MAX_SPOKEN).replace(/[^.!?]*$/, "")} The rest is on the screen.`
const abort = new AbortController()
const run: { abort: AbortController; stop?: () => void } = { abort }
this.speech = run
this.speaking = true
this.ev.changed?.()
try {
const parts = sentences(text)
let next: Promise<Uint8Array> | undefined = synthesize(this.cfg, parts[0]!, abort.signal)
for (let i = 0; i < parts.length && !abort.signal.aborted; i++) {
const audio = await next!
next = i + 1 < parts.length ? synthesize(this.cfg, parts[i + 1]!, abort.signal) : undefined
next?.catch(() => {})
if (abort.signal.aborted) break
const p = play(player, audio)
run.stop = p.stop
await p.done
}
} catch (e) {
if (!abort.signal.aborted) this.ev.notice?.(`speaking failed: ${(e as Error).message}`, true)
} finally {
if (this.speech === run) {
this.speech = undefined
this.speaking = false
this.ev.changed?.()
}
}
}
stopSpeaking() {
const s = this.speech
if (!s) return
s.abort.abort()
s.stop?.()
this.speech = undefined
this.speaking = false
this.ev.changed?.()
}
async close() {
this.stopSpeaking()
await this.cancel()
}
}
const MODIFIERS: Record<string, "ctrl" | "alt" | "shift"> = { ctrl: "ctrl", control: "ctrl", alt: "alt", option: "alt", opt: "alt", meta: "alt", shift: "shift" }
const NAMED = new Set(["space", "enter", "return", "tab", "backspace", "delete", "insert", "home", "end", "pageup", "pagedown", "up", "down", "left", "right", ...Array.from({ length: 12 }, (_, i) => `f${i + 1}`)])
export interface KeyMatcher {
label: string
/** Set when the configured key was not usable and the default is used instead. */
warning?: string
matches(k: { name?: string; ctrl?: boolean; meta?: boolean; option?: boolean; shift?: boolean }): boolean
/** A release: only the key's name counts — the modifier may be let go first. */
released(k: { name?: string }): boolean
}
/** "ctrl+t", "alt+space", "f9" → a matcher for OpenTUI key events. A key that types a character
* needs ctrl or alt, or it would swallow every "t" typed; an unknown modifier is refused. */
export function keyMatcher(spec = "ctrl+t"): KeyMatcher {
const parts = spec.toLowerCase().split("+").map((s) => s.trim()).filter(Boolean)
const raw = parts.pop() ?? ""
const name = raw === "spc" ? "space" : raw === "esc" ? "escape" : raw
const unknown = parts.filter((m) => !MODIFIERS[m])
const mods = new Set(parts.map((m) => MODIFIERS[m]).filter((m): m is "ctrl" | "alt" | "shift" => !!m))
const printable = name.length === 1 || name === "space"
const bad = !name ? "no key" : unknown.length ? `unknown modifier ${unknown.join(", ")}` : name === "escape" || (mods.has("ctrl") && ["c", "d", "j", "r", "o"].includes(name)) ? `${spec} is taken` : name.length > 1 && !NAMED.has(name) ? `unknown key ${raw}` : printable && !mods.has("ctrl") && !mods.has("alt") ? `${spec} would swallow typing — add ctrl+ or alt+` : undefined
if (bad) return { ...keyMatcher("ctrl+t"), warning: `voice.record_key: ${bad}; using Ctrl+T` }
const label = [...mods].map((m) => m[0]!.toUpperCase() + m.slice(1)).concat(name.length === 1 ? name.toUpperCase() : name[0]!.toUpperCase() + name.slice(1)).join("+")
return {
label,
matches: (k) => k.name === name && !!k.ctrl === mods.has("ctrl") && !!(k.meta || k.option) === mods.has("alt") && !!k.shift === mods.has("shift"),
released: (k) => k.name === name,
}
}
+112
View File
@@ -0,0 +1,112 @@
// 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 { mkdtempSync, readFileSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { Voice } from "../config/schema.ts"
import { expandHome } 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 = mkdtempSync(join(tmpdir(), "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())
}
+97
View File
@@ -0,0 +1,97 @@
// What is said aloud, and what is believed of what was heard. After Hermes Agent
// (tools/tts_text_normalize.py, tools/voice_mode_transcript.py; MIT, © Nous Research).
/** A reply as it should sound: no code, no markdown, no URLs, symbols as words. */
export function spokenText(md: string): string {
let t = md
.replace(/<think>[\s\S]*?(<\/think>|$)/gi, " ")
// code is not read out: a fenced block becomes a pause
.replace(/```[\s\S]*?(```|$)/g, " ")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/<?https?:\/\/[^\s>)]+>?/g, " ")
.replace(/`([^`]*)`/g, "$1")
.replace(/(\*\*|__)(.+?)\1/g, "$2")
.replace(/(^|[^\w*])[*_]([^*_\n]+)[*_](?=[^\w*]|$)/g, "$1$2")
.replace(/~~(.+?)~~/g, "$1")
.replace(/^\s{0,3}>\s?/gm, "")
.replace(/^\s*([-*+]|\d+[.)])\s+/gm, "")
.replace(/^\s*([-*_]\s*){3,}$/gm, " ")
// a heading runs into its first line: "Weather. It will be sunny."
.replace(/^#{1,6}\s+(.+)$/gm, "$1.")
// tables: cells become a list
.replace(/^\s*\|?\s*:?-{3,}.*$/gm, "")
.replace(/[ \t]*\|[ \t]*/g, "; ")
t = t
.replace(/(\d+)\s*[-–]\s*(\d+)\s*°C/g, "$1 to $2 degrees Celsius")
.replace(/(\d+)\s*°C/g, "$1 degrees Celsius")
.replace(/(\d+)\s*°F/g, "$1 degrees Fahrenheit")
.replace(/(\d)\s*%/g, "$1 percent")
.replace(/(\d)\s*\/\s*(month|year|day|hour|week)\b/g, "$1 per $2")
.replace(/€\s*(\d[\d.,]*)|(\d[\d.,]*)\s*€/g, (_m, a: string, b: string) => `${a ?? b} euros`)
.replace(/\$\s*(\d[\d.,]*)/g, "$1 dollars")
.replace(/£\s*(\d[\d.,]*)/g, "$1 pounds")
.replace(/\s&\s/g, " and ")
.replace(/\s*(→|⇒|->)\s*/g, " to ")
.replace(/(≈|~)\s*(?=\d)/g, "about ")
.replace(/\p{Extended_Pictographic}️?/gu, "")
// every line ends as a sentence, then the lines run together
const lines = t
.split("\n")
.map((l) => l.replace(/\s+/g, " ").trim())
.filter((l) => l && !/^[;.,\s]+$/.test(l))
.map((l) => l.replace(/^;\s*|;\s*$/g, "").replace(/[:;]$/, ".").replace(/([^.!?])$/, "$1."))
return lines.join(" ").replace(/\s+([.,;!?])/g, "$1").replace(/\.{2,}/g, ".").trim()
}
/** Speakable pieces: whole sentences, each under `max` characters, small ones merged. */
export function sentences(text: string, max = 400, min = 20): string[] {
// Too long even between commas: cut at spaces, never drop the rest.
const byWords = (s: string): string[] => {
const out: string[] = []
let cur = ""
for (const w of s.split(/\s+/)) {
if (cur && cur.length + w.length + 1 > max) (out.push(cur), (cur = ""))
cur = cur ? `${cur} ${w}` : w
}
if (cur) out.push(cur)
return out.flatMap((x) => (x.length <= max ? [x] : x.match(new RegExp(`.{1,${max}}`, "g")) ?? []))
}
const parts = text
.split(/(?<=[.!?])\s+/)
.flatMap((s) => (s.length <= max ? [s] : s.split(/(?<=[,;:])\s+/)))
.flatMap((s) => (s.length <= max ? [s] : byWords(s)))
const out: string[] = []
for (const p of parts) {
const last = out[out.length - 1]
if (last !== undefined && (last.length < min || last.length + p.length + 1 <= Math.min(max, 160))) out[out.length - 1] = `${last} ${p}`
else out.push(p)
}
return out.filter((s) => s.trim())
}
// What Whisper "hears" in silence: training-set subtitles.
const PHANTOMS = new Set([
"thank you",
"thanks for watching",
"subscribe to my channel",
"like and subscribe",
"please subscribe",
"thank you for watching",
"bye",
"you",
"the end",
"sous-titres réalisés par la communauté d'amara.org",
"amara.org",
"ご視聴ありがとうございました",
"untertitel der amara.org-community",
])
const ONLY_FILLER = /^(?:thank you|thanks|bye|you|ok|okay|the end|\.|\s|,|!)+$/i
/** The transcript, or "" when it is one of the phrases Whisper makes up from silence. */
export function believable(transcript: string): string {
const t = transcript.replace(/\[(BLANK_AUDIO|MUSIC|NOISE|SILENCE)\]|\((silence|music|noise)\)/gi, " ").replace(/\s+/g, " ").trim()
const key = t.toLowerCase().replace(/[.!]/g, "").trim()
if (!key || PHANTOMS.has(key) || ONLY_FILLER.test(t)) return ""
return t
}