# desktop-assistant A local desktop voice assistant: speak, get a spoken answer, nothing leaves the machine. Everything runs on already-packaged local tools; this repo is the glue between them. Status: **first working loop** in `assistant.py` (2026-09-25). Mic button on/off is push-to-talk, whisper large-v3-turbo on the Arc transcribes, the resident llama-server model answers, Kokoro speaks it. Button-off to first spoken word ~2 s. Kokoro is not packaged yet: it runs from the repo's gitignored `.venv`. See "Findings" and "Next steps". Run: `.venv/bin/python assistant.py` (`--test` for the self-check). The venv is `python3 -m venv --system-site-packages .venv && .venv/bin/pip install kokoro-onnx`; system-site-packages gives it the system onnxruntime. --- ## Pipeline ``` mic button ──> arecord ──> whisper-server (STT) ──> llama-server (LLM) ──> Kokoro (TTS) ──> aplay ``` | Stage | Package (installed) | Binary | Notes | |-------|---------------------|--------|-------| | STT | `whisper.cpp` 1.9.4 | `whisper-server` (also `whisper-stream`, `whisper-cli`) | SDL2 mic capture, ffmpeg decoding, Vulkan + OpenVINO backends | | LLM | `llama.cpp-vulkan` 0.5.0 | `llama-server` | already running as a system service, see below | | TTS | none yet: `kokoro-onnx` from PyPI in `.venv` | | uses system `python3-onnxruntime` and `espeak-ng` | | (TTS, unused) | `piper-tts` 1.7.0 | `piper-tts` | voices rejected by ear, see Findings | The three packages are in the `my-slackbuilds` repo (`../slackbuilds-superrepo/my-slackbuilds/`). Package-level changes (build options, backends, bumps) happen there, under that repo's `AGENTS.md`, not here. `openvino` 2026.4.0 is also packaged there, solely to feed whisper.cpp. ## Hardware - Discrete GPU: Intel Arc B580 (Battlemage). Target for both Vulkan and OpenVINO GPU. - Integrated GPU: AMD Radeon 610M on a Ryzen 7 9700X, also exposed through Vulkan (RADV). Vulkan enumeration order is Arc first, iGPU second, llvmpipe last. Check device indices in the startup log before picking `-dev N`, the iGPU is a plausible wrong pick. ## whisper.cpp: what the package gives you Built from ggml-org upstream 1.9.4 (SBo still ships 1.6.2, which is why it is ours). Binaries only, libraries static: bundled ggml would clash with llama.cpp-vulkan's. Installed tools: `whisper-cli`, `whisper-server`, `whisper-bench`, `whisper-quantize`, `whisper-vad-speech-segments`, `parakeet-cli`, `parakeet-quantize`, and the SDL2 mic tools `whisper-stream` (live transcription), `whisper-command` (voice commands), `whisper-talk-llama`, `whisper-lsp`. Backends compiled in: CPU, BLAS (OpenBLAS, autodetected), Vulkan (`GGML_VULKAN`), ggml OpenVINO (`GGML_OPENVINO`, whole model through OpenVINO), plus the older `WHISPER_OPENVINO` encoder-only path. - Vulkan and ggml-openvino both register as GPU devices. whisper uses the first GPU it finds; choose with `whisper-cli -dev N`. - `whisper-bench` has **no** device flag. Compare backends with `whisper-cli -dev N` on the same sample and read the timings it prints at exit. - The `WHISPER_OPENVINO` encoder path needs the encoder converted to OpenVINO IR first (upstream README, "OpenVINO Support"). Not done yet. The ggml-openvino device does not need that conversion. - OpenVINO libs are found through an rpath (`/usr/share/openvino/runtime/lib/intel64`); no need to source `setupvars.sh` at runtime. The OpenVINO GPU plugin (`libopenvino_intel_gpu_plugin.so`) is installed. - Input goes through ffmpeg, so any format ffmpeg reads works. - Speech encoders expect 16 kHz mono. `whisper-talk-llama` already does mic, whisper, LLM in one binary, but it loads its own model rather than talking to the running `llama-server`. Worth reading as reference, probably not the final shape. ## llama-server: already running A system service, `/etc/rc.d/rc.llama.cpp`, runs `llama-server` in **router mode** on `http://localhost:8181` with an OpenAI-compatible API. - Models are defined in `/etc/llama-server/presets.ini` (sections with `model =` and optional `mmproj =`), GGUF files under `/data/LLM-models/`. - `--models-max 1`: the router keeps **one** model loaded. If the assistant asks for a different model than the one in use, the router swaps it out, under whoever else is using it (e.g. llamachat). Prefer reusing whatever model is loaded, or accept the reload latency knowingly. - `/v1/models` lists models and each one's `architecture.input_modalities`. A model reporting `"audio"` accepts `input_audio` content parts directly, which is a possible shortcut that skips whisper entirely. Not evaluated. Prior art: `../local-chat` (llamachat, PySide6) is a client for the same router. It already records 16 kHz mono WAV and sends it as `input_audio` to audio-capable models, and parses `presets.ini`. Reuse its findings, do not import from it. ## piper-tts Prebuilt upstream wheel (OHF-voice/piper1-gpl), console script `piper-tts`, Python module `piper` under the system python3.12. Runtime deps `python3-onnxruntime` and `python3-pathvalidate` are packaged separately. Voices are ONNX models plus a `.onnx.json` config; none downloaded yet. --- ## Findings ### Models (2026-09-25) Everything under `/data/voice-models/` (outside the repo, kept apart from `/data/LLM-models/`): - `whisper/ggml-base.bin`, 142 MB, multilingual. SHA1 matches upstream (`465707469ff3a37a2b9b8d8f89f2f99de7299dac`). - `piper/it_IT-paola-medium.onnx` and `piper/en_US-lessac-medium.onnx`, 61 MB each, plus their `.onnx.json`. Both output 22050 Hz. - `samples/it.wav`, `samples/en.wav`: 10 s, 16 kHz mono mic recordings used for the STT comparison. Personal voice data, never copy into the repo. ### STT backends (2026-09-25) Same 10 s clip, `ggml-base`, `whisper-cli`: | `-dev` | Device | Total (warm) | Result | |--------|--------|--------------|--------| | `-ng` | CPU, 4 threads | ~370-420 ms | correct | | 0 | Vulkan0, Arc B580 | ~160-240 ms | correct | | 1 | Vulkan1, iGPU | ~900 ms | correct | | 2 | OPENVINO0 | 13.7 s | garbage | **Decision: Arc on Vulkan, `-dev 0`, `-l auto`.** - `-dev N` indexes GPU-type devices only, not the full device list: 0 = Arc, 1 = iGPU, 2 = OpenVINO. An index past the last GPU silently falls back to CPU. - The Arc's first run after a while costs 0.6-1.4 s (Vulkan pipeline warm-up), later runs are fast. A long-running process pays it once. - `-l auto` detected both languages correctly (it, p=0.999) for ~40 ms extra (one more encoder pass). One setup covers Italian and English. - ggml-openvino runs on **CPU**, not the Arc: the log says "OpenVINO: using device CPU" even with `GGML_OPENVINO_DEVICE=GPU`, and its output is garbage. Only OpenCL platform installed is Mesa `rusticl`; Intel compute-runtime (NEO), which the OpenVINO GPU plugin needs, is not installed. Likely cause, not confirmed. Decision on keeping OpenVINO in the package is deferred to `my-slackbuilds`. - Every run tries to load `ggml-base-encoder-openvino.xml` (the `WHISPER_OPENVINO` encoder path), fails, and continues. Harmless noise. - base accuracy is fine on full sentences, weak on short words: "ho" came out as "a", a spoken "hi" came out as "5". Try `ggml-small` (~488 MB) if that hurts short commands. ### Live mic with whisper-stream (2026-09-25) `whisper-stream -m ggml-base.bin -l auto --step 0 --length 8000 -vth 0.6` (VAD mode), 40 s, mixed Italian/English utterances with pauses. - No `-dev` flag; it takes the first GPU, which is the Arc (Vulkan0). Default SDL capture device is the USB mic, correct. - **Not usable as the assistant's STT entry point.** Its "VAD" is a crude energy check: on speech activity it re-transcribes the whole last `--length` window, not a segmented utterance. The same sentence comes out 2-3 times, windows straddle two utterances in different languages (one output mixed an English sentence with the next Italian one), and `-l auto` detects per window, so mixed windows garble. A window with short words ("hello, hi, ciao") came out as Greek gibberish. - Content of a clean window was mostly right ("ricordami di comprare latte"), so the model is fine, the segmentation is the problem. - Conclusion: the loop must segment utterances itself (push-to-talk or a real VAD) and send exactly one utterance to whisper, kept warm in a long-running process (`whisper-server`) to avoid the Arc warm-up per call. ### Push-to-talk via the mic's mute button (2026-09-25) The USB mic's hardware mute button toggles the ALSA control `numid=2 'Mic Capture Switch'` on card 0 (`amixer -c 0 cget numid=2` reads on/off, observed flipping with each press). So the button *is* the push-to-talk: switch on = start recording, switch off = stop and send the utterance to whisper. No keybind, no VAD. - Event-driven, no polling: `amixer -c 0 events` prints a line on each control change ("Ready to listen..." then events). - Card index 0 and numid 2 are what this machine shows now; card numbering can change with USB plug order, so resolve by card name, not number. - The mic also exposes a HID keyboard device (`/dev/input/event3`, group `input`); not needed, the ALSA switch is enough. - **Conflict: calls.** In a call the same button unmutes the mic for the call, and the assistant would record and answer the call. Decision: the assistant is paused while **presentation mode** is on (the user turns it on for calls). Presentation mode lives in the desktop status registry `~/bin/statusctl` (quickshell repo): `statusctl presentation get` prints `1`/`0`, backed by `$XDG_RUNTIME_DIR/status.presentation` (missing = off). Check it at the moment the switch goes on, and ignore the press if `1`. No watcher needed. Considered and not chosen: skipping when another app already records from the mic (`pactl list source-outputs`, sound server is PipeWire with the Pulse API); fallback if presentation mode gets forgotten. ### Push-to-talk round trip with whisper-server (2026-09-25) `whisper-server -m ggml-base.bin -dev 0 -l auto --host 127.0.0.1 --port 8182` (port next to llama-server's 8181), throwaway bash loop: `amixer events` drives `arecord` on/off, clip POSTed to `/inference`. - Works end to end. Warm server: **0.10-0.23 s** per utterance, including the very first request (no per-call Arc warm-up once the server is up). - `amixer events` piped into a loop is block-buffered and delivers nothing; needs `stdbuf -oL amixer ...`. - Do **not** mute the mic from software (`amixer cset numid=2 off`): tried at loop startup and the user found it unreliable (likely out of sync with the button's own state/LED). The user drives mute/unmute on the button only; the loop just reads the current state at start. - `amixer -c Microphone` (the card id) works, no need for the index. - Stopping `arecord` with SIGINT leaves the WAV header unfinalized (size field reads as ~67108 s). whisper decodes it anyway; do not trust the header for the duration. - **Language auto-detect is the weak spot with base on live speech:** an English sentence came out as a Greek/Hebrew/Arabic mix, "ciao" came out as "Tchau" (Portuguese). Italian sentence fine, "hi" fine. The same sentences from the pre-recorded samples detected correctly, so short or accented utterances push base's detector off. Only it/en will ever be used. - Correction: the pre-recorded English sample *also* detects as Greek (el p=0.88, en 0.009, it 0.075), on server and CLI, CPU and GPU alike; the earlier CLI run decoded English text in spite of the wrong language tag. base's detector does not handle this speaker's English. - Tried and rejected for base: (1) pick it/en by `language_probabilities` (verbose_json): picks it for English. (2) transcribe forced `it` and forced `en`, keep higher token-weighted `avg_logprob`: forced-it turns English into plausible Italian and scores *higher* (-0.164 vs -0.287). Moving to `ggml-small`. - `ggml-small.bin` (466 MB, SHA1 `55356645c2b361a969dfd0ef2c5a50d530afd8d5`, matches upstream) in `/data/voice-models/whisper/`. On the Arc via whisper-server: **~0.35 s per pass** on a 10 s clip. Transcripts clean on both samples (Italian and English self-introductions, all words right). - small's detector *still* mislabels the English sample (la 0.45, it 0.31, en 0.04) though it decodes English text under auto. Not trustworthy. - **Picker works with small:** forced `it` vs forced `en`, keep higher token-weighted `avg_logprob`. it.wav: it -0.196 vs en -0.426; en.wav: en -0.127 vs it -0.243. Both correct, ~0.5 s for the two passes. **STT decision: ggml-small + it/en picker.** - Live push-to-talk with small + picker, 5 utterances: Italian sentence, English sentence, "hi", and a mixed it/en sentence all transcribed perfectly, 0.42-0.54 s each (two passes). "ciao" failed: forced `en` produced Chinese characters ("早, 早!") with the *better* logprob (-0.032 vs -0.312); forced `it` gave "Ciao!". An initial `prompt` ("Ciao. Hi.") does not fix forced-en. - **Fix: reject any candidate containing non-Latin letters** (it and en are both Latin script; `unicodedata.name(c).startswith("LATIN")` for every alphabetic char). With that guard all 5 live clips come out right. - Mixed-language sentences: the picker chooses one language, but small still transcribes both halves correctly under either forced language. - In the full loop the picker still failed: spoken English "What does HTML stand for?" -> picked it ("Che stanno per HTML?", it -0.153 vs en -0.214) though small's own detector said en 0.926 on that clip. Combining `logprob + w*log(p_detect)` over 8 saved clips: 7/8 at every w, never 8/8. small's two signals are each unreliable on this speaker, in different clips. ### large-v3-turbo (2026-09-25, **chosen**, replaces small) `ggml-large-v3-turbo-q5_0.bin`, 548 MB, sha256 matches huggingface (`394221709cd5ad1f40c46e6031ca61bce88931e6e088c188294c6d5a55ffa7e2`). - Auto-detect correct on **all 7** single-language clips, including the English ones small's detector called Latin/Greek; the mixed it/en clip comes out "it". Transcripts good (spoken "HTML stands for" kept English). - Its forced-language logprob picker is *worse* than small's (it.wav picks en, -0.225 vs -0.667): with turbo, trust the detector, drop the picker. - Cost on the Arc: ~0.95 s (whisper-cli) / ~1.2 s (whisper-server) per clip. Encoder runs twice (~400 ms each): once for language detection, once for transcription. - `-ac` (audio context) does not pay: 768 saves ~0.25 s but starts repeating words, 512 repeats more, 384 is garbage. Keep 0 (full). ### The loop, `assistant.py` (2026-09-25) - STT: one `language=auto` pass on turbo; the answer is whichever of it/en the detector rates higher. Forced second pass only if detection lands on a third language or the text is non-Latin. whisper-server's default language is **en**, not auto: always send `language=auto` explicitly. - Live: turbo still tagged one English question "it" ("What is slackware?"), but the text stayed English, so no harm, because: - **TTS voice follows the reply's language, not the user's.** The model may answer in the other language (seen: user tagged en, reply Italian, read by the English voice). Per-sentence stopword count picks it/en. - **Pad 0.5 s of silence** after each clip: whisper dropped the final word ("... a cosa serve [python]") when the button was pressed mid-word. Fixed by the padding. Recording is raw PCM (`arecord -t raw`), wrapped into a WAV with stdlib `wave`, which also fixes the SIGINT header problem. - LLM: model = first one in `/v1/models` whose status is not `unloaded` (resident is Gemma4-12B, status `sleeping` when idle). Warm first token ~0.3 s; after `sleep-idle-seconds 1800` the first request waits ~45 s for the reload. `chat_template_kwargs.enable_thinking=false` sent; only `delta.content` is read. - Timings live: STT ~1.23 s, first LLM sentence 0.6-0.8 s. - Child processes (whisper-server, `amixer events`) get `prctl(PR_SET_PDEATHSIG, SIGTERM)` via ctypes in `preexec_fn`: they die with the assistant even on SIGKILL or a kill during startup (tested). Python's default SIGTERM skips `finally`; a handler calls `sys.exit`. - Gemma confidently gave a wrong fact (a city-to-city distance off by ~10x). No tools yet, so no time/weather/facts lookup. - Recordings live in `$XDG_RUNTIME_DIR` and are deleted on exit. - The reply's first word was often clipped: each reply opens a new `aplay` stream and the idle sink needs a moment to wake. 0.3 s of leading silence per reply fixed it (confirmed by ear). ### TTS (2026-09-25) `piper-tts` on CPU (onnxruntime), ~5 s sentence per voice: - CLI to WAV: 0.64-0.76 s wall for ~5 s of audio, ~7x real time. - CLI `--output-raw`, time to first byte: ~0.58 s, nearly all voice load (0.46 s). Python + `import piper` alone is 0.09 s. - Voice preloaded in-process (`PiperVoice.load` once, then `voice.synthesize(text)` yields chunks per sentence): first chunk in 0.035-0.069 s. - So: a per-reply `piper-tts` process costs ~0.5 s of load each time; a long-running process holding the voice makes TTS latency negligible. - `--output-raw` is 16-bit mono PCM at the voice's rate (22050 Hz): `piper-tts -m --output-raw | aplay -r 22050 -f S16_LE -c 1`. - **Quality rejected by ear** on a ~20 s paragraph: Italian `paola-medium` flat with odd rhythm, English `lessac-medium` metallic and robotic. Piper speed is fine, its voices are not. Looking at alternatives. ### Kokoro-82M (2026-09-25, **chosen**) `kokoro-onnx` 0.6.1 in a throwaway venv (`--system-site-packages`, so it uses the system onnxruntime 1.30, CPU). Model `kokoro-v1.0.onnx` (311 MB) and `voices-v1.0.bin` (27 MB) in `/data/voice-models/kokoro/`, from the kokoro-onnx GitHub release `model-files-v1.0`. - The PyPI `espeakng-loader` wheel ships a lib with a hardcoded CI data path and fails ("Error processing file .../phontab"). Point it at the system espeak-ng instead: `EspeakConfig(lib_path="/usr/lib64/libespeak-ng.so.1", data_path="/usr/share/espeak-ng-data")`. A system package would do the same and drop `espeakng-loader`. - Load 0.30 s. `create()` on the ~20 s paragraph: ~2.6 s, ~7x real time, same for `if_sara`, `im_nicola` (lang `it`), `af_heart` (`en-us`), `bm_george` (`en-gb`). `create()` returns only after the whole text, so a reply needs per-sentence synthesis (or `create_stream`) to start speaking early. - **By ear: much better than piper. Kokoro chosen.** Voices: `if_sara` (Italian) and `af_heart` (English), both female for consistency. `bm_george` also liked (male, "Jarvis"-like) but not used. `if_sara` wants a bit more color/expressiveness. - Kokoro's only knob is `speed`; color comes from blending voice style vectors (`k.get_voice_style(name)` returns an array, `create(voice=...)` accepts it). Compared by ear: sara alone, sara 80/heart 20, sara 65/heart 35, sara 80/bella 20. **Italian voice chosen: `0.8*if_sara + 0.2*af_bella`, lang `it`.** --- ## Next steps Steps 1-5 of the original plan are done (models, backend comparison, live mic, TTS, glue); push-to-talk came for free from the mic button. Open, in no particular order: - **Packaging Kokoro** in `my-slackbuilds` (`kokoro-onnx` without `espeakng-loader`, pointed at system espeak-ng), then drop `.venv`. - **Run as a service/autostart** (it currently runs by hand). - **Barge-in:** pressing the button while it speaks does not stop the reply yet. - **Check presentation mode during games:** `statusctl presentation get` reads the stored flag; confirm it is 1 while a game holds it on. - **Latency:** STT ~1.2 s is the biggest share; the double encoder pass (detect + transcribe) is the target if it matters. - **Tools** (time, weather, calendar) so the model stops guessing facts. - Piper leftovers: package unused, voices in `/data/voice-models/piper/` (~120 MB) can go. ggml-base and ggml-small too, once turbo is settled. - OpenVINO in the whisper.cpp package: runs on CPU only here (no Intel compute-runtime), decision goes to `my-slackbuilds`. Open questions: whether an audio-capable LLM makes whisper unnecessary. License: GPLv2 only (chosen 2026-09-25). ## Package-side leftovers (tracked in my-slackbuilds, not here) - whisper.cpp not yet built on the buildsystem VM via slackrepo (the installed package is from the local build). - `sbopkglint` on the openvino package not done. - OpenCL-CLHPP 2026.05.29 (needed by openvino and ggml-openvino) exists only as a buildsystem hint; the Docker test-build needs a temporary local copy when its cache is cold. Permanent fix undecided. - whisper.cpp and openvino cannot build on Slackware 15.0 (CMake 3.26, glslc, old CLHPP). -current only. --- ## Agent rules - Ask before acting when anything is ambiguous, and before committing. - Package changes belong in `my-slackbuilds`, not here. - Never download models or voices without saying which and how big. - Everything stays local: no cloud STT/TTS/LLM fallbacks.