Files
lucianoandClaude Fable 5.1 ff79832c30 AvatarPy: assistente personale con volto 3D, voce, memoria e plugin
Riscrittura in Python dell'assistente Avatar con interfaccia HUD (derivata da Mark LIV, CC BY-NC 4.0, vedi NOTICE.md).
Tre motori (Claude API, server locale OpenAI-compatibile, Claude Code), voce Kokoro/macOS, Whisper MLX,
avatar 3D con sincronizzazione labiale, memoria per categorie, allegati con OCR, monitor con avvisi,
plugin per Calendario, Mail, Promemoria, Note, Musica, app, Mac, timer, meteo, contatti, Messaggi,
file, Comandi Rapidi, browser, Telegram, WhatsApp (archivio e tempo reale).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 16:21:39 +02:00

174 lines
6.3 KiB
Python

"""
Push-to-talk — hold a key, speak, release.
Why this exists
---------------
Wake-word is hands-free but it is not always what you want: in a meeting, in a
noisy room, or when you simply do not feel like saying a name out loud, a key
you hold is faster and never mishears. It is also the natural way to use the
assistant while another window has focus.
How it works, and what it costs
-------------------------------
Zero new dependencies, best available mechanism per platform:
* **Windows** — `GetAsyncKeyState` polled from one small thread. This is
deliberately *not* `RegisterHotKey`, which only reports a press: push-to-talk
needs the release too, and it needs to work while another application has
focus. Polling two virtual-key codes 30 times a second is a rounding error of
CPU and needs no message loop.
* **macOS / Linux** — no portable way to read global key state without pulling
in a new package or asking for accessibility permissions, so the chord is
bound as an application shortcut instead: it works whenever the assistant's
window has focus. `scope` reports which of the two you got, so the UI can say
so honestly rather than pretending.
The class never raises. If the platform hook cannot be installed it simply
reports `scope == "window"` and the Qt shortcut carries it.
"""
from __future__ import annotations
import platform
import threading
import time
from typing import Callable
_OS = platform.system()
# The default chord. Ctrl+Space is free in most desktop environments and is the
# same finger shape on every keyboard layout, which matters for a worldwide app.
DEFAULT_CHORD = ("ctrl", "space")
# Windows virtual-key codes for the names we accept.
_VK = {
"ctrl": 0x11, "shift": 0x10, "alt": 0x12,
"space": 0x20, "f8": 0x77, "f9": 0x78, "f10": 0x79,
"capslock": 0x14, "insert": 0x2D,
}
# Qt key sequence text for the same chord, used by the windowed fallback.
_QT_NAME = {"ctrl": "Ctrl", "shift": "Shift", "alt": "Alt", "space": "Space",
"f8": "F8", "f9": "F9", "f10": "F10",
"capslock": "CapsLock", "insert": "Ins"}
_POLL_HZ = 30.0
# A key has to be down this long before we call it speech. It stops a stray
# brush of the chord from opening the microphone.
_DEBOUNCE_S = 0.06
def chord_label(chord=DEFAULT_CHORD) -> str:
"""Human-readable name of the chord, for the UI and the logs."""
return "+".join(_QT_NAME.get(k, k.title()) for k in chord)
def qt_sequence(chord=DEFAULT_CHORD) -> str:
"""The same chord as a QKeySequence string."""
return "+".join(_QT_NAME.get(k, k.title()) for k in chord)
class PushToTalk:
"""Calls `on_change(held: bool)` whenever the chord is pressed or released.
Start it once; it is safe to start and stop repeatedly, and safe to stop a
detector that never started.
"""
def __init__(self, on_change: Callable[[bool], None], chord=DEFAULT_CHORD):
self._on_change = on_change
self._chord = tuple(chord)
self._thread: threading.Thread | None = None
self._stop = threading.Event()
self._held = False
self._scope = "window"
# ── state ───────────────────────────────────────────────────────────────
@property
def held(self) -> bool:
return self._held
@property
def scope(self) -> str:
"""'global' once a system-wide hook is running, else 'window'."""
return self._scope
@property
def label(self) -> str:
return chord_label(self._chord)
# ── lifecycle ───────────────────────────────────────────────────────────
def start(self) -> str:
"""Begin watching. Returns the scope actually achieved."""
self.stop()
self._stop.clear()
if _OS == "Windows" and self._can_poll():
self._scope = "global"
self._thread = threading.Thread(
target=self._poll_loop, name="push-to-talk", daemon=True)
self._thread.start()
else:
self._scope = "window"
return self._scope
def stop(self) -> None:
self._stop.set()
t, self._thread = self._thread, None
if t is not None and t.is_alive():
t.join(timeout=1.0)
self._set_held(False)
# ── the windowed fallback drives this directly ──────────────────────────
def set_held(self, held: bool) -> None:
"""Feed a press/release from a Qt shortcut (non-Windows, or no hook)."""
self._set_held(bool(held))
# ── internals ───────────────────────────────────────────────────────────
def _can_poll(self) -> bool:
try:
import ctypes
ctypes.windll.user32.GetAsyncKeyState # noqa: B018 — presence check
return all(k in _VK for k in self._chord)
except Exception:
return False
def _set_held(self, held: bool) -> None:
if held == self._held:
return
self._held = held
try:
self._on_change(held)
except Exception:
pass # a listener fault must never kill the watcher
def _poll_loop(self) -> None:
import ctypes
user32 = ctypes.windll.user32
codes = [_VK[k] for k in self._chord]
period = 1.0 / _POLL_HZ
down_since = 0.0
while not self._stop.is_set():
try:
# The high bit of the return value is "currently down".
down = all(user32.GetAsyncKeyState(c) & 0x8000 for c in codes)
except Exception:
break # driver or session teardown — fall back to windowed
now = time.monotonic()
if down:
if down_since == 0.0:
down_since = now
elif now - down_since >= _DEBOUNCE_S:
self._set_held(True)
else:
down_since = 0.0
self._set_held(False)
self._stop.wait(period)
self._set_held(False)
self._scope = "window"