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:
355 files changed
+47028
No files matched your search
@@ -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() }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user