#!/usr/bin/env python3 # desktop-assistant: push-to-talk local voice assistant. # Copyright (C) 2026 Danilo M. # # This program is free software; you can redistribute it and/or modify it # under the terms of the GNU General Public License version 2 as published # by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """Mic button on: record. Mic button off: transcribe (whisper-server), ask the loaded llama-server model, speak the reply (Kokoro). Run: .venv/bin/python assistant.py (--test runs the self-check) """ import ctypes import json import os import queue import re import signal import subprocess import sys import threading import time import unicodedata import urllib.request import wave CARD = "Microphone" # ALSA card id of the USB mic MODELS = "/data/voice-models" WHISPER = "http://127.0.0.1:8182" LLM = "http://localhost:8181/v1" RAW = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "desktop-assistant.raw") WAV = RAW[:-3] + "wav" SYSTEM = ("You are a voice assistant. Your replies are spoken aloud, so answer " "briefly in plain sentences: no markdown, no lists, no emoji. " "Reply in the language the user speaks, Italian or English.") KLANG = {"it": "it", "en": "en-us"} def log(*a): print(time.strftime("%H:%M:%S"), *a, flush=True) def mic_on(): out = subprocess.run(["amixer", "-c", CARD, "cget", "numid=2"], capture_output=True, text=True).stdout return out.rstrip().endswith("values=on") def presenting(): # During calls the user turns on presentation mode; the button then # belongs to the call, not to us. r = subprocess.run([os.path.expanduser("~/bin/statusctl"), "presentation", "get"], capture_output=True, text=True) return r.stdout.strip() == "1" def latin(text): return all(unicodedata.name(c, "").startswith("LATIN") for c in text if c.isalpha()) def transcribe(lang="auto"): # Always send language: whisper-server's own default is en, not auto. out = subprocess.run(["curl", "-s", "-F", f"file=@{WAV}", "-F", f"language={lang}", "-F", "response_format=verbose_json", WHISPER + "/inference"], capture_output=True, text=True).stdout d = json.loads(out) return d.get("language_probabilities", {}), " ".join(d["text"].split()) def stt(): # large-v3-turbo's detector is reliable for this speaker (small's was # not). Only it and en are spoken: if detection lands elsewhere, or the # text is non-Latin (a hallucination, both are Latin script), redo the # pass forced to the likelier of the two. p, text = transcribe() lang = max(KLANG, key=lambda l: p.get(l, 0)) if max(p, key=p.get, default=lang) != lang or not latin(text): text = transcribe(lang)[1] return lang, text IT_WORDS = set("il lo la gli le di che è non per una sono ho hai con mi ti ci del della " "come questo questa anche ma più se nel alla puoi posso sei".split()) EN_WORDS = set("the is are you i to of and it that for with not this what be have " "can your my do there".split()) def lang_of(sentence, fallback): # The voice must follow the reply's language, not the user's: the model # may answer in the other one. ponytail: stopword count, a real language # identifier if this misfires. words = re.findall(r"[a-zàèéìòù]+", sentence.lower()) it = sum(w in IT_WORDS for w in words) en = sum(w in EN_WORDS for w in words) return "it" if it > en else "en" if en > it else fallback def split_sentences(buf): parts = re.split(r"(?<=[.!?…])\s+|\n+", buf) return [p.strip() for p in parts[:-1] if p.strip()], parts[-1] def llm_model(): # The router keeps one model resident; asking for another would swap it # out under other clients, so use whatever is not unloaded. with urllib.request.urlopen(LLM + "/models") as r: models = json.load(r)["data"] return next((m["id"] for m in models if m["status"]["value"] != "unloaded"), models[0]["id"]) def ask(messages): body = {"model": llm_model(), "messages": messages, "stream": True, "chat_template_kwargs": {"enable_thinking": False}} req = urllib.request.Request(LLM + "/chat/completions", json.dumps(body).encode(), {"Content-Type": "application/json"}) with urllib.request.urlopen(req) as r: for line in r: if not line.startswith(b"data: ") or line.strip() == b"data: [DONE]": continue choices = json.loads(line[6:])["choices"] if choices and choices[0]["delta"].get("content"): yield choices[0]["delta"]["content"] def speaker(q): from kokoro_onnx import Kokoro, EspeakConfig # The espeakng-loader wheel's bundled lib has a broken data path; use # the system espeak-ng. k = Kokoro(f"{MODELS}/kokoro/kokoro-v1.0.onnx", f"{MODELS}/kokoro/voices-v1.0.bin", espeak_config=EspeakConfig(lib_path="/usr/lib64/libespeak-ng.so.1", data_path="/usr/share/espeak-ng-data")) voices = {"it": 0.8 * k.get_voice_style("if_sara") + 0.2 * k.get_voice_style("af_bella"), "en": k.get_voice_style("af_heart")} play = None while True: item = q.get() if item is None: # end of reply if play: play.stdin.close() play.wait() play = None continue text, lang = item audio, sr = k.create(text, voice=voices[lang], lang=KLANG[lang]) if play is None: play = subprocess.Popen(["aplay", "-q", "-r", str(sr), "-f", "S16_LE", "-c", "1"], stdin=subprocess.PIPE) # A new stream wakes the suspended sink and the first ~0.2 s is # lost: lead with silence so it eats that, not the first word. play.stdin.write(bytes(int(sr * 0.3) * 2)) # The pipe buffer lets synthesis of the next sentence overlap playback. play.stdin.write((audio.clip(-1, 1) * 32767).astype("