Sonia Assistant
**Sonia** is an LLM + STT + TTS voice assistant that needs **no dedicated GPU** and runs in **less than 15 GB of RAM**, entirely on a local computer. Multilingual and full-duplex: the mic stays open while the assistant talks - interrupt it (barge-in) and it pauses, resumes or cancels accordingly. It mirrors whichever language you speak (English/Italian tested).
0.0.1Add to Favorites
Source
Get it from source
Spark does not host a copy of it.
Open sourceReports
Agent outcome reports
No reports yet
Overview
Sonia Assistant
What it does
Sonia Personal Assistant
Sonia is an LLM + STT + TTS voice assistant that needs no dedicated GPU and runs
in less than 15 GB of RAM, entirely on a local computer.
Multilingual and full-duplex: the mic stays open while the assistant talks - interrupt
it (barge-in) and it pauses, resumes or cancels accordingly. It mirrors whichever
language you speak (English/Italian tested).
Everything is local; the only external piece is an OpenAI-compatible endpoint (an
already-running llama-server, managed outside this project). Optionally and
experimentally, an existing pi executable can replace it - see AI models used.
Contents - Features · Architecture ·
Models · Installation · Security ·
Inspiration · License
Main features
- True full-duplex listening - the mic never closes; answers can be interrupted mid-sentence. Barge-in is echo-aware: backchannels and Sonia's own voice resume it, real speech cancels the reply and keeps what was already said.
- Streaming ASR + layered turn detection - partial transcripts drive the live
🎤 …line; a turn ends on Silero VAD + silence gaps + the Smart Turn classifier, with a 4-tokenLISTEN/REPLYprompt only when the pause is ambiguous. - Bilingual IT/EN - the model mirrors your language per turn;
LOCALE=it|enlocalizes UI, spoken sentences and tool results, and a timer rings in the language of the request that set it. - Function-calling tools - datetime, timers, watches, notes, skills, shell (6 tool rounds per turn), gated by a tool gate that re-checks each call against your words (so a 4B model can't fire spurious tools) and by
CMD_MODE=off|ask|autofor risky shell. The read-only skill tools (read_skill,list_skills) are exempt from the gate - they load context and act on nothing, while therun_commandthat uses them is still judged. - Externally managed skills -
SKILL.mdpackages are not owned by this project: pointSKILLS_DIRat the directory you already use (pi's, opencode's, any other harness's) and activate each per-turn from the UI settings drawer. - Two interchangeable LLM legs - the endpoint (default) or, optionally, an existing
piexecutable (LLM_VIA_PI=1, toggled at runtime) - plus a browser app-window UI and--cli/--textterminal modes. - A task banner over the chat - every pending timer, running command and active watch, with its countdown and a ✕ that stops that task alone. Watches repeat a read-only command at an interval (≥ 5 s), optionally staying quiet until the output matches some text.
- ⟲ rewind + persistent chat - everything after the clicked line goes (later turns and their context, in transcript and model history alike, on both legs); the line itself and pending tasks stay. 🧹 erases the whole chat, this only the part you regret. State lives in
data/chat.json, so a restart resumes where you left off (CHAT_PERSIST=0disables).
Architecture
One TypeScript hub process owns the 32 ms frame loop, the queues and the state machine.
The speech stack is three audio.cpp servers, the LLM is an external llama-server,
VAD runs in-process.
mic (32 ms frames)
│
├─► Silero VAD (in-process, ONNX) [src/vad.ts]
▼
audio.cpp :8091 streaming ASR ── partial + final transcript
▼
HARNESS src/harness/harness.ts — turn-end decision:
│ pause ≥200ms → Smart Turn (:8093) ≥0.5 ⇒ end · pause ≥900ms + "not finished"
│ → 4-token LLM prompt LISTEN/REPLY (prompts/decision.txt)
▼
LLM leg (src/clients/llm.ts / pi.ts — one interface, two backends)
│ A) llama-server :8080 (OpenAI API) B) `pi -c` CLI (experimental, optional)
│ + tools + tool gate (prompts/tool-gate.txt) filtering each round
▼
streamed text → SentenceSplitter → sentences
▼
audio.cpp :8092 TTS ── SSE PCM16 → paced Player (20 ms slices)
▲ barge-in: pause / resume / flush within ~one buffer
| Port | Service | Started by |
|---|---|---|
:8080 |
llama-server - OpenAI-compatible LLM (external) |
you: systemctl --user start llama-server |
:8091 / :8092 / :8093 |
audio.cpp ASR (Vulkan) / TTS / Smart Turn (CPU) | scripts/start-audio-servers.sh start |
:8095 |
harness web UI - GET /, GET /ws (JSON events + binary PCM both ways) |
bun run start (scripts/run.ts) |
Code layout (src/, Bun + TypeScript)
Entry points cli.ts (--web/--cli/--text/offline WAV), web.ts, config.ts (every
knob = env var), i18n.ts · event loop in harness/ · model clients in clients/
(audiocpp.ts, llm.ts, pi.ts, sse.ts, think.ts) · capabilities tools.ts,command-policy.ts, shell-runner.ts, skills.ts, vad.ts · audio in audio/, UI instatic/index.html (single-file, localized) · prompts in prompts/.
AI models used
| Model | Role | Served by / device | Size |
|---|---|---|---|
Qwen3.5-4B-MTP (Q4_K_M) |
main LLM: replies, tools, turn judge, tool gate | external llama-server :8080, Vulkan |
2.7 GiB (not fetched by this repo) |
Voxtral Mini 4B Realtime 2602 (q4_k) |
streaming ASR | audio.cpp :8091, Vulkan (B390 iGPU) |
2.9 GB |
Pocket TTS (bf16, 5 language packs, 26 voices) |
streaming TTS | audio.cpp :8092, CPU |
~209 MiB/pack |
Smart Turn v3.2 (f32) |
end-of-turn classifier | audio.cpp :8093, CPU |
31 MB |
Silero VAD v5 (onnx) |
voice activity detection | in-process, CPU | 2.3 MB |
experimental + optional: any pi --list-models id |
alternative LLM leg (LLM_VIA_PI=1); harness tools are off in this mode |
pi CLI subprocess |
- |
Speech models go to models/ via bun run models-download (~3.3 GiB total,
idempotent/resumable) from the audio.cpp GGUF mirror;
the LLM .gguf is never downloaded here - it belongs to your external llama-server.
No dedicated GPU: ASR uses whatever Vulkan device is available (an iGPU is fine) orASR_BACKEND=cpu; TTS, Smart Turn and the VAD are CPU-only.
Installation
Prerequisites
Bun ≥ 1.x (runtime + tests, no build step) · llama-server on :8080 (the LLM
leg - not started by this repo, must answer /health) · cmake + Ninja + Vulkan SDK
for the one-time build of vendor/audio.cpp (no Vulkan device? -DENGINE_ENABLE_VULKAN=OFF
ASR_BACKEND=cpu) · Chromium inPATHfor the browser UI (optional:--no-browser+ any browser) · PipeWire for mic/speaker in--climode (browser mode
uses the tab's audio).
Steps
bun install # 1. dependencies
cp .env.example .env # 2. config — set SKILLS_DIR (see "Skills") and
# LLM_BASE_URL / LLM_MODEL, or LLM_VIA_PI=1 to use an existing `pi` executable
bun run fetch-and-compile-audio.cpp # 3. fetch + compile the latest audio.cpp release
# (clone → cmake → build, incremental; vendor/ is git-ignored. Or point
# AUDIOCPP_SERVER=/path/to/audiocpp_server at an existing binary)
Why compile instead of using the official prebuilt binaries?
Upstream (0xShug0/audio.cpp) ships Ubuntu/macOS/Windows
binaries with every release, but they are built withENGINE_ENABLE_NATIVE_CPU=ON
(i.e.-march=native) on AVX-512 CI runners, so the AVX-512 instructions end up inggml_cpu_inititself. On CPUs without AVX-512 (all Intel 12th-gen+) the process dies
withSIGILLright after Vulkan init - with bothbackend: vulkanandbackend: cpu, since the CPU backend is always registered.
Verified against releasev0.9.1(gdb:ggml_cpu_init+607: vpbroadcastd %edx,%ymm17,
an AVX-512VL instruction). A local build works because-march=nativethen means
plain AVX2/AVX-VNNI. (Not a Mesa/driver issue: neither binary bundles a driver - both
link onlylibvulkan.so.1and use the host's ICD, and the official one did enumerate
the iGPU correctly before crashing.)
Revisit once upstream releases baseline-ISA binaries (e.g.GGML_NATIVE=OFFor-DENGINE_ENABLE_CPU_ALL_VARIANTS=ON): thenAUDIOCPP_SERVER=can just point at the
downloaded release and thevendor/audio.cppclone (1.2 GB) can go away.
bun run models-download # 4. speech models (~3.3 GiB, resumable)
bun run models-download --check # verify only: ✓/✗ per file, exit 1 if missing
bun run models-download --only vad # one package only (asr|tts|turn|vad)
systemctl --user start llama-server # 5. the LLM (external, your own unit/config)
bun run runtime-check # 6. verify everything answers
Run
bun run start # three audio.cpp servers + the harness → http://127.0.0.1:8095/
bun run stop # …plus the audio servers
bun run start --no-browser # same, print the URL only
bun run start --cli # terminal through PipeWire mic/speaker
bun run start --text # typed input
bun run stop --all # …plus llama-server (the systemd unit)
bun run status # what is running + does it answer (--ui: + the browser-UI suite)
bun run stop --status # …the processes table alone
bun run scripts/stop.ts # only the harness + its Chromium window
scripts/run.ts checks llama-server, starts the audio servers (waiting on /health),
then the harness - which warms up (LLM + TTS rate + ASR Vulkan graph) before printingready, so a cold model load can take minutes. Everything private or regeneratable
lives in data/ (git-ignored): pid files, server configs, logs, the browser profile,
notes/skills/voice choices and the conversation (chat.json).
Development
bun run test # unit tests (pretest = tsc --noEmit, strict)
bun run test:all # + the WebSocket suite (needs models/silero)
bun run typecheck
bun run status # what is running + are llama-server/ASR/TTS/turn/harness up?
bun run status --ui # …+ the browser-UI suite (headless Chromium)
bun run runtime-check # the endpoint probe alone (the second half of `status`)
scripts/ui-test.sh # the browser-UI suite alone (the `--ui` part)
CI (.github/workflows/ci.yml) is hermetic: llama-server and the audio.cpp servers are
mocked, temp dirs hold the state, and the only file fetched is the Silero VAD. The opt-in
job (WITH_MODELS=true repo variable) fetches the full ~3.3 GiB of speech models - also
checking the download plan still resolves - and re-runs the suite with them.
Env vars: .env.example documents all of them (defaults insrc/config.ts); the UI's ⚙ drawer overrides some at runtime.
Skills
Skills are plain SKILL.md packages (instructions + scripts/) read from a single
directory - Sonia owns none of them. Set SKILLS_DIR in .env to a folder your other
tooling already uses: ~/.pi/agent/skills (the pi coding agent, the default), your
opencode skills/plugins directory, or any dir with <name>/SKILL.md. Which are
active is chosen per-session in the UI drawer (persisted in data/skills.json); inactive
skills never reach the prompt and read_skill refuses them.
Security
Everything is localhost by design - no login, no TLS: do not pass a non-loopback--host without your own protection in front of it. run_command spawns bash -c as
your user with no sandbox; the policy in src/command-policy.ts
classes read-only commands as safe, while shell metacharacters and sensitive paths
(.ssh, keys, .env) always stop at the approval prompt in CMD_MODE=ask.
The /ws WebSocket - the control channel of the whole app - accepts a connection only
from this server's own origin (originAllowed in src/web.ts): browsers
do not apply the same-origin policy to WebSockets, so without that check any page you
visit could open ws://127.0.0.1:8095/ws, read the transcript and push commands at it.
A page served elsewhere is refused at the upgrade; a second window of your own takes the
single client slot over, telling the first one with a session_taken event.
Skills are out of this project's scope - verifying them is yours. Commands insideSKILLS_DIR are implicitly trusted: they skip both the whitelist and the approval
prompt and run hands-free as your user. Before activating a skill, check what itsSKILL.md tells the model to do and which CLI commands it calls - Sonia only relays
them. Prefer CMD_MODE=ask while trying new ones.
Inspiration
This project answers Speakrail - a
low-latency, fully-local voice assistant that listens while it talks, but requires
CUDA/Nvidia hardware and more resources.
scripts/start-audio-servers.sh
#!/usr/bin/env bash
# start-audio-servers.sh — launch the three audio.cpp servers of Stack A:
# ASR : Voxtral Mini 4B Realtime q4_k, streaming (default Vulkan, :8091)
# ASR_PRECISION=q8_0|bf16 serves another package of the same model id
# TTS : Pocket TTS Italian, streaming (default CPU, :8092)
# TURN : Smart Turn v3.2 (default CPU, :8093)
# The LLM is NOT started here: the harness uses the already-running llama-server (:8080).
#
# Build prereq: audio.cpp is vendored at vendor/audio.cpp in this repo and must be built:
# bun run fetch-and-compile-audio.cpp (fetches the latest release + compiles it)
# …or by hand: cmake -S vendor/audio.cpp -B vendor/audio.cpp/build -G Ninja \
# -DCMAKE_BUILD_TYPE=Release -DENGINE_ENABLE_VULKAN=ON \
# && cmake --build vendor/audio.cpp/build --target audiocpp_server
# Or point AUDIOCPP_SERVER at an already-built audiocpp_server binary.
# The `start` action prints the audio.cpp version it is about to run (git tag + build);
# each server logs to data/logs/<name>.log (pids and configs stay in data/run/).
#
# Usage: scripts/start-audio-servers.sh [start [asr|tts|turn ...]|stop|status]
# Env: ASR_BACKEND=vulkan TTS_BACKEND=cpu TURN_BACKEND=cpu THREADS=8
# ASR_PORT=8091 TTS_PORT=8092 TURN_PORT=8093
# ASR_PRECISION=q4_k|q8_0|bf16 TTS_PRECISION=bf16|q8_0
# TTS_LANG=italian|english|german|portuguese|spanish (the Pocket TTS pack +
# its `language` load option; `bun run models-download` fetches it)
# (a project .env is honoured too; a real environment variable always wins)
set -euo pipefail
ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
# project .env (same keys as src/config.ts); a real environment variable always wins
if [[ -f "$ROOT/.env" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%$'\r'}"
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
[[ -n "${!key+x}" ]] || export "$line" || true
fi
done < "$ROOT/.env"
fi
BIN="${AUDIOCPP_SERVER:-$ROOT/vendor/audio.cpp/build/bin/audiocpp_server}"
RUN="$ROOT/data/run" # pid files + server configs (state)
LOGS="$ROOT/data/logs" # *.log only — git-ignored, see .gitignore
M="${MODELS_DIR:-$ROOT/models}"
case "$M" in /*) ;; *) M="$ROOT/${M#./}" ;; esac
mkdir -p "$RUN" "$LOGS"
ASR_BACKEND="${ASR_BACKEND:-vulkan}"
TTS_BACKEND="${TTS_BACKEND:-cpu}"
TURN_BACKEND="${TURN_BACKEND:-cpu}"
ASR_PORT="${ASR_PORT:-8091}"
TTS_PORT="${TTS_PORT:-8092}"
TURN_PORT="${TURN_PORT:-8093}"
THREADS="${THREADS:-8}"
TTS_PRECISION="${TTS_PRECISION:-bf16}"
TTS_LANG="${TTS_LANG:-italian}"
ASR_PRECISION="${ASR_PRECISION:-q4_k}"
ASR_GGUF="${ASR_GGUF:-$M/Voxtral-Mini-4B-Realtime-2602-GGUF/voxtral-mini-4b-realtime-2602-$ASR_PRECISION.gguf}"
TTS_GGUF="${TTS_GGUF:-$M/PocketTTS-GGUF/$TTS_LANG/pocket-tts-$TTS_LANG-$TTS_PRECISION.gguf}"
TURN_GGUF="${TURN_GGUF:-$M/Smart-Turn-v3-GGUF/smart-turn-v3.2-f32.gguf}"
ASR_BATCH_TOKENS="${ASR_BATCH_TOKENS:-4}"
info(){ printf "\033[1;34m[audio]\033[0m %s\n" "$*"; }
warn(){ printf "\033[1;33m[audio]\033[0m %s\n" "$*" >&2; }
die(){ printf "\033[1;31m[err]\033[0m %s\n" "$*" >&2; exit 1; }
# Which audio.cpp is this? — the tag the vendored source sits at plus the binary's own
# `--version` (commit, compiler, backends). Printed by `start`, i.e. by `bun run start` too.
print_version() {
local tag build
tag="$(git -C "$ROOT/vendor/audio.cpp" describe --tags --exact-match 2>/dev/null || true)"
build="$(timeout 5 "$BIN" --version 2>/dev/null || true)"
info "audio.cpp ${tag:-${build#audio.cpp }} — ${BIN#"$ROOT"/}"
[[ -n "$build" ]] && printf '%s\n' "$build" | tail -n +2 | sed 's/^/ /'
return 0
}
write_configs() {
cat > "$RUN/asr.json" <<JSON
{
"host": "127.0.0.1", "port": $ASR_PORT, "backend": "$ASR_BACKEND", "device": 0,
"threads": $THREADS, "lazy_load": false, "busy_timeout_ms": 0,
"live_ingest": { "total_timeout_ms": 0, "idle_timeout_ms": 60000 },
"models": [
{ "id": "voxtral", "family": "voxtral_realtime", "path": "$ASR_GGUF",
"task": "asr", "mode": "streaming",
"session_options": { "voxtral_realtime.stream_batch_tokens": "$ASR_BATCH_TOKENS" } }
]
}
JSON
cat > "$RUN/tts.json" <<JSON
{
"host": "127.0.0.1", "port": $TTS_PORT, "backend": "$TTS_BACKEND", "device": 0,
"threads": $THREADS, "lazy_load": false,
"models": [
{ "id": "pocket-it", "family": "pocket_tts", "path": "$TTS_GGUF",
"task": "tts", "mode": "streaming",
"load_options": { "language": "$TTS_LANG" } }
]
}
JSON
cat > "$RUN/turn.json" <<JSON
{
"host": "127.0.0.1", "port": $TURN_PORT, "backend": "$TURN_BACKEND", "device": 0,
"threads": 4, "lazy_load": false,
"models": [
{ "id": "smart-turn", "family": "smart_turn", "path": "$TURN_GGUF",
"task": "turn", "mode": "offline" }
]
}
JSON
}
start_one() { # name port
local name="$1" port="$2"
if ss -tlnH 2>/dev/null | grep -qE "127.0.0.1:${port}\b"; then
info "$name already listening on :$port"; return
fi
info "starting $name on :$port → ${LOGS#"$ROOT"/}/$name.log"
nohup setsid "$BIN" --config "$RUN/$name.json" --no-ui --log >"$LOGS/$name.log" 2>&1 </dev/null &
echo $! > "$RUN/$name.pid"
}
wait_ready() { # name port
local name="$1" port="$2"
for _ in $(seq 1 240); do
if curl -sf -m 2 "http://127.0.0.1:$port/health" >/dev/null 2>&1; then
info "$name ready"; return 0
fi
if [[ -f "$RUN/$name.pid" ]] && ! kill -0 "$(cat "$RUN/$name.pid")" 2>/dev/null; then
tail -n 20 "$LOGS/$name.log" >&2; die "$name died"
fi
sleep 1
done
die "$name not ready after 240 s (see ${LOGS#"$ROOT"/}/$name.log)"
}
stop_all() {
local n port pid i
for n in asr tts turn; do
case "$n" in asr) port=$ASR_PORT ;; tts) port=$TTS_PORT ;; turn) port=$TURN_PORT ;; esac
if [[ -f "$RUN/$n.pid" ]]; then
kill "$(cat "$RUN/$n.pid")" 2>/dev/null && info "stopped $n" || true
rm -f "$RUN/$n.pid"
fi
# stale pid file / started by hand → fall back to whoever holds the port
pid="$(ss -tlnpH "sport = :$port" 2>/dev/null | grep -oE 'pid=[0-9]+' | head -n1 | cut -d= -f2 || true)"
if [[ -n "$pid" ]]; then
kill "$pid" 2>/dev/null && info "stopped $n on :$port (pid $pid)" || true
fi
done
for n in asr tts turn; do
case "$n" in asr) port=$ASR_PORT ;; tts) port=$TTS_PORT ;; turn) port=$TURN_PORT ;; esac
for i in $(seq 1 20); do
ss -tlnH "sport = :$port" 2>/dev/null | grep -q . || break
sleep 0.5
done
ss -tlnH "sport = :$port" 2>/dev/null | grep -q . \
&& warn ":$port still listening — kill it by hand (pid $(ss -tlnpH "sport = :$port" | grep -oE 'pid=[0-9]+' | head -n1))" || true
done
}
case "${1:-start}" in
start)
[[ -x "$BIN" ]] || die "missing $BIN — run: bun run fetch-and-compile-audio.cpp (or set AUDIOCPP_SERVER)"
shift || true
print_version
NAMES=("$@"); (( ${#NAMES[@]} )) || NAMES=(asr tts turn)
write_configs
for n in "${NAMES[@]}"; do
case "$n" in
asr) [[ -f "$ASR_GGUF" ]] || die "missing $ASR_GGUF — fetch it with: bun run models-download (ASR_PRECISION=$ASR_PRECISION)"; start_one asr "$ASR_PORT" ;;
tts) [[ -f "$TTS_GGUF" ]] || die "missing $TTS_GGUF — fetch it with: bun run models-download (TTS_LANG=$TTS_LANG, TTS_PRECISION=$TTS_PRECISION)"; start_one tts "$TTS_PORT" ;;
turn) [[ -f "$TURN_GGUF" ]] || die "missing $TURN_GGUF — fetch it with: bun run models-download"; start_one turn "$TURN_PORT" ;;
*) die "unknown server $n (asr|tts|turn)" ;;
esac
done
for n in "${NAMES[@]}"; do
case "$n" in asr) wait_ready asr "$ASR_PORT" ;; tts) wait_ready tts "$TTS_PORT" ;; turn) wait_ready turn "$TURN_PORT" ;; esac
done
;;
stop) stop_all ;;
status)
for p in "asr $ASR_PORT" "tts $TTS_PORT" "turn $TURN_PORT"; do
set -- $p; printf "%-5s :%s " "$1" "$2"; curl -s -m 2 "http://127.0.0.1:$2/health" || printf "down"; echo
done ;;
*) die "usage: $0 [start|stop|status]" ;;
esac
scripts/run.ts
/**
* `bun run start` — start the audio.cpp servers (if needed) and the full-duplex harness.
* (Formerly the root-level ./run.sh; same steps, same arguments, same exit codes.)
* The LLM is the already-running llama-server on :8080 (Qwen3.5-4B-MTP-Q4_K_M).
*
* bun run start browser UI: opens Chromium (mic + speaker + AEC) once ready
* bun run start --no-browser same, but just print the URL (http://127.0.0.1:8095)
* bun run start --cli terminal mode through the default PipeWire mic/speaker
* bun run start --text type instead of speaking
* bun run start --input-wav 1:q.wav --no-speaker --output-wav out.wav (offline test)
* bun run stop stop what this script started (scripts/stop.ts)
*
* Prereqs: llama-server on :8080 (LLM leg) and audio.cpp built at vendor/audio.cpp
* (see scripts/start-audio-servers.sh for the cmake build command).
*
* `.env` needs no parsing here: Bun itself exports every key of it into `process.env`
* when it starts this file (a shell variable already set still wins), and every child
* we spawn inherits that environment — the hand-rolled reader run.sh had is obsolete.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { Config } from "../src/config";
const ROOT = path.resolve(import.meta.dir, "..");
process.chdir(ROOT);
const cfg = Config.fromEnv();
// ── 1. the LLM leg ───────────────────────────────────────────────────────────
// llama-server is needed by the API leg, and by the pi leg too when pi's model is
// served by it (the default llamacpp/Qwen3.5-…): only a non-local PI_MODEL makes
// the check pointless.
const needLlama = !cfg.llmViaPi || cfg.piModel.startsWith("llamacpp/");
if (needLlama) {
// `${LLM_BASE_URL%/v1}/health` as run.sh did it: strip a trailing /v1, ask /health.
const base = cfg.llmBaseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
const up = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) })
.then((r) => r.ok)
.catch(() => false);
if (!up) {
console.error(
`llama-server not reachable at ${cfg.llmBaseUrl} — start it first (e.g. systemctl --user start llama-server)`,
);
process.exit(1);
}
}
// ── 2. the audio.cpp servers (idempotent: already-running ones are left alone) ──
{
const r = spawnSync("bash", [path.join(ROOT, "scripts/start-audio-servers.sh"), "start"], {
cwd: ROOT,
stdio: "inherit",
});
if (r.status !== 0) process.exit(r.status ?? 1);
}
// ── 3. the harness ───────────────────────────────────────────────────────────
// Which mode: `--cli` first and foremost, then the offline-WAV flags, else the browser.
const argv = process.argv.slice(2);
const cliArgs: string[] =
argv[0] === "--cli"
? argv.slice(1)
: argv.includes("--text") || argv.includes("--input-wav")
? argv
: ["--web", ...argv];
const child = spawn(process.execPath, ["run", "src/cli.ts", ...cliArgs], {
cwd: ROOT,
stdio: "inherit",
});
// pid file for `bun run stop` — the pid that actually listens on :8095, not ours.
fs.mkdirSync(path.join(ROOT, "data/run"), { recursive: true });
fs.writeFileSync(path.join(ROOT, "data/run/harness.pid"), `${child.pid}\n`);
// a Ctrl+C in the terminal reaches both of us (same process group); pass it on anyway
// for the case where only this wrapper is signalled (systemd, `kill <pid>`).
for (const sig of ["SIGINT", "SIGTERM"] as const) {
process.on(sig, () => {
if (child.exitCode === null && child.signalCode === null) child.kill(sig);
});
}
const [code, signal] = await new Promise<[number | null, NodeJS.Signals | null]>((res) => {
child.once("close", (c, s) => res([c, s]));
});
// a startup failure (EADDRINUSE, warmup, LLM/TTS down) must not look like a success:
// scripts/run.ts and any caller see a non-zero exit code. A child that dies from a signal
// reports 128+N, the same the shell used to return after `exec bun run src/cli.ts`.
process.exit(signal ? 128 + (os.constants.signals[signal] ?? 1) : (code ?? 1));
scripts/stop.ts
/**
* `bun run stop` — stop what `bun run start` started (the mirror of scripts/run.ts).
* (Formerly the root-level ./stop.sh; same flags, same discovery rules, same output.)
*
* bun run stop harness web server (:8095) + its Chromium app window
* bun run stop --audio … plus the three audio.cpp servers (asr/tts/turn)
* bun run stop --all … plus llama-server (systemctl --user stop llama-server)
* bun run stop --port 8123 a harness listening on another port (leftover test instance)
* bun run stop --keep-window stop the server but leave the Chromium window open
* bun run stop --grace 30 seconds to wait for a polite exit before SIGKILL
* bun run stop --dry-run print what would be stopped, stop nothing
* bun run stop --status print what is running (touches nothing)
*
* llama-server is external (README §Ports): only --all stops it.
*/
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const ROOT = path.resolve(import.meta.dir, "..");
const RUN = path.join(ROOT, "data", "run");
const c = {
blue: "\x1b[1;34m",
gray: "\x1b[1;90m",
yellow: "\x1b[1;33m",
red: "\x1b[1;31m",
off: "\x1b[0m",
};
const info = (m: string) => console.log(`${c.blue}[stop]${c.off} ${m}`);
const note = (m: string) => console.log(`${c.gray}[stop]${c.off} ${m}`);
const warn = (m: string) => console.log(`${c.yellow}[stop]${c.off} ${m}`);
const die = (m: string): never => {
console.error(`${c.red}[stop]${c.off} ${m}`);
process.exit(1);
};
const USAGE = `bun run stop — stop what \`bun run start\` started (see scripts/stop.ts).
--audio … plus the three audio.cpp servers (asr/tts/turn)
--all … plus llama-server (systemd --user unit)
--port 8123 a harness listening on another port (leftover test instance)
--keep-window stop the server but leave the Chromium window open
--grace 30 seconds to wait for a polite exit before SIGKILL
--dry-run print what would be stopped, stop nothing
--status, -s print what is running (touches nothing)`;
// ── arguments ────────────────────────────────────────────────────────────────
const argv = process.argv.slice(2);
const port0 = Number(process.env["HARNESS_PORT"] ?? 8095);
if (!Number.isInteger(port0) || port0 < 1 || port0 > 65535) die(`HARNESS_PORT is not a port: ${port0}`);
let port = port0;
let grace = 10;
let doAudio = false;
let doAll = false;
let doStatus = false;
let keepWindow = false;
let dry = false;
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
const next = (flag: string): string => {
const v = argv[++i];
if (v === undefined) die(`${flag} needs a value`);
return v;
};
switch (a) {
case "--audio": doAudio = true; break;
case "--all": doAudio = true; doAll = true; break;
case "--keep-window": keepWindow = true; break;
case "--status": case "-s": doStatus = true; break;
case "--dry-run": case "-n": dry = true; break;
case "--port": port = Number(next("--port")); break;
case "--grace": grace = Number(next("--grace")); break;
case "-h": case "--help": console.log(USAGE); process.exit(0); break;
default: die(`unknown argument ${a} (try --help)`);
}
}
if (!Number.isInteger(port) || port < 1 || port > 65535) die(`--port needs a port`);
if (!Number.isFinite(grace) || grace < 0) die(`--grace needs seconds`);
// ── discovery (the /proc walk replaces pgrep/ss parsing where it helps) ──────
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function allPids(): number[] {
const out: number[] = [];
for (const name of fs.readdirSync("/proc")) if (/^\d+$/.test(name)) out.push(Number(name));
return out;
}
function cmdOf(pid: number): string {
try {
return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ").trim();
} catch {
return "";
}
}
function cwdOf(pid: number): string {
try {
return fs.readlinkSync(`/proc/${pid}/cwd`);
} catch {
return "";
}
}
/** basename of the running binary — `bun`/`node` for every harness instance. */
function exeOf(pid: number): string {
try {
return path.basename(fs.readlinkSync(`/proc/${pid}/exe`));
} catch {
return "";
}
}
function alive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (e) {
return (e as NodeJS.ErrnoException).code === "EPERM"; // exists, not ours to signal
}
}
/** pid of the listener on `p`, null if none — same `ss -tlnpH "sport = :$p"` as stop.sh. */
function pidOnPort(p: number): number | null {
const r = spawnSync("ss", ["-tlnpH", `sport = :${p}`], { encoding: "utf8" });
const m = (r.stdout ?? "").match(/pid=(\d+)/);
return m ? Number(m[1]) : null;
}
/**
* Is this pid one of *our* harness processes (never an unrelated process on the port)?
*
* cwd/cmdline say "it is a cli.ts of this checkout"; the exe check says "and it really
* *is* the interpreter running it" — without it any shell, editor or agent whose command
* line merely quotes both the repo path and `cli.ts` (a grep, a `bun test` line, a paste)
* would be treated as the harness and SIGTERMed. The bash original had that hole too.
*/
function isHarness(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
if (!alive(pid)) return false;
const exe = exeOf(pid);
if (exe !== "bun" && exe !== "node") return false;
const cwd = cwdOf(pid);
const cmd = cmdOf(pid);
return (cwd === ROOT || cwd.startsWith(`${ROOT}/`) || cmd.includes(ROOT)) && cmd.includes("cli.ts");
}
function harnessPids(): number[] {
const found = new Set<number>();
const pf = path.join(RUN, "harness.pid");
try {
const v = Number(fs.readFileSync(pf, "utf8").trim());
if (Number.isInteger(v) && v > 0) found.add(v);
} catch {
/* no pid file */
}
const onPort = pidOnPort(port);
if (onPort !== null) found.add(onPort);
for (const pid of allPids()) if (/cli\.ts --web/.test(cmdOf(pid))) found.add(pid);
return [...found].filter(isHarness).sort((a, b) => a - b);
}
const CHROMIUM_STOP = `user-data-dir=${RUN}/chromium-profile`; // exact flag the app window gets
const CHROMIUM_ANY = "chromium-profile"; // looser pattern, for --status (as stop.sh had it)
function chromiumPids(pattern: string): number[] {
return allPids().filter((pid) => cmdOf(pid).includes(pattern)).sort((a, b) => a - b);
}
// ── stop one pid: graceful TERM → wait grace → KILL ──────────────────────────
async function stopPid(name: string, pid: number): Promise<void> {
if (dry) {
info(`would stop ${name} (pid ${pid}, ${cmdOf(pid).slice(0, 60)})`);
return;
}
info(`stopping ${name} (pid ${pid})`);
try {
process.kill(pid, "SIGTERM");
} catch {
/* already gone */
}
for (let i = 0; i < grace * 2; i++) {
await sleep(500);
if (!alive(pid)) {
info(`${name} stopped`);
return;
}
}
try {
process.kill(pid, "SIGKILL");
} catch {
/* already gone */
}
info(`${name} did not exit in ${grace}s — killed`);
}
async function stopHarness(): Promise<void> {
const pids = harnessPids();
const held = pidOnPort(port);
if (held !== null && !pids.includes(held)) {
warn(`:${port} is held by pid ${held} (${cmdOf(held).slice(0, 70)}) — not one of ours, leaving it alone`);
}
if (pids.length === 0) {
note(`no harness running (port :${port} free)`);
if (!dry) {
try {
fs.rmSync(path.join(RUN, "harness.pid"));
} catch {
/* already gone */
}
}
return;
}
for (const pid of pids) await stopPid("harness", pid);
if (!dry) {
try {
fs.rmSync(path.join(RUN, "harness.pid"));
} catch {
/* already gone */
}
}
}
async function stopChromium(): Promise<void> {
if (keepWindow) {
note("leaving the Chromium window open (--keep-window)");
return;
}
const pids = chromiumPids(CHROMIUM_STOP);
if (pids.length === 0) {
note("no Chromium app window running");
return;
}
info("stopping Chromium app window");
if (dry) {
info(`would stop Chromium app window (${pids.join(" ")})`);
return;
}
for (const pid of pids) {
try {
process.kill(pid, "SIGTERM");
} catch {
/* already gone */
}
}
for (let i = 0; i < grace * 2; i++) {
await sleep(500);
if (chromiumPids(CHROMIUM_STOP).length === 0) {
info("Chromium stopped");
return;
}
}
for (const pid of chromiumPids(CHROMIUM_STOP)) {
try {
process.kill(pid, "SIGKILL");
} catch {
/* already gone */
}
}
info(`Chromium did not exit in ${grace}s — killed`);
}
// ── --status: print what is running (touches nothing) ────────────────────────
async function status(): Promise<void> {
const pad = (label: string) => label.padEnd(9);
const p = pidOnPort(port);
process.stdout.write(`${pad("harness")} :${port} `);
console.log(p !== null ? `up (pid ${p}, ${cwdOf(p)})` : "down");
process.stdout.write(`${pad("chromium")} `);
const ch = chromiumPids(CHROMIUM_ANY);
console.log(ch.length > 0 ? `up (${ch[0]} pid)` : "down");
if (fs.existsSync(path.join(ROOT, "scripts/start-audio-servers.sh"))) {
spawnSync("bash", [path.join(ROOT, "scripts/start-audio-servers.sh"), "status"], { stdio: "inherit" });
}
process.stdout.write(`${pad("llama")} `);
const base = (process.env["LLM_BASE_URL"] ?? "http://127.0.0.1:8080/v1").replace(/\/+$/, "");
const up = await fetch(`${base}/health`, { signal: AbortSignal.timeout(2000) })
.then((r) => r.ok)
.catch(() => false);
console.log(up ? "up" : "down");
}
if (doStatus) {
await status();
process.exit(0);
}
// ── stop ─────────────────────────────────────────────────────────────────────
await stopChromium();
await stopHarness();
if (doAudio) {
if (dry) info("would stop the three audio.cpp servers (asr/tts/turn)");
else spawnSync("bash", [path.join(ROOT, "scripts/start-audio-servers.sh"), "stop"], { stdio: "inherit" });
} else {
note("audio.cpp servers left running (use --audio to stop them)");
}
if (doAll) {
if (dry) {
info("would stop llama-server (systemd --user unit)");
} else {
const unit = spawnSync("systemctl", ["--user", "cat", "llama-server.service"], { stdio: "ignore" });
if (unit.status === 0) {
info("stopping llama-server (systemd --user unit)");
spawnSync("systemctl", ["--user", "stop", "llama-server.service"], { stdio: "inherit" });
} else {
warn("no llama-server.service under systemd --user — stop it yourself");
}
}
}
info("done");
scripts/ui-test.sh
#!/usr/bin/env bash
# ui-test.sh — run the browser-UI test-suite (tests/ui.ts) safely:
# * a throwaway harness on a spare port, so the window you are actually using keeps
# its WebSocket, its config and its model untouched;
# * a dedicated headless Chromium with its own profile — never your app window.
#
# scripts/ui-test.sh # the whole suite (check, reasoning, tool, model, smoke, rewind)
# scripts/ui-test.sh check smoke # just these
# UI_TEST_URL=http://127.0.0.1:8095/ scripts/ui-test.sh check # against a live instance
#
# Prereqs: llama-server on :8080 (the LLM leg), the audio.cpp servers (started on demand).
set -euo pipefail
ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
cd "$ROOT"
info() { printf '\033[1;34m[ui-test]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[ui-test]\033[0m %s\n' "$*" >&2; exit 1; }
pick_port() {
bun -e 'const net=require("node:net");const s=net.createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})'
}
HARNESS_PID="" CHROME_PID="" PROFILE=""
cleanup() {
local rc=$?
trap - EXIT INT TERM
[[ -n "$HARNESS_PID" ]] && { kill "$HARNESS_PID" 2>/dev/null || true; wait "$HARNESS_PID" 2>/dev/null || true; }
[[ -n "$CHROME_PID" ]] && { kill "$CHROME_PID" 2>/dev/null || true; }
[[ -n "$PROFILE" ]] && { pkill -f "user-data-dir=$PROFILE" 2>/dev/null || true; rm -rf "$PROFILE"; }
exit "$rc"
}
trap cleanup EXIT INT TERM
# ── prereqs ────────────────────────────────────────────────────────────────────
LLM_BASE_URL="${LLM_BASE_URL:-http://127.0.0.1:8080/v1}"
curl -sf -m 3 "${LLM_BASE_URL%/v1}/health" >/dev/null \
|| die "llama-server not reachable at $LLM_BASE_URL — start it first (systemctl --user start llama-server)"
"$ROOT/scripts/start-audio-servers.sh" start >/dev/null # idempotent
# ── the harness under test ─────────────────────────────────────────────────────
URL="${UI_TEST_URL:-}"
if [[ -z "$URL" ]]; then
PORT="$(pick_port)"
URL="http://127.0.0.1:$PORT/"
LOG="$ROOT/data/logs/ui-test-harness.log"
mkdir -p "$ROOT/data/run" "$ROOT/data/logs"
info "harness → $URL (log: ${LOG#"$ROOT"/})"
# its own pi namespace: the tests' turns must never land in your data/pi conversations,
# and its own chat file: the suite's turns must never land in your data/chat.json
LLM_VIA_PI=1 PI_CWD="$ROOT/data/run/ui-test-pi" CHAT_PERSIST=0 \
bun run src/cli.ts --web --no-browser --port "$PORT" >"$LOG" 2>&1 &
HARNESS_PID=$!
# coming up means a.start() finished → the LLM warmup (a cold model load can take minutes)
for _ in $(seq 1 300); do
curl -sf -m 2 "$URL" >/dev/null && break
kill -0 "$HARNESS_PID" 2>/dev/null || { tail -n 20 "$LOG" >&2; die "harness died on startup"; }
sleep 1
done
curl -sf -m 2 "$URL" >/dev/null || { tail -n 20 "$LOG" >&2; die "harness not up after 300 s (see ${LOG#"$ROOT"/})"; }
else
info "using the instance you pointed at: $URL"
fi
# ── a headless Chromium of our own ─────────────────────────────────────────────
CDP_PORT="$(pick_port)"
PROFILE="$(mktemp -d /tmp/ui-test-chrome.XXXXXX)"
CHROME="$(command -v chromium || command -v chromium-browser || command -v google-chrome-stable \
|| echo /usr/lib/chromium/chromium)"
[[ -x "$CHROME" ]] || die "no Chromium binary found"
info "chromium → CDP :$CDP_PORT (profile $PROFILE)"
"$CHROME" --headless --remote-debugging-port="$CDP_PORT" --user-data-dir="$PROFILE" \
--no-first-run --no-default-browser-check --disable-gpu --mute-audio \
--autoplay-policy=no-user-gesture-required about:blank \
>"$ROOT/data/logs/ui-test-chromium.log" 2>&1 &
CHROME_PID=$!
for _ in $(seq 1 50); do
curl -sf -m 2 "http://127.0.0.1:$CDP_PORT/json/version" >/dev/null && break
sleep 0.3
done
curl -sf -m 2 "http://127.0.0.1:$CDP_PORT/json/version" >/dev/null \
|| { tail -n 20 "$ROOT/data/logs/ui-test-chromium.log" >&2; die "Chromium CDP never came up"; }
# ── the suite ──────────────────────────────────────────────────────────────────
tests=("$@")
((${#tests[@]})) || tests=(all)
info "running: ${tests[*]}"
set +e
bun run tests/ui.ts --url "$URL" --cdp "http://127.0.0.1:$CDP_PORT" "${tests[@]}"
rc=$?
set -e
exit "$rc"
scripts/fetch-and-compile-audio-cpp.ts
#!/usr/bin/env bun
/**
* `bun run fetch-and-compile-audio.cpp` - fetch the latest audio.cpp **release** and
* compile `audiocpp_server` from its source (the official prebuilt binaries are built
* with `-march=native` on AVX-512 CI runners and SIGILL on CPUs without AVX-512 - see
* the "Why compile" note in README.md; fetching source, not a release asset, is the point).
*
* bun run fetch-and-compile-audio.cpp clone/update `vendor/audio.cpp` to the newest release and build it
* bun run fetch-and-compile-audio.cpp --tag v0.9.1 … pin one release instead of "latest"
* bun run fetch-and-compile-audio.cpp -h | --help
*
* What it does, in order:
* 1. resolve the newest tag from the GitHub releases API (fallback: `git ls-remote --tags`)
* 2. clone `vendor/audio.cpp` at that tag, or `git fetch` + check it out when it exists
* (a dirty checkout is refused, nothing is stashed or discarded behind your back)
* 3. `cmake` configure + build the `audiocpp_server` target (incremental on re-runs)
* 4. verify `vendor/audio.cpp/build/bin/audiocpp_server` and say how to start it
*
* The vendor directory is git-ignored: it is fetched on demand, never committed.
*
* Env:
* AUDIOCPP_VULKAN=ON|OFF force the Vulkan backend instead of auto-detecting
* (auto: ON when the Vulkan headers + `glslc` are installed)
* AUDIOCPP_SERVER=… where `scripts/start-audio-servers.sh` looks for the binary
* (defaults to the path this script builds)
*
* Needs: git, cmake, a C++ toolchain (Ninja when available), and network for step 1-2.
*/
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const ROOT = path.resolve(import.meta.dir, "..");
const REPO = "https://github.com/0xShug0/audio.cpp";
const VENDOR = path.join(ROOT, "vendor", "audio.cpp");
const BUILD = path.join(VENDOR, "build");
const BIN = path.join(BUILD, "bin", "audiocpp_server");
const tty = Boolean(process.stdout.isTTY);
const paint = (code: number, s: string) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
const green = (s: string) => paint(32, s);
const red = (s: string) => paint(31, s);
const yellow = (s: string) => paint(33, s);
const dim = (s: string) => paint(2, s);
const info = (s: string) => console.log(`${green("[audio.cpp]")} ${s}`);
const warn = (s: string) => console.error(`${yellow("[audio.cpp]")} ${s}`);
function die(s: string): never {
console.error(`${red("[audio.cpp]")} ${s}`);
process.exit(1);
}
type Res = { ok: boolean; out: string };
/** Run a command: output inherited when `capture` is unset, captured when it is set. */
function run(args: string[], capture = false): Res {
const r = spawnSync(args[0]!, args.slice(1), {
encoding: "utf8",
stdio: capture ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
cwd: ROOT,
});
return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
}
/** `command -v name` without assuming a `which` binary exists. */
function onPath(name: string): boolean {
return spawnSync("/bin/sh", ["-c", `command -v ${name} >/dev/null 2>&1`]).status === 0;
}
const human = (ms: number): string => {
const s = Math.round(ms / 1000);
return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
};
/** `v0.9.1` / `0.9.1` → comparable numbers; anything unparseable sorts last. */
function tagKey(tag: string): number[] | null {
const m = /^v?(\d+(?:\.\d+)*)/.exec(tag);
return m ? m[1]!.split(".").map(Number) : null;
}
function newer(a: string, b: string): boolean {
const ka = tagKey(a);
const kb = tagKey(b);
if (!ka || !kb) return false;
for (let i = 0; i < Math.max(ka.length, kb.length); i++) {
const d = (ka[i] ?? 0) - (kb[i] ?? 0);
if (d) return d > 0;
}
return false;
}
/** Newest release tag: GitHub API first, `git ls-remote` when the API is unreachable. */
async function latestTag(): Promise<string> {
try {
const r = await fetch("https://api.github.com/repos/0xShug0/audio.cpp/releases/latest", {
headers: { accept: "application/vnd.github+json" },
signal: AbortSignal.timeout(10_000),
});
if (r.ok) {
const tag = ((await r.json()) as { tag_name?: string }).tag_name;
if (tag) return tag;
}
warn(`GitHub API answered HTTP ${r.status} — falling back to git ls-remote`);
} catch (err) {
warn(`GitHub API unreachable (${err instanceof Error ? err.message : err}) — falling back to git ls-remote`);
}
const r = run(["git", "ls-remote", "--tags", "--refs", REPO], true);
if (!r.ok) die(`could not list releases: ${r.out}`);
const tags = r.out
.split("\n")
.map((l) => l.split("refs/tags/")[1])
.filter((t): t is string => Boolean(t) && tagKey(t!) !== null);
if (!tags.length) die("no tags found upstream");
return tags.reduce((best, t) => (newer(t, best) ? t : best));
}
/** Clone `vendor/audio.cpp` at `tag`, or move an existing clean clone to it. */
function fetchSource(tag: string): void {
if (!fs.existsSync(VENDOR)) {
fs.mkdirSync(path.dirname(VENDOR), { recursive: true });
info(`cloning ${REPO} @ ${tag} → ${path.relative(ROOT, VENDOR)} ${dim("(shallow)")}`);
const r = run(["git", "clone", "--depth", "1", "--branch", tag, REPO, VENDOR]);
if (!r.ok) die(`clone failed — is "${tag}" a real tag? (${r.out})`);
return;
}
if (!fs.existsSync(path.join(VENDOR, ".git")))
die(`${path.relative(ROOT, VENDOR)} exists but is not a git clone — remove it and re-run`);
const head = run(["git", "-C", VENDOR, "rev-parse", "HEAD"], true).out.trim();
// fetch exactly this tag (works for shallow clones too); offline + already local → continue
const f = run(["git", "-C", VENDOR, "fetch", "--force", "origin", `refs/tags/${tag}:refs/tags/${tag}`], true);
if (!f.ok) {
if (run(["git", "-C", VENDOR, "rev-parse", "--verify", `refs/tags/${tag}`], true).ok)
warn(`git fetch failed (${f.out}) — the tag is local already, continuing offline`);
else die(`could not fetch ${tag}: ${f.out}`);
}
const want = run(["git", "-C", VENDOR, "rev-parse", `${tag}^{commit}`], true).out.trim();
if (head === want) {
info(`source already at ${green(tag)}`);
return;
}
const dirty = run(["git", "-C", VENDOR, "status", "--porcelain"], true).out;
if (dirty)
die(
`${path.relative(ROOT, VENDOR)} has local changes — commit/stash/clean them first:\n` +
dirty
.split("\n")
.slice(0, 5)
.map((l) => ` ${l}`)
.join("\n")
);
info(`moving ${path.relative(ROOT, VENDOR)} ${dim(`(${head.slice(0, 8)} → ${tag})`)}`);
const c = run(["git", "-C", VENDOR, "checkout", "--force", tag], true);
if (!c.ok) die(`checkout ${tag} failed: ${c.out}`);
const now = run(["git", "-C", VENDOR, "describe", "--tags", "--exact-match"], true).out.trim();
info(`source at ${green(now || tag)}`);
}
/** Vulkan: honor AUDIOCPP_VULKAN, else auto-detect headers + `glslc`. */
function vulkanWanted(): boolean {
const forced = process.env.AUDIOCPP_VULKAN?.trim().toUpperCase();
if (forced === "ON") return true;
if (forced === "OFF") return false;
const headers =
fs.existsSync("/usr/include/vulkan/vulkan.h") ||
spawnSync("pkg-config", ["--exists", "vulkan"]).status === 0;
const glslc = onPath("glslc");
if (headers && glslc) return true;
warn(
`Vulkan toolchain not found (${!headers ? "no vulkan headers" : ""}${!headers && !glslc ? " and " : ""}${!glslc ? "no g...
Source README
Sonia Personal Assistant
Sonia is an LLM + STT + TTS voice assistant that needs no dedicated GPU and runs
in less than 15 GB of RAM, entirely on a local computer.
Multilingual and full-duplex: the mic stays open while the assistant talks - interrupt
it (barge-in) and it pauses, resumes or cancels accordingly. It mirrors whichever
language you speak (English/Italian tested).
Everything is local; the only external piece is an OpenAI-compatible endpoint (an
already-running llama-server, managed outside this project). Optionally and
experimentally, an existing pi executable can replace it - see AI models used.
Contents - Features · Architecture ·
Models · Installation · Security ·
Inspiration · License
Main features
- True full-duplex listening - the mic never closes; answers can be interrupted mid-sentence. Barge-in is echo-aware: backchannels and Sonia's own voice resume it, real speech cancels the reply and keeps what was already said.
- Streaming ASR + layered turn detection - partial transcripts drive the live
🎤 …line; a turn ends on Silero VAD + silence gaps + the Smart Turn classifier, with a 4-tokenLISTEN/REPLYprompt only when the pause is ambiguous. - Bilingual IT/EN - the model mirrors your language per turn;
LOCALE=it|enlocalizes UI, spoken sentences and tool results, and a timer rings in the language of the request that set it. - Function-calling tools - datetime, timers, watches, notes, skills, shell (6 tool rounds per turn), gated by a tool gate that re-checks each call against your words (so a 4B model can't fire spurious tools) and by
CMD_MODE=off|ask|autofor risky shell. The read-only skill tools (read_skill,list_skills) are exempt from the gate - they load context and act on nothing, while therun_commandthat uses them is still judged. - Externally managed skills -
SKILL.mdpackages are not owned by this project: pointSKILLS_DIRat the directory you already use (pi's, opencode's, any other harness's) and activate each per-turn from the UI settings drawer. - Two interchangeable LLM legs - the endpoint (default) or, optionally, an existing
piexecutable (LLM_VIA_PI=1, toggled at runtime) - plus a browser app-window UI and--cli/--textterminal modes. - A task banner over the chat - every pending timer, running command and active watch, with its countdown and a ✕ that stops that task alone. Watches repeat a read-only command at an interval (≥ 5 s), optionally staying quiet until the output matches some text.
- ⟲ rewind + persistent chat - everything after the clicked line goes (later turns and their context, in transcript and model history alike, on both legs); the line itself and pending tasks stay. 🧹 erases the whole chat, this only the part you regret. State lives in
data/chat.json, so a restart resumes where you left off (CHAT_PERSIST=0disables).
Architecture
One TypeScript hub process owns the 32 ms frame loop, the queues and the state machine.
The speech stack is three audio.cpp servers, the LLM is an external llama-server,
VAD runs in-process.
mic (32 ms frames)
│
├─► Silero VAD (in-process, ONNX) [src/vad.ts]
▼
audio.cpp :8091 streaming ASR ── partial + final transcript
▼
HARNESS src/harness/harness.ts — turn-end decision:
│ pause ≥200ms → Smart Turn (:8093) ≥0.5 ⇒ end · pause ≥900ms + "not finished"
│ → 4-token LLM prompt LISTEN/REPLY (prompts/decision.txt)
▼
LLM leg (src/clients/llm.ts / pi.ts — one interface, two backends)
│ A) llama-server :8080 (OpenAI API) B) `pi -c` CLI (experimental, optional)
│ + tools + tool gate (prompts/tool-gate.txt) filtering each round
▼
streamed text → SentenceSplitter → sentences
▼
audio.cpp :8092 TTS ── SSE PCM16 → paced Player (20 ms slices)
▲ barge-in: pause / resume / flush within ~one buffer
| Port | Service | Started by |
|---|---|---|
:8080 |
llama-server - OpenAI-compatible LLM (external) |
you: systemctl --user start llama-server |
:8091 / :8092 / :8093 |
audio.cpp ASR (Vulkan) / TTS / Smart Turn (CPU) | scripts/start-audio-servers.sh start |
:8095 |
harness web UI - GET /, GET /ws (JSON events + binary PCM both ways) |
bun run start (scripts/run.ts) |
Code layout (src/, Bun + TypeScript)
Entry points cli.ts (--web/--cli/--text/offline WAV), web.ts, config.ts (every
knob = env var), i18n.ts · event loop in harness/ · model clients in clients/
(audiocpp.ts, llm.ts, pi.ts, sse.ts, think.ts) · capabilities tools.ts,command-policy.ts, shell-runner.ts, skills.ts, vad.ts · audio in audio/, UI instatic/index.html (single-file, localized) · prompts in prompts/.
AI models used
| Model | Role | Served by / device | Size |
|---|---|---|---|
Qwen3.5-4B-MTP (Q4_K_M) |
main LLM: replies, tools, turn judge, tool gate | external llama-server :8080, Vulkan |
2.7 GiB (not fetched by this repo) |
Voxtral Mini 4B Realtime 2602 (q4_k) |
streaming ASR | audio.cpp :8091, Vulkan (B390 iGPU) |
2.9 GB |
Pocket TTS (bf16, 5 language packs, 26 voices) |
streaming TTS | audio.cpp :8092, CPU |
~209 MiB/pack |
Smart Turn v3.2 (f32) |
end-of-turn classifier | audio.cpp :8093, CPU |
31 MB |
Silero VAD v5 (onnx) |
voice activity detection | in-process, CPU | 2.3 MB |
experimental + optional: any pi --list-models id |
alternative LLM leg (LLM_VIA_PI=1); harness tools are off in this mode |
pi CLI subprocess |
- |
Speech models go to models/ via bun run models-download (~3.3 GiB total,
idempotent/resumable) from the audio.cpp GGUF mirror;
the LLM .gguf is never downloaded here - it belongs to your external llama-server.
No dedicated GPU: ASR uses whatever Vulkan device is available (an iGPU is fine) orASR_BACKEND=cpu; TTS, Smart Turn and the VAD are CPU-only.
Installation
Prerequisites
Bun ≥ 1.x (runtime + tests, no build step) · llama-server on :8080 (the LLM
leg - not started by this repo, must answer /health) · cmake + Ninja + Vulkan SDK
for the one-time build of vendor/audio.cpp (no Vulkan device? -DENGINE_ENABLE_VULKAN=OFF
ASR_BACKEND=cpu) · Chromium inPATHfor the browser UI (optional:--no-browser+ any browser) · PipeWire for mic/speaker in--climode (browser mode
uses the tab's audio).
Steps
bun install # 1. dependencies
cp .env.example .env # 2. config — set SKILLS_DIR (see "Skills") and
# LLM_BASE_URL / LLM_MODEL, or LLM_VIA_PI=1 to use an existing `pi` executable
bun run fetch-and-compile-audio.cpp # 3. fetch + compile the latest audio.cpp release
# (clone → cmake → build, incremental; vendor/ is git-ignored. Or point
# AUDIOCPP_SERVER=/path/to/audiocpp_server at an existing binary)
Why compile instead of using the official prebuilt binaries?
Upstream (0xShug0/audio.cpp) ships Ubuntu/macOS/Windows
binaries with every release, but they are built withENGINE_ENABLE_NATIVE_CPU=ON
(i.e.-march=native) on AVX-512 CI runners, so the AVX-512 instructions end up inggml_cpu_inititself. On CPUs without AVX-512 (all Intel 12th-gen+) the process dies
withSIGILLright after Vulkan init - with bothbackend: vulkanandbackend: cpu, since the CPU backend is always registered.
Verified against releasev0.9.1(gdb:ggml_cpu_init+607: vpbroadcastd %edx,%ymm17,
an AVX-512VL instruction). A local build works because-march=nativethen means
plain AVX2/AVX-VNNI. (Not a Mesa/driver issue: neither binary bundles a driver - both
link onlylibvulkan.so.1and use the host's ICD, and the official one did enumerate
the iGPU correctly before crashing.)
Revisit once upstream releases baseline-ISA binaries (e.g.GGML_NATIVE=OFFor-DENGINE_ENABLE_CPU_ALL_VARIANTS=ON): thenAUDIOCPP_SERVER=can just point at the
downloaded release and thevendor/audio.cppclone (1.2 GB) can go away.
bun run models-download # 4. speech models (~3.3 GiB, resumable)
bun run models-download --check # verify only: ✓/✗ per file, exit 1 if missing
bun run models-download --only vad # one package only (asr|tts|turn|vad)
systemctl --user start llama-server # 5. the LLM (external, your own unit/config)
bun run runtime-check # 6. verify everything answers
Run
bun run start # three audio.cpp servers + the harness → http://127.0.0.1:8095/
bun run stop # …plus the audio servers
bun run start --no-browser # same, print the URL only
bun run start --cli # terminal through PipeWire mic/speaker
bun run start --text # typed input
bun run stop --all # …plus llama-server (the systemd unit)
bun run status # what is running + does it answer (--ui: + the browser-UI suite)
bun run stop --status # …the processes table alone
bun run scripts/stop.ts # only the harness + its Chromium window
scripts/run.ts checks llama-server, starts the audio servers (waiting on /health),
then the harness - which warms up (LLM + TTS rate + ASR Vulkan graph) before printingready, so a cold model load can take minutes. Everything private or regeneratable
lives in data/ (git-ignored): pid files, server configs, logs, the browser profile,
notes/skills/voice choices and the conversation (chat.json).
Development
bun run test # unit tests (pretest = tsc --noEmit, strict)
bun run test:all # + the WebSocket suite (needs models/silero)
bun run typecheck
bun run status # what is running + are llama-server/ASR/TTS/turn/harness up?
bun run status --ui # …+ the browser-UI suite (headless Chromium)
bun run runtime-check # the endpoint probe alone (the second half of `status`)
scripts/ui-test.sh # the browser-UI suite alone (the `--ui` part)
CI (.github/workflows/ci.yml) is hermetic: llama-server and the audio.cpp servers are
mocked, temp dirs hold the state, and the only file fetched is the Silero VAD. The opt-in
job (WITH_MODELS=true repo variable) fetches the full ~3.3 GiB of speech models - also
checking the download plan still resolves - and re-runs the suite with them.
Env vars: .env.example documents all of them (defaults insrc/config.ts); the UI's ⚙ drawer overrides some at runtime.
Skills
Skills are plain SKILL.md packages (instructions + scripts/) read from a single
directory - Sonia owns none of them. Set SKILLS_DIR in .env to a folder your other
tooling already uses: ~/.pi/agent/skills (the pi coding agent, the default), your
opencode skills/plugins directory, or any dir with <name>/SKILL.md. Which are
active is chosen per-session in the UI drawer (persisted in data/skills.json); inactive
skills never reach the prompt and read_skill refuses them.
Security
Everything is localhost by design - no login, no TLS: do not pass a non-loopback--host without your own protection in front of it. run_command spawns bash -c as
your user with no sandbox; the policy in src/command-policy.ts
classes read-only commands as safe, while shell metacharacters and sensitive paths
(.ssh, keys, .env) always stop at the approval prompt in CMD_MODE=ask.
The /ws WebSocket - the control channel of the whole app - accepts a connection only
from this server's own origin (originAllowed in src/web.ts): browsers
do not apply the same-origin policy to WebSockets, so without that check any page you
visit could open ws://127.0.0.1:8095/ws, read the transcript and push commands at it.
A page served elsewhere is refused at the upgrade; a second window of your own takes the
single client slot over, telling the first one with a session_taken event.
Skills are out of this project's scope - verifying them is yours. Commands insideSKILLS_DIR are implicitly trusted: they skip both the whitelist and the approval
prompt and run hands-free as your user. Before activating a skill, check what itsSKILL.md tells the model to do and which CLI commands it calls - Sonia only relays
them. Prefer CMD_MODE=ask while trying new ones.
Inspiration
This project answers Speakrail - a
low-latency, fully-local voice assistant that listens while it talks, but requires
CUDA/Nvidia hardware and more resources.
scripts/start-audio-servers.sh
#!/usr/bin/env bash
# start-audio-servers.sh — launch the three audio.cpp servers of Stack A:
# ASR : Voxtral Mini 4B Realtime q4_k, streaming (default Vulkan, :8091)
# ASR_PRECISION=q8_0|bf16 serves another package of the same model id
# TTS : Pocket TTS Italian, streaming (default CPU, :8092)
# TURN : Smart Turn v3.2 (default CPU, :8093)
# The LLM is NOT started here: the harness uses the already-running llama-server (:8080).
#
# Build prereq: audio.cpp is vendored at vendor/audio.cpp in this repo and must be built:
# bun run fetch-and-compile-audio.cpp (fetches the latest release + compiles it)
# …or by hand: cmake -S vendor/audio.cpp -B vendor/audio.cpp/build -G Ninja \
# -DCMAKE_BUILD_TYPE=Release -DENGINE_ENABLE_VULKAN=ON \
# && cmake --build vendor/audio.cpp/build --target audiocpp_server
# Or point AUDIOCPP_SERVER at an already-built audiocpp_server binary.
# The `start` action prints the audio.cpp version it is about to run (git tag + build);
# each server logs to data/logs/<name>.log (pids and configs stay in data/run/).
#
# Usage: scripts/start-audio-servers.sh [start [asr|tts|turn ...]|stop|status]
# Env: ASR_BACKEND=vulkan TTS_BACKEND=cpu TURN_BACKEND=cpu THREADS=8
# ASR_PORT=8091 TTS_PORT=8092 TURN_PORT=8093
# ASR_PRECISION=q4_k|q8_0|bf16 TTS_PRECISION=bf16|q8_0
# TTS_LANG=italian|english|german|portuguese|spanish (the Pocket TTS pack +
# its `language` load option; `bun run models-download` fetches it)
# (a project .env is honoured too; a real environment variable always wins)
set -euo pipefail
ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
# project .env (same keys as src/config.ts); a real environment variable always wins
if [[ -f "$ROOT/.env" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%$'\r'}"
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
[[ -n "${!key+x}" ]] || export "$line" || true
fi
done < "$ROOT/.env"
fi
BIN="${AUDIOCPP_SERVER:-$ROOT/vendor/audio.cpp/build/bin/audiocpp_server}"
RUN="$ROOT/data/run" # pid files + server configs (state)
LOGS="$ROOT/data/logs" # *.log only — git-ignored, see .gitignore
M="${MODELS_DIR:-$ROOT/models}"
case "$M" in /*) ;; *) M="$ROOT/${M#./}" ;; esac
mkdir -p "$RUN" "$LOGS"
ASR_BACKEND="${ASR_BACKEND:-vulkan}"
TTS_BACKEND="${TTS_BACKEND:-cpu}"
TURN_BACKEND="${TURN_BACKEND:-cpu}"
ASR_PORT="${ASR_PORT:-8091}"
TTS_PORT="${TTS_PORT:-8092}"
TURN_PORT="${TURN_PORT:-8093}"
THREADS="${THREADS:-8}"
TTS_PRECISION="${TTS_PRECISION:-bf16}"
TTS_LANG="${TTS_LANG:-italian}"
ASR_PRECISION="${ASR_PRECISION:-q4_k}"
ASR_GGUF="${ASR_GGUF:-$M/Voxtral-Mini-4B-Realtime-2602-GGUF/voxtral-mini-4b-realtime-2602-$ASR_PRECISION.gguf}"
TTS_GGUF="${TTS_GGUF:-$M/PocketTTS-GGUF/$TTS_LANG/pocket-tts-$TTS_LANG-$TTS_PRECISION.gguf}"
TURN_GGUF="${TURN_GGUF:-$M/Smart-Turn-v3-GGUF/smart-turn-v3.2-f32.gguf}"
ASR_BATCH_TOKENS="${ASR_BATCH_TOKENS:-4}"
info(){ printf "\033[1;34m[audio]\033[0m %s\n" "$*"; }
warn(){ printf "\033[1;33m[audio]\033[0m %s\n" "$*" >&2; }
die(){ printf "\033[1;31m[err]\033[0m %s\n" "$*" >&2; exit 1; }
# Which audio.cpp is this? — the tag the vendored source sits at plus the binary's own
# `--version` (commit, compiler, backends). Printed by `start`, i.e. by `bun run start` too.
print_version() {
local tag build
tag="$(git -C "$ROOT/vendor/audio.cpp" describe --tags --exact-match 2>/dev/null || true)"
build="$(timeout 5 "$BIN" --version 2>/dev/null || true)"
info "audio.cpp ${tag:-${build#audio.cpp }} — ${BIN#"$ROOT"/}"
[[ -n "$build" ]] && printf '%s\n' "$build" | tail -n +2 | sed 's/^/ /'
return 0
}
write_configs() {
cat > "$RUN/asr.json" <<JSON
{
"host": "127.0.0.1", "port": $ASR_PORT, "backend": "$ASR_BACKEND", "device": 0,
"threads": $THREADS, "lazy_load": false, "busy_timeout_ms": 0,
"live_ingest": { "total_timeout_ms": 0, "idle_timeout_ms": 60000 },
"models": [
{ "id": "voxtral", "family": "voxtral_realtime", "path": "$ASR_GGUF",
"task": "asr", "mode": "streaming",
"session_options": { "voxtral_realtime.stream_batch_tokens": "$ASR_BATCH_TOKENS" } }
]
}
JSON
cat > "$RUN/tts.json" <<JSON
{
"host": "127.0.0.1", "port": $TTS_PORT, "backend": "$TTS_BACKEND", "device": 0,
"threads": $THREADS, "lazy_load": false,
"models": [
{ "id": "pocket-it", "family": "pocket_tts", "path": "$TTS_GGUF",
"task": "tts", "mode": "streaming",
"load_options": { "language": "$TTS_LANG" } }
]
}
JSON
cat > "$RUN/turn.json" <<JSON
{
"host": "127.0.0.1", "port": $TURN_PORT, "backend": "$TURN_BACKEND", "device": 0,
"threads": 4, "lazy_load": false,
"models": [
{ "id": "smart-turn", "family": "smart_turn", "path": "$TURN_GGUF",
"task": "turn", "mode": "offline" }
]
}
JSON
}
start_one() { # name port
local name="$1" port="$2"
if ss -tlnH 2>/dev/null | grep -qE "127.0.0.1:${port}\b"; then
info "$name already listening on :$port"; return
fi
info "starting $name on :$port → ${LOGS#"$ROOT"/}/$name.log"
nohup setsid "$BIN" --config "$RUN/$name.json" --no-ui --log >"$LOGS/$name.log" 2>&1 </dev/null &
echo $! > "$RUN/$name.pid"
}
wait_ready() { # name port
local name="$1" port="$2"
for _ in $(seq 1 240); do
if curl -sf -m 2 "http://127.0.0.1:$port/health" >/dev/null 2>&1; then
info "$name ready"; return 0
fi
if [[ -f "$RUN/$name.pid" ]] && ! kill -0 "$(cat "$RUN/$name.pid")" 2>/dev/null; then
tail -n 20 "$LOGS/$name.log" >&2; die "$name died"
fi
sleep 1
done
die "$name not ready after 240 s (see ${LOGS#"$ROOT"/}/$name.log)"
}
stop_all() {
local n port pid i
for n in asr tts turn; do
case "$n" in asr) port=$ASR_PORT ;; tts) port=$TTS_PORT ;; turn) port=$TURN_PORT ;; esac
if [[ -f "$RUN/$n.pid" ]]; then
kill "$(cat "$RUN/$n.pid")" 2>/dev/null && info "stopped $n" || true
rm -f "$RUN/$n.pid"
fi
# stale pid file / started by hand → fall back to whoever holds the port
pid="$(ss -tlnpH "sport = :$port" 2>/dev/null | grep -oE 'pid=[0-9]+' | head -n1 | cut -d= -f2 || true)"
if [[ -n "$pid" ]]; then
kill "$pid" 2>/dev/null && info "stopped $n on :$port (pid $pid)" || true
fi
done
for n in asr tts turn; do
case "$n" in asr) port=$ASR_PORT ;; tts) port=$TTS_PORT ;; turn) port=$TURN_PORT ;; esac
for i in $(seq 1 20); do
ss -tlnH "sport = :$port" 2>/dev/null | grep -q . || break
sleep 0.5
done
ss -tlnH "sport = :$port" 2>/dev/null | grep -q . \
&& warn ":$port still listening — kill it by hand (pid $(ss -tlnpH "sport = :$port" | grep -oE 'pid=[0-9]+' | head -n1))" || true
done
}
case "${1:-start}" in
start)
[[ -x "$BIN" ]] || die "missing $BIN — run: bun run fetch-and-compile-audio.cpp (or set AUDIOCPP_SERVER)"
shift || true
print_version
NAMES=("$@"); (( ${#NAMES[@]} )) || NAMES=(asr tts turn)
write_configs
for n in "${NAMES[@]}"; do
case "$n" in
asr) [[ -f "$ASR_GGUF" ]] || die "missing $ASR_GGUF — fetch it with: bun run models-download (ASR_PRECISION=$ASR_PRECISION)"; start_one asr "$ASR_PORT" ;;
tts) [[ -f "$TTS_GGUF" ]] || die "missing $TTS_GGUF — fetch it with: bun run models-download (TTS_LANG=$TTS_LANG, TTS_PRECISION=$TTS_PRECISION)"; start_one tts "$TTS_PORT" ;;
turn) [[ -f "$TURN_GGUF" ]] || die "missing $TURN_GGUF — fetch it with: bun run models-download"; start_one turn "$TURN_PORT" ;;
*) die "unknown server $n (asr|tts|turn)" ;;
esac
done
for n in "${NAMES[@]}"; do
case "$n" in asr) wait_ready asr "$ASR_PORT" ;; tts) wait_ready tts "$TTS_PORT" ;; turn) wait_ready turn "$TURN_PORT" ;; esac
done
;;
stop) stop_all ;;
status)
for p in "asr $ASR_PORT" "tts $TTS_PORT" "turn $TURN_PORT"; do
set -- $p; printf "%-5s :%s " "$1" "$2"; curl -s -m 2 "http://127.0.0.1:$2/health" || printf "down"; echo
done ;;
*) die "usage: $0 [start|stop|status]" ;;
esac
scripts/run.ts
/**
* `bun run start` — start the audio.cpp servers (if needed) and the full-duplex harness.
* (Formerly the root-level ./run.sh; same steps, same arguments, same exit codes.)
* The LLM is the already-running llama-server on :8080 (Qwen3.5-4B-MTP-Q4_K_M).
*
* bun run start browser UI: opens Chromium (mic + speaker + AEC) once ready
* bun run start --no-browser same, but just print the URL (http://127.0.0.1:8095)
* bun run start --cli terminal mode through the default PipeWire mic/speaker
* bun run start --text type instead of speaking
* bun run start --input-wav 1:q.wav --no-speaker --output-wav out.wav (offline test)
* bun run stop stop what this script started (scripts/stop.ts)
*
* Prereqs: llama-server on :8080 (LLM leg) and audio.cpp built at vendor/audio.cpp
* (see scripts/start-audio-servers.sh for the cmake build command).
*
* `.env` needs no parsing here: Bun itself exports every key of it into `process.env`
* when it starts this file (a shell variable already set still wins), and every child
* we spawn inherits that environment — the hand-rolled reader run.sh had is obsolete.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { Config } from "../src/config";
const ROOT = path.resolve(import.meta.dir, "..");
process.chdir(ROOT);
const cfg = Config.fromEnv();
// ── 1. the LLM leg ───────────────────────────────────────────────────────────
// llama-server is needed by the API leg, and by the pi leg too when pi's model is
// served by it (the default llamacpp/Qwen3.5-…): only a non-local PI_MODEL makes
// the check pointless.
const needLlama = !cfg.llmViaPi || cfg.piModel.startsWith("llamacpp/");
if (needLlama) {
// `${LLM_BASE_URL%/v1}/health` as run.sh did it: strip a trailing /v1, ask /health.
const base = cfg.llmBaseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
const up = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) })
.then((r) => r.ok)
.catch(() => false);
if (!up) {
console.error(
`llama-server not reachable at ${cfg.llmBaseUrl} — start it first (e.g. systemctl --user start llama-server)`,
);
process.exit(1);
}
}
// ── 2. the audio.cpp servers (idempotent: already-running ones are left alone) ──
{
const r = spawnSync("bash", [path.join(ROOT, "scripts/start-audio-servers.sh"), "start"], {
cwd: ROOT,
stdio: "inherit",
});
if (r.status !== 0) process.exit(r.status ?? 1);
}
// ── 3. the harness ───────────────────────────────────────────────────────────
// Which mode: `--cli` first and foremost, then the offline-WAV flags, else the browser.
const argv = process.argv.slice(2);
const cliArgs: string[] =
argv[0] === "--cli"
? argv.slice(1)
: argv.includes("--text") || argv.includes("--input-wav")
? argv
: ["--web", ...argv];
const child = spawn(process.execPath, ["run", "src/cli.ts", ...cliArgs], {
cwd: ROOT,
stdio: "inherit",
});
// pid file for `bun run stop` — the pid that actually listens on :8095, not ours.
fs.mkdirSync(path.join(ROOT, "data/run"), { recursive: true });
fs.writeFileSync(path.join(ROOT, "data/run/harness.pid"), `${child.pid}\n`);
// a Ctrl+C in the terminal reaches both of us (same process group); pass it on anyway
// for the case where only this wrapper is signalled (systemd, `kill <pid>`).
for (const sig of ["SIGINT", "SIGTERM"] as const) {
process.on(sig, () => {
if (child.exitCode === null && child.signalCode === null) child.kill(sig);
});
}
const [code, signal] = await new Promise<[number | null, NodeJS.Signals | null]>((res) => {
child.once("close", (c, s) => res([c, s]));
});
// a startup failure (EADDRINUSE, warmup, LLM/TTS down) must not look like a success:
// scripts/run.ts and any caller see a non-zero exit code. A child that dies from a signal
// reports 128+N, the same the shell used to return after `exec bun run src/cli.ts`.
process.exit(signal ? 128 + (os.constants.signals[signal] ?? 1) : (code ?? 1));
scripts/stop.ts
/**
* `bun run stop` — stop what `bun run start` started (the mirror of scripts/run.ts).
* (Formerly the root-level ./stop.sh; same flags, same discovery rules, same output.)
*
* bun run stop harness web server (:8095) + its Chromium app window
* bun run stop --audio … plus the three audio.cpp servers (asr/tts/turn)
* bun run stop --all … plus llama-server (systemctl --user stop llama-server)
* bun run stop --port 8123 a harness listening on another port (leftover test instance)
* bun run stop --keep-window stop the server but leave the Chromium window open
* bun run stop --grace 30 seconds to wait for a polite exit before SIGKILL
* bun run stop --dry-run print what would be stopped, stop nothing
* bun run stop --status print what is running (touches nothing)
*
* llama-server is external (README §Ports): only --all stops it.
*/
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const ROOT = path.resolve(import.meta.dir, "..");
const RUN = path.join(ROOT, "data", "run");
const c = {
blue: "\x1b[1;34m",
gray: "\x1b[1;90m",
yellow: "\x1b[1;33m",
red: "\x1b[1;31m",
off: "\x1b[0m",
};
const info = (m: string) => console.log(`${c.blue}[stop]${c.off} ${m}`);
const note = (m: string) => console.log(`${c.gray}[stop]${c.off} ${m}`);
const warn = (m: string) => console.log(`${c.yellow}[stop]${c.off} ${m}`);
const die = (m: string): never => {
console.error(`${c.red}[stop]${c.off} ${m}`);
process.exit(1);
};
const USAGE = `bun run stop — stop what \`bun run start\` started (see scripts/stop.ts).
--audio … plus the three audio.cpp servers (asr/tts/turn)
--all … plus llama-server (systemd --user unit)
--port 8123 a harness listening on another port (leftover test instance)
--keep-window stop the server but leave the Chromium window open
--grace 30 seconds to wait for a polite exit before SIGKILL
--dry-run print what would be stopped, stop nothing
--status, -s print what is running (touches nothing)`;
// ── arguments ────────────────────────────────────────────────────────────────
const argv = process.argv.slice(2);
const port0 = Number(process.env["HARNESS_PORT"] ?? 8095);
if (!Number.isInteger(port0) || port0 < 1 || port0 > 65535) die(`HARNESS_PORT is not a port: ${port0}`);
let port = port0;
let grace = 10;
let doAudio = false;
let doAll = false;
let doStatus = false;
let keepWindow = false;
let dry = false;
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
const next = (flag: string): string => {
const v = argv[++i];
if (v === undefined) die(`${flag} needs a value`);
return v;
};
switch (a) {
case "--audio": doAudio = true; break;
case "--all": doAudio = true; doAll = true; break;
case "--keep-window": keepWindow = true; break;
case "--status": case "-s": doStatus = true; break;
case "--dry-run": case "-n": dry = true; break;
case "--port": port = Number(next("--port")); break;
case "--grace": grace = Number(next("--grace")); break;
case "-h": case "--help": console.log(USAGE); process.exit(0); break;
default: die(`unknown argument ${a} (try --help)`);
}
}
if (!Number.isInteger(port) || port < 1 || port > 65535) die(`--port needs a port`);
if (!Number.isFinite(grace) || grace < 0) die(`--grace needs seconds`);
// ── discovery (the /proc walk replaces pgrep/ss parsing where it helps) ──────
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function allPids(): number[] {
const out: number[] = [];
for (const name of fs.readdirSync("/proc")) if (/^\d+$/.test(name)) out.push(Number(name));
return out;
}
function cmdOf(pid: number): string {
try {
return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ").trim();
} catch {
return "";
}
}
function cwdOf(pid: number): string {
try {
return fs.readlinkSync(`/proc/${pid}/cwd`);
} catch {
return "";
}
}
/** basename of the running binary — `bun`/`node` for every harness instance. */
function exeOf(pid: number): string {
try {
return path.basename(fs.readlinkSync(`/proc/${pid}/exe`));
} catch {
return "";
}
}
function alive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (e) {
return (e as NodeJS.ErrnoException).code === "EPERM"; // exists, not ours to signal
}
}
/** pid of the listener on `p`, null if none — same `ss -tlnpH "sport = :$p"` as stop.sh. */
function pidOnPort(p: number): number | null {
const r = spawnSync("ss", ["-tlnpH", `sport = :${p}`], { encoding: "utf8" });
const m = (r.stdout ?? "").match(/pid=(\d+)/);
return m ? Number(m[1]) : null;
}
/**
* Is this pid one of *our* harness processes (never an unrelated process on the port)?
*
* cwd/cmdline say "it is a cli.ts of this checkout"; the exe check says "and it really
* *is* the interpreter running it" — without it any shell, editor or agent whose command
* line merely quotes both the repo path and `cli.ts` (a grep, a `bun test` line, a paste)
* would be treated as the harness and SIGTERMed. The bash original had that hole too.
*/
function isHarness(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
if (!alive(pid)) return false;
const exe = exeOf(pid);
if (exe !== "bun" && exe !== "node") return false;
const cwd = cwdOf(pid);
const cmd = cmdOf(pid);
return (cwd === ROOT || cwd.startsWith(`${ROOT}/`) || cmd.includes(ROOT)) && cmd.includes("cli.ts");
}
function harnessPids(): number[] {
const found = new Set<number>();
const pf = path.join(RUN, "harness.pid");
try {
const v = Number(fs.readFileSync(pf, "utf8").trim());
if (Number.isInteger(v) && v > 0) found.add(v);
} catch {
/* no pid file */
}
const onPort = pidOnPort(port);
if (onPort !== null) found.add(onPort);
for (const pid of allPids()) if (/cli\.ts --web/.test(cmdOf(pid))) found.add(pid);
return [...found].filter(isHarness).sort((a, b) => a - b);
}
const CHROMIUM_STOP = `user-data-dir=${RUN}/chromium-profile`; // exact flag the app window gets
const CHROMIUM_ANY = "chromium-profile"; // looser pattern, for --status (as stop.sh had it)
function chromiumPids(pattern: string): number[] {
return allPids().filter((pid) => cmdOf(pid).includes(pattern)).sort((a, b) => a - b);
}
// ── stop one pid: graceful TERM → wait grace → KILL ──────────────────────────
async function stopPid(name: string, pid: number): Promise<void> {
if (dry) {
info(`would stop ${name} (pid ${pid}, ${cmdOf(pid).slice(0, 60)})`);
return;
}
info(`stopping ${name} (pid ${pid})`);
try {
process.kill(pid, "SIGTERM");
} catch {
/* already gone */
}
for (let i = 0; i < grace * 2; i++) {
await sleep(500);
if (!alive(pid)) {
info(`${name} stopped`);
return;
}
}
try {
process.kill(pid, "SIGKILL");
} catch {
/* already gone */
}
info(`${name} did not exit in ${grace}s — killed`);
}
async function stopHarness(): Promise<void> {
const pids = harnessPids();
const held = pidOnPort(port);
if (held !== null && !pids.includes(held)) {
warn(`:${port} is held by pid ${held} (${cmdOf(held).slice(0, 70)}) — not one of ours, leaving it alone`);
}
if (pids.length === 0) {
note(`no harness running (port :${port} free)`);
if (!dry) {
try {
fs.rmSync(path.join(RUN, "harness.pid"));
} catch {
/* already gone */
}
}
return;
}
for (const pid of pids) await stopPid("harness", pid);
if (!dry) {
try {
fs.rmSync(path.join(RUN, "harness.pid"));
} catch {
/* already gone */
}
}
}
async function stopChromium(): Promise<void> {
if (keepWindow) {
note("leaving the Chromium window open (--keep-window)");
return;
}
const pids = chromiumPids(CHROMIUM_STOP);
if (pids.length === 0) {
note("no Chromium app window running");
return;
}
info("stopping Chromium app window");
if (dry) {
info(`would stop Chromium app window (${pids.join(" ")})`);
return;
}
for (const pid of pids) {
try {
process.kill(pid, "SIGTERM");
} catch {
/* already gone */
}
}
for (let i = 0; i < grace * 2; i++) {
await sleep(500);
if (chromiumPids(CHROMIUM_STOP).length === 0) {
info("Chromium stopped");
return;
}
}
for (const pid of chromiumPids(CHROMIUM_STOP)) {
try {
process.kill(pid, "SIGKILL");
} catch {
/* already gone */
}
}
info(`Chromium did not exit in ${grace}s — killed`);
}
// ── --status: print what is running (touches nothing) ────────────────────────
async function status(): Promise<void> {
const pad = (label: string) => label.padEnd(9);
const p = pidOnPort(port);
process.stdout.write(`${pad("harness")} :${port} `);
console.log(p !== null ? `up (pid ${p}, ${cwdOf(p)})` : "down");
process.stdout.write(`${pad("chromium")} `);
const ch = chromiumPids(CHROMIUM_ANY);
console.log(ch.length > 0 ? `up (${ch[0]} pid)` : "down");
if (fs.existsSync(path.join(ROOT, "scripts/start-audio-servers.sh"))) {
spawnSync("bash", [path.join(ROOT, "scripts/start-audio-servers.sh"), "status"], { stdio: "inherit" });
}
process.stdout.write(`${pad("llama")} `);
const base = (process.env["LLM_BASE_URL"] ?? "http://127.0.0.1:8080/v1").replace(/\/+$/, "");
const up = await fetch(`${base}/health`, { signal: AbortSignal.timeout(2000) })
.then((r) => r.ok)
.catch(() => false);
console.log(up ? "up" : "down");
}
if (doStatus) {
await status();
process.exit(0);
}
// ── stop ─────────────────────────────────────────────────────────────────────
await stopChromium();
await stopHarness();
if (doAudio) {
if (dry) info("would stop the three audio.cpp servers (asr/tts/turn)");
else spawnSync("bash", [path.join(ROOT, "scripts/start-audio-servers.sh"), "stop"], { stdio: "inherit" });
} else {
note("audio.cpp servers left running (use --audio to stop them)");
}
if (doAll) {
if (dry) {
info("would stop llama-server (systemd --user unit)");
} else {
const unit = spawnSync("systemctl", ["--user", "cat", "llama-server.service"], { stdio: "ignore" });
if (unit.status === 0) {
info("stopping llama-server (systemd --user unit)");
spawnSync("systemctl", ["--user", "stop", "llama-server.service"], { stdio: "inherit" });
} else {
warn("no llama-server.service under systemd --user — stop it yourself");
}
}
}
info("done");
scripts/ui-test.sh
#!/usr/bin/env bash
# ui-test.sh — run the browser-UI test-suite (tests/ui.ts) safely:
# * a throwaway harness on a spare port, so the window you are actually using keeps
# its WebSocket, its config and its model untouched;
# * a dedicated headless Chromium with its own profile — never your app window.
#
# scripts/ui-test.sh # the whole suite (check, reasoning, tool, model, smoke, rewind)
# scripts/ui-test.sh check smoke # just these
# UI_TEST_URL=http://127.0.0.1:8095/ scripts/ui-test.sh check # against a live instance
#
# Prereqs: llama-server on :8080 (the LLM leg), the audio.cpp servers (started on demand).
set -euo pipefail
ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
cd "$ROOT"
info() { printf '\033[1;34m[ui-test]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[ui-test]\033[0m %s\n' "$*" >&2; exit 1; }
pick_port() {
bun -e 'const net=require("node:net");const s=net.createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})'
}
HARNESS_PID="" CHROME_PID="" PROFILE=""
cleanup() {
local rc=$?
trap - EXIT INT TERM
[[ -n "$HARNESS_PID" ]] && { kill "$HARNESS_PID" 2>/dev/null || true; wait "$HARNESS_PID" 2>/dev/null || true; }
[[ -n "$CHROME_PID" ]] && { kill "$CHROME_PID" 2>/dev/null || true; }
[[ -n "$PROFILE" ]] && { pkill -f "user-data-dir=$PROFILE" 2>/dev/null || true; rm -rf "$PROFILE"; }
exit "$rc"
}
trap cleanup EXIT INT TERM
# ── prereqs ────────────────────────────────────────────────────────────────────
LLM_BASE_URL="${LLM_BASE_URL:-http://127.0.0.1:8080/v1}"
curl -sf -m 3 "${LLM_BASE_URL%/v1}/health" >/dev/null \
|| die "llama-server not reachable at $LLM_BASE_URL — start it first (systemctl --user start llama-server)"
"$ROOT/scripts/start-audio-servers.sh" start >/dev/null # idempotent
# ── the harness under test ─────────────────────────────────────────────────────
URL="${UI_TEST_URL:-}"
if [[ -z "$URL" ]]; then
PORT="$(pick_port)"
URL="http://127.0.0.1:$PORT/"
LOG="$ROOT/data/logs/ui-test-harness.log"
mkdir -p "$ROOT/data/run" "$ROOT/data/logs"
info "harness → $URL (log: ${LOG#"$ROOT"/})"
# its own pi namespace: the tests' turns must never land in your data/pi conversations,
# and its own chat file: the suite's turns must never land in your data/chat.json
LLM_VIA_PI=1 PI_CWD="$ROOT/data/run/ui-test-pi" CHAT_PERSIST=0 \
bun run src/cli.ts --web --no-browser --port "$PORT" >"$LOG" 2>&1 &
HARNESS_PID=$!
# coming up means a.start() finished → the LLM warmup (a cold model load can take minutes)
for _ in $(seq 1 300); do
curl -sf -m 2 "$URL" >/dev/null && break
kill -0 "$HARNESS_PID" 2>/dev/null || { tail -n 20 "$LOG" >&2; die "harness died on startup"; }
sleep 1
done
curl -sf -m 2 "$URL" >/dev/null || { tail -n 20 "$LOG" >&2; die "harness not up after 300 s (see ${LOG#"$ROOT"/})"; }
else
info "using the instance you pointed at: $URL"
fi
# ── a headless Chromium of our own ─────────────────────────────────────────────
CDP_PORT="$(pick_port)"
PROFILE="$(mktemp -d /tmp/ui-test-chrome.XXXXXX)"
CHROME="$(command -v chromium || command -v chromium-browser || command -v google-chrome-stable \
|| echo /usr/lib/chromium/chromium)"
[[ -x "$CHROME" ]] || die "no Chromium binary found"
info "chromium → CDP :$CDP_PORT (profile $PROFILE)"
"$CHROME" --headless --remote-debugging-port="$CDP_PORT" --user-data-dir="$PROFILE" \
--no-first-run --no-default-browser-check --disable-gpu --mute-audio \
--autoplay-policy=no-user-gesture-required about:blank \
>"$ROOT/data/logs/ui-test-chromium.log" 2>&1 &
CHROME_PID=$!
for _ in $(seq 1 50); do
curl -sf -m 2 "http://127.0.0.1:$CDP_PORT/json/version" >/dev/null && break
sleep 0.3
done
curl -sf -m 2 "http://127.0.0.1:$CDP_PORT/json/version" >/dev/null \
|| { tail -n 20 "$ROOT/data/logs/ui-test-chromium.log" >&2; die "Chromium CDP never came up"; }
# ── the suite ──────────────────────────────────────────────────────────────────
tests=("$@")
((${#tests[@]})) || tests=(all)
info "running: ${tests[*]}"
set +e
bun run tests/ui.ts --url "$URL" --cdp "http://127.0.0.1:$CDP_PORT" "${tests[@]}"
rc=$?
set -e
exit "$rc"
scripts/fetch-and-compile-audio-cpp.ts
#!/usr/bin/env bun
/**
* `bun run fetch-and-compile-audio.cpp` - fetch the latest audio.cpp **release** and
* compile `audiocpp_server` from its source (the official prebuilt binaries are built
* with `-march=native` on AVX-512 CI runners and SIGILL on CPUs without AVX-512 - see
* the "Why compile" note in README.md; fetching source, not a release asset, is the point).
*
* bun run fetch-and-compile-audio.cpp clone/update `vendor/audio.cpp` to the newest release and build it
* bun run fetch-and-compile-audio.cpp --tag v0.9.1 … pin one release instead of "latest"
* bun run fetch-and-compile-audio.cpp -h | --help
*
* What it does, in order:
* 1. resolve the newest tag from the GitHub releases API (fallback: `git ls-remote --tags`)
* 2. clone `vendor/audio.cpp` at that tag, or `git fetch` + check it out when it exists
* (a dirty checkout is refused, nothing is stashed or discarded behind your back)
* 3. `cmake` configure + build the `audiocpp_server` target (incremental on re-runs)
* 4. verify `vendor/audio.cpp/build/bin/audiocpp_server` and say how to start it
*
* The vendor directory is git-ignored: it is fetched on demand, never committed.
*
* Env:
* AUDIOCPP_VULKAN=ON|OFF force the Vulkan backend instead of auto-detecting
* (auto: ON when the Vulkan headers + `glslc` are installed)
* AUDIOCPP_SERVER=… where `scripts/start-audio-servers.sh` looks for the binary
* (defaults to the path this script builds)
*
* Needs: git, cmake, a C++ toolchain (Ninja when available), and network for step 1-2.
*/
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const ROOT = path.resolve(import.meta.dir, "..");
const REPO = "https://github.com/0xShug0/audio.cpp";
const VENDOR = path.join(ROOT, "vendor", "audio.cpp");
const BUILD = path.join(VENDOR, "build");
const BIN = path.join(BUILD, "bin", "audiocpp_server");
const tty = Boolean(process.stdout.isTTY);
const paint = (code: number, s: string) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
const green = (s: string) => paint(32, s);
const red = (s: string) => paint(31, s);
const yellow = (s: string) => paint(33, s);
const dim = (s: string) => paint(2, s);
const info = (s: string) => console.log(`${green("[audio.cpp]")} ${s}`);
const warn = (s: string) => console.error(`${yellow("[audio.cpp]")} ${s}`);
function die(s: string): never {
console.error(`${red("[audio.cpp]")} ${s}`);
process.exit(1);
}
type Res = { ok: boolean; out: string };
/** Run a command: output inherited when `capture` is unset, captured when it is set. */
function run(args: string[], capture = false): Res {
const r = spawnSync(args[0]!, args.slice(1), {
encoding: "utf8",
stdio: capture ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
cwd: ROOT,
});
return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
}
/** `command -v name` without assuming a `which` binary exists. */
function onPath(name: string): boolean {
return spawnSync("/bin/sh", ["-c", `command -v ${name} >/dev/null 2>&1`]).status === 0;
}
const human = (ms: number): string => {
const s = Math.round(ms / 1000);
return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
};
/** `v0.9.1` / `0.9.1` → comparable numbers; anything unparseable sorts last. */
function tagKey(tag: string): number[] | null {
const m = /^v?(\d+(?:\.\d+)*)/.exec(tag);
return m ? m[1]!.split(".").map(Number) : null;
}
function newer(a: string, b: string): boolean {
const ka = tagKey(a);
const kb = tagKey(b);
if (!ka || !kb) return false;
for (let i = 0; i < Math.max(ka.length, kb.length); i++) {
const d = (ka[i] ?? 0) - (kb[i] ?? 0);
if (d) return d > 0;
}
return false;
}
/** Newest release tag: GitHub API first, `git ls-remote` when the API is unreachable. */
async function latestTag(): Promise<string> {
try {
const r = await fetch("https://api.github.com/repos/0xShug0/audio.cpp/releases/latest", {
headers: { accept: "application/vnd.github+json" },
signal: AbortSignal.timeout(10_000),
});
if (r.ok) {
const tag = ((await r.json()) as { tag_name?: string }).tag_name;
if (tag) return tag;
}
warn(`GitHub API answered HTTP ${r.status} — falling back to git ls-remote`);
} catch (err) {
warn(`GitHub API unreachable (${err instanceof Error ? err.message : err}) — falling back to git ls-remote`);
}
const r = run(["git", "ls-remote", "--tags", "--refs", REPO], true);
if (!r.ok) die(`could not list releases: ${r.out}`);
const tags = r.out
.split("\n")
.map((l) => l.split("refs/tags/")[1])
.filter((t): t is string => Boolean(t) && tagKey(t!) !== null);
if (!tags.length) die("no tags found upstream");
return tags.reduce((best, t) => (newer(t, best) ? t : best));
}
/** Clone `vendor/audio.cpp` at `tag`, or move an existing clean clone to it. */
function fetchSource(tag: string): void {
if (!fs.existsSync(VENDOR)) {
fs.mkdirSync(path.dirname(VENDOR), { recursive: true });
info(`cloning ${REPO} @ ${tag} → ${path.relative(ROOT, VENDOR)} ${dim("(shallow)")}`);
const r = run(["git", "clone", "--depth", "1", "--branch", tag, REPO, VENDOR]);
if (!r.ok) die(`clone failed — is "${tag}" a real tag? (${r.out})`);
return;
}
if (!fs.existsSync(path.join(VENDOR, ".git")))
die(`${path.relative(ROOT, VENDOR)} exists but is not a git clone — remove it and re-run`);
const head = run(["git", "-C", VENDOR, "rev-parse", "HEAD"], true).out.trim();
// fetch exactly this tag (works for shallow clones too); offline + already local → continue
const f = run(["git", "-C", VENDOR, "fetch", "--force", "origin", `refs/tags/${tag}:refs/tags/${tag}`], true);
if (!f.ok) {
if (run(["git", "-C", VENDOR, "rev-parse", "--verify", `refs/tags/${tag}`], true).ok)
warn(`git fetch failed (${f.out}) — the tag is local already, continuing offline`);
else die(`could not fetch ${tag}: ${f.out}`);
}
const want = run(["git", "-C", VENDOR, "rev-parse", `${tag}^{commit}`], true).out.trim();
if (head === want) {
info(`source already at ${green(tag)}`);
return;
}
const dirty = run(["git", "-C", VENDOR, "status", "--porcelain"], true).out;
if (dirty)
die(
`${path.relative(ROOT, VENDOR)} has local changes — commit/stash/clean them first:\n` +
dirty
.split("\n")
.slice(0, 5)
.map((l) => ` ${l}`)
.join("\n")
);
info(`moving ${path.relative(ROOT, VENDOR)} ${dim(`(${head.slice(0, 8)} → ${tag})`)}`);
const c = run(["git", "-C", VENDOR, "checkout", "--force", tag], true);
if (!c.ok) die(`checkout ${tag} failed: ${c.out}`);
const now = run(["git", "-C", VENDOR, "describe", "--tags", "--exact-match"], true).out.trim();
info(`source at ${green(now || tag)}`);
}
/** Vulkan: honor AUDIOCPP_VULKAN, else auto-detect headers + `glslc`. */
function vulkanWanted(): boolean {
const forced = process.env.AUDIOCPP_VULKAN?.trim().toUpperCase();
if (forced === "ON") return true;
if (forced === "OFF") return false;
const headers =
fs.existsSync("/usr/include/vulkan/vulkan.h") ||
spawnSync("pkg-config", ["--exists", "vulkan"]).status === 0;
const glslc = onPath("glslc");
if (headers && glslc) return true;
warn(
`Vulkan toolchain not found (${!headers ? "no vulkan headers" : ""}${!headers && !glslc ? " and " : ""}${!glslc ? "no g...
Discussion
Questions & comments · 0
Sign In Sign in to leave a comment.