Tool

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).


88
Spark score
out of 100
Updated 8 days ago
Source checked Oct 16, 2026
Version 0.0.1
Models
gpt 4ollama 3

Add to Favorites

Source

Get it from source

Spark does not host a copy of it.

Open source

Reports

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).

Sonia web UI: live transcript, timer/watch banner and settings drawer

▶️ Sonia demo video

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-token LISTEN/REPLY prompt only when the pause is ambiguous.
  • Bilingual IT/EN - the model mirrors your language per turn; LOCALE=it|en localizes 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|auto for risky shell. The read-only skill tools (read_skill, list_skills) are exempt from the gate - they load context and act on nothing, while the run_command that uses them is still judged.
  • Externally managed skills - SKILL.md packages are not owned by this project: point SKILLS_DIR at 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 pi executable (LLM_VIA_PI=1, toggled at runtime) - plus a browser app-window UI and --cli/--text terminal 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=0 disables).

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 in
static/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) or
ASR_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 in PATH for the browser UI (optional:
    --no-browser + any browser) · PipeWire for mic/speaker in --cli mode (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 with ENGINE_ENABLE_NATIVE_CPU=ON
(i.e. -march=native) on AVX-512 CI runners, so the AVX-512 instructions end up in
ggml_cpu_init itself. On CPUs without AVX-512 (all Intel 12th-gen+) the process dies
with SIGILL right after Vulkan init - with both
backend: vulkan and backend: cpu, since the CPU backend is always registered.
Verified against release v0.9.1 (gdb: ggml_cpu_init+607: vpbroadcastd %edx,%ymm17,
an AVX-512VL instruction). A local build works because -march=native then means
plain AVX2/AVX-VNNI. (Not a Mesa/driver issue: neither binary bundles a driver - both
link only libvulkan.so.1 and 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=OFF or
-DENGINE_ENABLE_CPU_ALL_VARIANTS=ON): then AUDIOCPP_SERVER= can just point at the
downloaded release and the vendor/audio.cpp clone (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 printing
ready, 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 in
src/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 inside
SKILLS_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 its
SKILL.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).

Sonia web UI: live transcript, timer/watch banner and settings drawer

▶️ Sonia demo video

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-token LISTEN/REPLY prompt only when the pause is ambiguous.
  • Bilingual IT/EN - the model mirrors your language per turn; LOCALE=it|en localizes 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|auto for risky shell. The read-only skill tools (read_skill, list_skills) are exempt from the gate - they load context and act on nothing, while the run_command that uses them is still judged.
  • Externally managed skills - SKILL.md packages are not owned by this project: point SKILLS_DIR at 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 pi executable (LLM_VIA_PI=1, toggled at runtime) - plus a browser app-window UI and --cli/--text terminal 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=0 disables).

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 in
static/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) or
ASR_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 in PATH for the browser UI (optional:
    --no-browser + any browser) · PipeWire for mic/speaker in --cli mode (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 with ENGINE_ENABLE_NATIVE_CPU=ON
(i.e. -march=native) on AVX-512 CI runners, so the AVX-512 instructions end up in
ggml_cpu_init itself. On CPUs without AVX-512 (all Intel 12th-gen+) the process dies
with SIGILL right after Vulkan init - with both
backend: vulkan and backend: cpu, since the CPU backend is always registered.
Verified against release v0.9.1 (gdb: ggml_cpu_init+607: vpbroadcastd %edx,%ymm17,
an AVX-512VL instruction). A local build works because -march=native then means
plain AVX2/AVX-VNNI. (Not a Mesa/driver issue: neither binary bundles a driver - both
link only libvulkan.so.1 and 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=OFF or
-DENGINE_ENABLE_CPU_ALL_VARIANTS=ON): then AUDIOCPP_SERVER= can just point at the
downloaded release and the vendor/audio.cpp clone (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 printing
ready, 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 in
src/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 inside
SKILLS_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 its
SKILL.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.