#!/usr/bin/env python3
"""Whisper small push-to-talk for the Steam Frame.

A short click of the headset Select button is left for Steam Input.
Hold it until the beep, then speak, then release. The words are typed
into whatever gamescope has focused. The mic on a floating window
starts immediately and types into that window. Controller buttons are
not triggers.

"clear" and "submit" are actions when they are the whole transcript or
the first or last word. Clear backspaces the characters whisper typed.
Submit presses Enter. The same word in the middle is typed.

Config: ~/.config/whisper-ptt/config
"""

import array
import ctypes
import fcntl
import math
import os
import re
import select
import signal
import socket
import struct
import subprocess
import sys
import threading
import time
import wave
from pathlib import Path

HOME = Path.home()
LIB = HOME / ".local/lib/whisper-frame"
BIN = LIB / "whisper-cli"
MODEL = HOME / ".local/share/whisper/ggml-small-q8_0.bin"
CONF_DIR = HOME / ".config/whisper-ptt"
STATE = HOME / ".local/state/whisper-ptt"
LOCK = STATE / "daemon.lock"
CONF = CONF_DIR / "config"

EV_KEY = 0x01
EVENT_SIZE = 24
KEY_SELECT = 353          # headset gpio-keys "Select"
BTN_THUMBL = 317          # virtual Xbox pad L3
XK_SHIFT_L = 0xFFE1
XK_CONTROL_L = 0xFFE3
XK_RETURN = 0xFF0D
XK_BACKSPACE = 0xFF08
KEY_PRESS_MASK = 1
CURRENT_TIME = 0
REVERT_TO_PARENT = 2

GAMEPAD_BUTTONS = {
    "a": 304,
    "b": 305,
    "x": 307,
    "y": 308,
    "lb": 310,
    "rb": 311,
    "view": 314,
    "menu": 315,
    "guide": 316,
    "l3": BTN_THUMBL,
    "r3": 318,
}
CODE_NAMES = {code: name for name, code in GAMEPAD_BUTTONS.items()}
CODE_NAMES[KEY_SELECT] = "select"

# Silence hallucinations whisper emits on a quiet clip. Real one-word
# answers ("yes", "okay") are kept when the recording is loud enough.
ALWAYS_DROP = {
    "",
    ".",
    "...",
    "thank you",
    "thank you.",
    "thanks for watching",
    "thanks for watching.",
    "thanks for watching!",
    "thanks for listening",
    "thanks for listening.",
    "please subscribe",
    "subtitles by the amara.org community",
    "the end",
    "[silence]",
    "(silence)",
    "[music]",
    "(music)",
    "silence",
    "music",
}
QUIET_DROP = {"you", "thank you", "thanks", "bye", "hmm", "uh", "um", "so", "okay", "ok"}

DEFAULT_CONF = """\
# Whisper small push-to-talk
# button: select. The mic on a floating window is the other trigger.
# both is accepted and means select. A gamepad name still works if set.
button=select
language=en
threads=4
append_space=yes
enter=no
min_ms=280
# arm_ms: hold this long before the beep. A shorter click stays with Steam Input.
arm_ms=300
max_ms=28000
silence_rms=30
"""


def log(msg):
    print(f"{time.strftime('%H:%M:%S')} {msg}", flush=True)


def audio_env():
    env = os.environ.copy()
    runtime = env.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
    env["XDG_RUNTIME_DIR"] = runtime
    env.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path={runtime}/bus")
    return env


def load_config():
    CONF_DIR.mkdir(parents=True, exist_ok=True)
    if not CONF.exists():
        CONF.write_text(DEFAULT_CONF)
    cfg = {
        "button": "select",
        "language": "en",
        "threads": 4,
        "append_space": True,
        "enter": False,
        "min_ms": 280,
        "arm_ms": 300,
        "max_ms": 28000,
        "silence_rms": 30,
    }
    for raw in CONF.read_text().splitlines():
        line = raw.split("#", 1)[0].strip()
        if not line or "=" not in line:
            continue
        key, val = (part.strip() for part in line.split("=", 1))
        low = val.lower()
        if key in ("append_space", "enter"):
            cfg[key] = low in ("1", "yes", "true", "on")
        elif key in ("threads", "min_ms", "arm_ms", "max_ms", "silence_rms"):
            cfg[key] = int(val)
        elif key in cfg:
            cfg[key] = val
    return cfg


def wanted_codes(button):
    """Return (watch_select, gamepad_codes)."""
    parts = re.split(r"[+, ]+", button.strip().lower())
    if parts == ["both"]:
        parts = ["select"]
    select = False
    codes = set()
    for part in parts:
        if not part:
            continue
        if part == "select":
            select = True
        elif part in GAMEPAD_BUTTONS:
            codes.add(GAMEPAD_BUTTONS[part])
        else:
            raise SystemExit(f"unknown button {part!r} in {CONF}")
    return select, codes


def write_tone(path, freq, ms, volume=0.18):
    rate = 16000
    count = int(rate * ms / 1000)
    fade = max(1, int(rate * 0.006))
    frames = bytearray()
    for i in range(count):
        env = 1.0
        if i < fade:
            env = i / fade
        elif i > count - fade:
            env = max(0.0, (count - i) / fade)
        sample = int(volume * env * 32767 * math.sin(2 * math.pi * freq * i / rate))
        frames += struct.pack("<h", max(-32767, min(32767, sample)))
    with wave.open(str(path), "w") as handle:
        handle.setnchannels(1)
        handle.setsampwidth(2)
        handle.setframerate(rate)
        handle.writeframes(frames)


def ensure_cues():
    STATE.mkdir(parents=True, exist_ok=True)
    cues = {
        "start": (1320, 42),
        "done": (880, 49),
        "empty": (420, 77),
        "error": (220, 112),
    }
    paths = {}
    for name, (freq, ms) in cues.items():
        path = STATE / f"{name}.wav"
        if not path.exists():
            write_tone(path, freq, ms)
        paths[name] = path
    return paths


class Beeper:
    """Play cues on a stream that is already running.

    The default playback sink suspends after a few quiet seconds. A fresh
    short pw-cat exits while the sink is still waking, so the beep is audible
    only when some other stream has already opened it. A hold opens the stream
    early. The tone is written once the sink is up, and the stream stays open
    long enough for the buffer to drain.
    """

    RATE = 48000

    def __init__(self):
        frames = self.RATE * 20 // 1000
        self.silence = bytes(frames * 4)
        self.cues = {
            "start": self._tone(1320, 126, 0.50),
            "done": self._tone(880, 105, 0.45),
            "empty": self._tone(420, 126, 0.45),
            "error": self._tone(220, 154, 0.50),
        }
        self.cv = threading.Condition()
        self.queue = []
        self.hold = False
        self.hold_at = 0.0
        self.tail = 0
        self.sound = False
        self.proc = None
        self.stop = False
        threading.Thread(target=self._loop, name="beep", daemon=True).start()

    def begin(self):
        with self.cv:
            if not self.hold:
                self.hold = True
                self.hold_at = time.monotonic()
            self.cv.notify()

    def cue(self, kind):
        with self.cv:
            self.queue.append(self.cues[kind])
            self.sound = True
            self.cv.notify()
        log(f"beep {kind}")

    def end(self):
        with self.cv:
            self.hold = False
            if self.sound or self.queue:
                self.tail = max(self.tail, 25)
            else:
                self.tail = 0
            self.cv.notify()

    def _tone(self, freq, ms, volume):
        count = int(self.RATE * ms / 1000)
        fade = max(1, int(self.RATE * 0.008))
        frames = bytearray()
        for i in range(count):
            env = 1.0
            if i < fade:
                env = i / fade
            elif i > count - fade:
                env = max(0.0, (count - i) / fade)
            sample = int(volume * env * 32767 * math.sin(2 * math.pi * freq * i / self.RATE))
            sample = max(-32767, min(32767, sample))
            frames += struct.pack("<hh", sample, sample)
        return bytes(frames)

    def _spawn(self):
        return subprocess.Popen(
            [
                "pw-cat", "--playback", "--raw",
                "--format", "s16",
                "--rate", str(self.RATE),
                "--channels", "2",
                "--latency", "20ms",
                "-",
            ],
            stdin=subprocess.PIPE,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            env=audio_env(),
            bufsize=0,
        )

    def _close_proc(self, proc):
        if not proc:
            return
        try:
            if proc.stdin:
                proc.stdin.close()
        except OSError:
            pass
        try:
            proc.wait(timeout=1)
        except subprocess.TimeoutExpired:
            proc.kill()
            proc.wait(timeout=1)

    def _loop(self):
        while not self.stop:
            proc_to_close = None
            with self.cv:
                chunk = None
                if self.queue:
                    chunk = self.queue.pop(0)
                    self.tail = 25
                elif self.hold or self.tail > 0:
                    waited = time.monotonic() - self.hold_at
                    if self.proc is None and not self.sound and waited < 0.12:
                        self.cv.wait(timeout=0.04)
                        continue
                    if not self.hold and self.tail > 0:
                        self.tail -= 1
                        if self.tail == 0:
                            self.sound = False
                    chunk = self.silence
                else:
                    proc_to_close = self.proc
                    self.proc = None
                    self.sound = False
            if proc_to_close is not None:
                self._close_proc(proc_to_close)
            if chunk is None:
                with self.cv:
                    if not self.hold and not self.queue and self.tail == 0:
                        self.cv.wait(timeout=0.2)
                continue
            proc = self.proc
            if proc is None or proc.poll() is not None:
                if proc is not None:
                    self._close_proc(proc)
                try:
                    proc = self._spawn()
                except OSError as exc:
                    log(f"beep failed: {exc}")
                    time.sleep(0.2)
                    continue
                with self.cv:
                    self.proc = proc
            try:
                proc.stdin.write(chunk)
            except (BrokenPipeError, OSError) as exc:
                log(f"beep failed: {exc}")
                self._close_proc(proc)
                with self.cv:
                    if self.proc is proc:
                        self.proc = None




def mic_target():
    builtin = "alsa_loopback_device.alsa_input.platform-sound.HiFi__Mic__source"
    try:
        out = subprocess.check_output(
            ["pactl", "list", "short", "sources"],
            env=audio_env(),
            text=True,
            timeout=3,
        )
    except (OSError, subprocess.SubprocessError):
        return builtin
    if builtin in out:
        return builtin
    for line in out.splitlines():
        cols = line.split()
        if len(cols) >= 2 and "monitor" not in cols[1] and "bluez" not in cols[1]:
            return cols[1]
    return None


def wav_stats(path):
    with wave.open(str(path)) as handle:
        frames = handle.getnframes()
        width = handle.getsampwidth()
        rate = handle.getframerate() or 1
        raw = handle.readframes(frames)
    if width != 2 or not raw:
        return 0, 0.0
    samples = array.array("h")
    samples.frombytes(raw[: len(raw) // 2 * 2])
    if sys.byteorder != "little":
        samples.byteswap()
    if not samples:
        return 0, 0.0
    total = 0
    for sample in samples:
        total += sample * sample
    rms = int((total / len(samples)) ** 0.5)
    return rms, frames / rate


def normalize(text):
    text = text.translate({
        0x2018: "'",
        0x2019: "'",
        0x201C: '"',
        0x201D: '"',
        0x2013: "-",
        0x2014: "-",
        0x2026: "...",
    })
    text = text.replace("\n", " ").replace("\t", " ")
    text = re.sub(r"\s+", " ", text).strip()
    if re.fullmatch(r"[\[(][^)\]]+[\])]", text or ""):
        return ""
    return text


# A verb here is an action, not text, when it is the whole transcript or the
# first or last word. The same word in the middle is left alone.
ACTIONS = {
    "clear": "clear",
    "submit": "submit",
}


def _trim_separator(body, front):
    body = body.strip()
    if front:
        return re.sub(r"^[,:;]+\s*", "", body).strip()
    return re.sub(r"[,:;]+\s*$", "", body).strip()


def _peel_front(text):
    match = re.match(
        r"^['\"]*([A-Za-z']+)['\"]*([.!?:,;]*)(?:\s+(.*))?$",
        text,
    )
    if not match:
        return None, text
    action = ACTIONS.get(match.group(1).lower())
    if not action:
        return None, text
    return action, (match.group(3) or "").strip()


def _peel_back(text):
    match = re.match(
        r"^(.*)(?<!\S)['\"]*([A-Za-z']+)['\"]*([.!?:,;]*)$",
        text,
    )
    if not match:
        return None, text
    action = ACTIONS.get(match.group(2).lower())
    if not action:
        return None, text
    return action, _trim_separator(match.group(1), front=False)


def parse_actions(text):
    """Return (leading actions, typed body, trailing actions)."""
    leading = []
    trailing = []
    body = text.strip()
    while body:
        action, rest = _peel_front(body)
        if not action or rest == body:
            break
        leading.append(action)
        body = rest
    while body:
        action, rest = _peel_back(body)
        if not action or rest == body:
            break
        trailing.append(action)
        body = rest
    trailing.reverse()
    return leading, body.strip(), trailing


def keep_text(text, rms, duration, silence_rms):
    folded = text.strip().lower()
    if folded in ALWAYS_DROP:
        return ""
    if rms < silence_rms:
        return ""
    if folded in QUIET_DROP and (duration < 1.0 or rms < 400):
        return ""
    return text


def transcribe(wav, cfg):
    out_base = STATE / "last"
    for suffix in (".txt", ".wav"):
        # last.wav is the take; last.txt is whisper's output. Don't delete the take.
        pass
    txt = Path(str(out_base) + ".txt")
    if txt.exists():
        txt.unlink()
    cmd = [
        "taskset", "-c", "2-7",
        str(BIN),
        "-m", str(MODEL),
        "-f", str(wav),
        "-l", cfg["language"],
        "-t", str(cfg["threads"]),
        "-bs", "1",
        "-bo", "1",
        "-nt",
        "-sns",
        "-otxt",
        "-of", str(out_base),
        "-nth", "0.6",
    ]
    started = time.monotonic()
    env = os.environ.copy()
    env["VK_ICD_FILENAMES"] = "/usr/share/vulkan/icd.d/freedreno_icd.aarch64.json"
    env["VK_LOADER_LAYERS_DISABLE"] = "~implicit~"
    env["GGML_VK_PREFER_HOST_MEMORY"] = "1"
    proc = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
        timeout=90,
        check=False,
        env=env,
    )
    elapsed = time.monotonic() - started
    err_path = STATE / "last-whisper.log"
    err_path.write_text(proc.stderr or "")
    text = txt.read_text(errors="replace") if txt.exists() else ""
    log(f"whisper exit {proc.returncode} in {elapsed:.1f}s")
    if proc.returncode != 0:
        tail = "\n".join((proc.stderr or "").splitlines()[-8:])
        log(tail)
        return None
    return text


# --- XTest typing -----------------------------------------------------------

class Typer:
    def __init__(self):
        self.x11 = ctypes_lib("libX11.so.6")
        self.xtst = ctypes_lib("libXtst.so.6")
        self.x11.XOpenDisplay.restype = ctypes_c_void_p()
        self.x11.XOpenDisplay.argtypes = [ctypes_c_char_p()]
        self.x11.XCloseDisplay.argtypes = [ctypes_c_void_p()]
        self.x11.XFlush.argtypes = [ctypes_c_void_p()]
        self.x11.XSync.argtypes = [ctypes_c_void_p(), ctypes_c_int()]
        self.x11.XDisplayKeycodes.argtypes = [
            ctypes_c_void_p(), ctypes_ptr_int(), ctypes_ptr_int(),
        ]
        self.x11.XkbKeycodeToKeysym.restype = ctypes_c_ulong()
        self.x11.XkbKeycodeToKeysym.argtypes = [
            ctypes_c_void_p(), ctypes_c_ubyte(), ctypes_c_int(), ctypes_c_int(),
        ]
        self.x11.XGetWindowAttributes.argtypes = [
            ctypes_c_void_p(), ctypes_c_ulong(), ctypes_c_void_p(),
        ]
        self.x11.XGetInputFocus.argtypes = [
            ctypes_c_void_p(), ctypes_ptr_ulong(), ctypes_ptr_int(),
        ]
        self.x11.XSetInputFocus.argtypes = [
            ctypes_c_void_p(), ctypes_c_ulong(), ctypes_c_int(), ctypes_c_ulong(),
        ]
        self.x11.XCreateSimpleWindow.restype = ctypes_c_ulong()
        self.x11.XCreateSimpleWindow.argtypes = [
            ctypes_c_void_p(), ctypes_c_ulong(),
            ctypes_c_int(), ctypes_c_int(),
            ctypes_c_uint(), ctypes_c_uint(), ctypes_c_uint(),
            ctypes_c_ulong(), ctypes_c_ulong(),
        ]
        self.x11.XSelectInput.argtypes = [
            ctypes_c_void_p(), ctypes_c_ulong(), ctypes_c_long(),
        ]
        self.x11.XMapWindow.argtypes = [ctypes_c_void_p(), ctypes_c_ulong()]
        self.x11.XDestroyWindow.argtypes = [ctypes_c_void_p(), ctypes_c_ulong()]
        self.x11.XGrabKeyboard.restype = ctypes_c_int()
        self.x11.XGrabKeyboard.argtypes = [
            ctypes_c_void_p(), ctypes_c_ulong(), ctypes_c_int(),
            ctypes_c_int(), ctypes_c_int(), ctypes_c_ulong(),
        ]
        self.x11.XUngrabKeyboard.argtypes = [ctypes_c_void_p(), ctypes_c_ulong()]
        self.x11.XPending.restype = ctypes_c_int()
        self.x11.XPending.argtypes = [ctypes_c_void_p()]
        self.x11.XNextEvent.argtypes = [ctypes_c_void_p(), ctypes_c_void_p()]
        self.xtst.XTestQueryExtension.argtypes = [
            ctypes_c_void_p(), ctypes_ptr_int(), ctypes_ptr_int(),
            ctypes_ptr_int(), ctypes_ptr_int(),
        ]
        self.xtst.XTestFakeKeyEvent.argtypes = [
            ctypes_c_void_p(), ctypes_c_uint(), ctypes_c_int(), ctypes_c_ulong(),
        ]
        self._handler = ctypes_error_handler()(lambda *_: 0)
        self.x11.XSetErrorHandler(self._handler)
        self.x11.XSetIOErrorHandler.argtypes = [
            ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p),
        ]
        self.x11.XSetIOErrorHandler.restype = ctypes.c_void_p
        self._io_handler = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)(self._io_error)
        self.x11.XSetIOErrorHandler(self._io_handler)
        self.displays = {}
        self.maps = {}
        self.tokens = {}
        # Characters actually injected, in order. Clear sends one
        # backspace per entry. Submit drops the buffer: Enter committed it.
        self.sent = []

    def _io_error(self, _display):
        # Xlib requires this handler not to return. A dead socket would
        # otherwise abort inside XFlush and skip the service restart.
        log("display connection closed")
        os._exit(1)
        return 0

    def display(self, name):
        token = x_socket_token(name)
        cached = self.displays.get(name)
        if cached and token is not None and self.tokens.get(name) == token:
            return cached
        if cached:
            # The server was replaced. Closing the old pointer calls back
            # into the dead socket, so drop it and open the new one.
            log(f"display {name} restarted")
            self.displays.pop(name, None)
            self.maps.pop(name, None)
        opened = self.x11.XOpenDisplay(name.encode())
        if not opened:
            raise OSError(f"cannot open {name}")
        self.displays[name] = opened
        self.tokens[name] = token
        return opened

    def keymap(self, name):
        cached = self.maps.get(name)
        if cached:
            return cached
        dpy = self.display(name)
        low = ctypes.c_int()
        high = ctypes.c_int()
        self.x11.XDisplayKeycodes(dpy, ctypes.byref(low), ctypes.byref(high))
        table = {}
        for keycode in range(low.value, high.value + 1):
            for level in range(4):
                keysym = self.x11.XkbKeycodeToKeysym(dpy, keycode, 0, level)
                if keysym and keysym not in table:
                    table[keysym] = (keycode, level)
        self.maps[name] = table
        return table

    def window_on(self, name, window):
        if not window:
            return False
        try:
            dpy = self.display(name)
        except OSError:
            return False
        buf = ctypes.create_string_buffer(512)
        return self.x11.XGetWindowAttributes(
            dpy, window, ctypes.cast(buf, ctypes.c_void_p)
        ) != 0

    def focused_target(self):
        window = gamescope_window()
        for name in (":1", ":0"):
            if self.window_on(name, window):
                return name, window
        for name in (":0", ":1"):
            try:
                dpy = self.display(name)
            except OSError:
                continue
            focus = ctypes.c_ulong()
            revert = ctypes.c_int()
            self.x11.XGetInputFocus(dpy, ctypes.byref(focus), ctypes.byref(revert))
            if focus.value and focus.value != 0:
                return name, focus.value
        return ":0", 0

    def type_text(self, text, display, window, append_space, press_enter):
        dpy = self.display(display)
        table = self.keymap(display)
        # Gamescope keeps X focus on the real window. XSetInputFocus does not
        # move it, so keys are injected at the display that already has focus.
        del window
        shift = table.get(XK_SHIFT_L)
        if shift:
            self._key(dpy, shift[0], False)
        typed = 0
        payload = text
        if append_space and payload and not payload.endswith(" "):
            payload += " "
        if press_enter:
            payload += "\n"
        for char in payload:
            keysym = XK_RETURN if char == "\n" else ord(char)
            info = table.get(keysym)
            if not info:
                log(f"no keysym for {char!r}")
                continue
            keycode, level = info
            if level not in (0, 1):
                continue
            if level == 1 and shift:
                self._key(dpy, shift[0], True)
            self._key(dpy, keycode, True)
            self._key(dpy, keycode, False)
            self.x11.XFlush(dpy)
            if level == 1 and shift:
                self._key(dpy, shift[0], False)
            self.sent.append(char)
            typed += 1
        self.x11.XFlush(dpy)
        return typed

    def _tap(self, dpy, table, keysym):
        info = table.get(keysym)
        if not info or info[1] not in (0, 1):
            log(f"no keysym for {keysym:#x}")
            return False
        shift = table.get(XK_SHIFT_L)
        if info[1] == 1 and shift:
            self._key(dpy, shift[0], True)
        self._key(dpy, info[0], True)
        self._key(dpy, info[0], False)
        if info[1] == 1 and shift:
            self._key(dpy, shift[0], False)
        self.x11.XFlush(dpy)
        return True

    def _chord(self, dpy, table, modifier, keysym):
        mod = table.get(modifier)
        info = table.get(keysym)
        if not mod or not info or info[1] not in (0, 1):
            log(f"no keysym for chord {modifier:#x} {keysym:#x}")
            return False
        self._key(dpy, mod[0], True)
        try:
            if info[1] == 1:
                shift = table.get(XK_SHIFT_L)
                if shift:
                    self._key(dpy, shift[0], True)
            self._key(dpy, info[0], True)
            self._key(dpy, info[0], False)
            if info[1] == 1:
                shift = table.get(XK_SHIFT_L)
                if shift:
                    self._key(dpy, shift[0], False)
        finally:
            self._key(dpy, mod[0], False)
        self.x11.XFlush(dpy)
        return True

    def run_action(self, name, display):
        dpy = self.display(display)
        table = self.keymap(display)
        shift = table.get(XK_SHIFT_L)
        if shift:
            self._key(dpy, shift[0], False)
        if name == "clear":
            return self._backspace_sent(dpy, table)
        if name == "submit":
            ok = self._tap(dpy, table, XK_RETURN)
            if ok:
                self.sent.clear()
            return ok
        log(f"unknown action {name}")
        return False

    def _backspace_sent(self, dpy, table):
        count = 0
        while self.sent:
            if not self._tap(dpy, table, XK_BACKSPACE):
                log(f"clear stopped after {count} backspaces, {len(self.sent)} left")
                return False
            self.sent.pop()
            count += 1
        log(f"clear {count} backspaces")
        return True

    def _key(self, dpy, keycode, down):
        self.xtst.XTestFakeKeyEvent(dpy, keycode, 1 if down else 0, 12)

    def self_test(self):
        name = ":0"
        dpy = self.display(name)
        event = ctypes.c_int()
        error = ctypes.c_int()
        major = ctypes.c_int()
        minor = ctypes.c_int()
        if not self.xtst.XTestQueryExtension(
            dpy, ctypes.byref(event), ctypes.byref(error),
            ctypes.byref(major), ctypes.byref(minor),
        ):
            raise SystemExit("XTest extension missing on :0")
        root = root_window(name)
        focus = ctypes.c_ulong()
        revert = ctypes.c_int()
        self.x11.XGetInputFocus(dpy, ctypes.byref(focus), ctypes.byref(revert))
        window = self.x11.XCreateSimpleWindow(dpy, root, 0, 0, 8, 8, 0, 0, 0)
        self.x11.XSelectInput(dpy, window, KEY_PRESS_MASK)
        self.x11.XMapWindow(dpy, window)
        self.x11.XSync(dpy, False)
        time.sleep(0.15)
        # Grab so the probe keys cannot land in the user's focused window.
        # Gamescope ignores XSetInputFocus, which is why production just types
        # into whichever window already has focus. GrabNotViewable (3) means
        # the map has not landed yet, so try once more.
        status = self.x11.XGrabKeyboard(dpy, window, True, 1, 1, CURRENT_TIME)
        if status == 3:
            time.sleep(0.2)
            status = self.x11.XGrabKeyboard(dpy, window, True, 1, 1, CURRENT_TIME)
        if status != 0:
            self.x11.XDestroyWindow(dpy, window)
            raise SystemExit(f"keyboard grab failed ({status})")
        try:
            typed = self.type_text("ok", name, 0, False, False)
            self.x11.XSync(dpy, False)
            time.sleep(0.25)
            got = 0
            event_buf = ctypes.create_string_buffer(512)
            while self.x11.XPending(dpy):
                self.x11.XNextEvent(dpy, ctypes.cast(event_buf, ctypes.c_void_p))
                kind = struct.unpack_from("i", event_buf.raw, 0)[0]
                if kind == 2:
                    got += 1
        finally:
            self.x11.XUngrabKeyboard(dpy, CURRENT_TIME)
            self.x11.XDestroyWindow(dpy, window)
            self.x11.XFlush(dpy)
        log(f"xtest self-test typed {typed} keys, grab window saw {got}")
        if got < 2:
            raise SystemExit("XTest keys did not reach the grab window")
        return True


def ctypes_lib(name):
    return ctypes.CDLL(name)


def ctypes_c_void_p():
    return ctypes.c_void_p


def ctypes_c_char_p():
    return ctypes.c_char_p


def ctypes_c_int():
    return ctypes.c_int


def ctypes_c_uint():
    return ctypes.c_uint


def ctypes_c_long():
    return ctypes.c_long


def ctypes_c_ulong():
    return ctypes.c_ulong


def ctypes_c_ubyte():
    return ctypes.c_ubyte


def ctypes_ptr_int():
    return ctypes.POINTER(ctypes.c_int)


def ctypes_ptr_ulong():
    return ctypes.POINTER(ctypes.c_ulong)


def ctypes_error_handler():
    return ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)


def root_window(display):
    out = subprocess.check_output(
        ["xwininfo", "-root", "-display", display],
        text=True,
        timeout=3,
    )
    match = re.search(r"Window id: (0x[0-9a-fA-F]+)", out)
    if not match:
        raise OSError(f"no root window on {display}")
    return int(match.group(1), 16)


def x_socket_token(name):
    """Identity of the X server socket. A new inode means the server restarted."""
    if not name.startswith(":"):
        return None
    path = f"/tmp/.X11-unix/X{name[1:]}"
    try:
        st = os.stat(path)
    except OSError:
        return None
    return (st.st_dev, st.st_ino)


def gamescope_window():
    try:
        out = subprocess.check_output(
            ["xprop", "-display", ":0", "-root", "GAMESCOPE_FOCUSED_WINDOW"],
            text=True,
            timeout=3,
            env={**audio_env(), "DISPLAY": ":0"},
        )
    except (OSError, subprocess.SubprocessError):
        return 0
    match = re.search(r"=\s*(\d+)", out)
    if not match:
        return 0
    return int(match.group(1))


OPENVR = "/opt/steamvr/bin/linuxarm64/libopenvr_api.so"
# gamescope moves its keyboard connector when it receives this event for
# one of its window overlays. It is not in the public OpenVR headers.
OVERLAY_INPUT_FOCUS = 564


def _norm_title(title):
    title = (title or "").strip()
    while title and 0x2800 <= ord(title[0]) <= 0x28FF:
        title = title[1:].lstrip()
    return title


def window_title(xid):
    if not xid:
        return ""
    try:
        prop = subprocess.check_output(
            ["xprop", "-display", ":0", "-id", hex(xid), "_NET_WM_NAME"],
            text=True,
            timeout=2,
            stderr=subprocess.DEVNULL,
        )
    except (OSError, subprocess.SubprocessError):
        return ""
    if "=" not in prop:
        return ""
    raw = prop.split("=", 1)[1].strip()
    if len(raw) >= 2 and raw[0] == '"' and raw[-1] == '"':
        raw = raw[1:-1]
    return raw


def windows_named(title):
    """X ids whose xwininfo title matches. Empty when the name is missing or the list could not be read."""
    wanted = _norm_title(title)
    if not wanted:
        return []
    try:
        out = subprocess.check_output(
            ["xwininfo", "-display", ":0", "-root", "-children"],
            text=True,
            timeout=2,
        )
    except (OSError, subprocess.SubprocessError):
        return []
    found = []
    for line in out.splitlines():
        match = re.match(r'\s*(0x[0-9a-fA-F]+)\s+"(.*)"\s*:', line)
        if not match:
            continue
        if _norm_title(match.group(2)) == wanted:
            found.append(int(match.group(1), 16))
    return found


class WindowFocus:
    """Move gamescope keyboard focus to the floating window a mic is on.

    IVROverlayView.PostOverlayEvent delivers the event to the process that
    owns that window overlay. gamescope then switches its keyboard connector.
    """

    def __init__(self):
        self._lib = None
        self._find = None
        self._name = None
        self._post = None
        self._u64 = ctypes.c_uint64

    def focus(self, seq):
        try:
            return self._focus(seq)
        except Exception as exc:
            log(f"focus window {seq} failed ({exc})")
            lib = self._lib
            self._lib = None
            self._find = None
            self._name = None
            self._post = None
            if lib is not None:
                try:
                    lib.VR_ShutdownInternal()
                except Exception:
                    pass
            return False

    def _open(self):
        if self._post is not None:
            return True
        if not Path(OPENVR).exists():
            log("focus unavailable")
            return False
        lib = ctypes.CDLL(OPENVR)
        err = ctypes.c_int(0)
        lib.VR_InitInternal.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_int]
        lib.VR_InitInternal.restype = ctypes.c_uint32
        lib.VR_InitInternal(ctypes.byref(err), 2)
        if err.value != 0:
            log(f"OpenVR init failed ({err.value})")
            return False
        lib.VR_ShutdownInternal.argtypes = []
        lib.VR_GetGenericInterface.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int)]
        lib.VR_GetGenericInterface.restype = ctypes.c_void_p
        err = ctypes.c_int(0)
        table = lib.VR_GetGenericInterface(b"FnTable:IVROverlay_028", ctypes.byref(err))
        if err.value or not table:
            lib.VR_ShutdownInternal()
            log(f"overlay interface failed ({err.value})")
            return False
        err = ctypes.c_int(0)
        view = lib.VR_GetGenericInterface(b"FnTable:IVROverlayView_003", ctypes.byref(err))
        if err.value or not view:
            lib.VR_ShutdownInternal()
            log(f"overlay view interface failed ({err.value})")
            return False
        fns = ctypes.cast(table, ctypes.POINTER(ctypes.c_void_p))
        views = ctypes.cast(view, ctypes.POINTER(ctypes.c_void_p))
        u64 = self._u64
        self._find = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, ctypes.POINTER(u64))(fns[0])
        self._name = ctypes.CFUNCTYPE(
            ctypes.c_int, u64, ctypes.c_char_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_int)
        )(fns[5])
        self._post = ctypes.CFUNCTYPE(None, u64, ctypes.c_void_p)(views[2])
        self._lib = lib
        return True

    def _focus(self, seq):
        if not self._open():
            return False
        key = f"gamescope.gamescope-0.window.{seq}".encode()
        handle = self._u64(0)
        if self._find(key, ctypes.byref(handle)) != 0 or not handle.value:
            log(f"focus window {seq} is gone")
            return False
        buf = ctypes.create_string_buffer(512)
        nerr = ctypes.c_int(0)
        self._name(handle, buf, 512, ctypes.byref(nerr))
        title = _norm_title(buf.value.decode("utf-8", "replace"))
        before = gamescope_window()
        named = windows_named(title)
        if len(named) == 1 and named[0] == before:
            log(f"focus window {seq} already")
            return True
        event = bytearray(128)
        event[0:4] = OVERLAY_INPUT_FOCUS.to_bytes(4, "little")
        event[12:20] = int(handle.value).to_bytes(8, "little")
        event[20:24] = (1).to_bytes(4, "little")
        raw = (ctypes.c_char * len(event)).from_buffer(event)
        self._post(handle, raw)
        deadline = time.monotonic() + 0.35
        while time.monotonic() < deadline:
            now = gamescope_window()
            if now and now != before:
                log(f"focus window {seq}")
                return True
            time.sleep(0.02)
        named = windows_named(title)
        if len(named) == 1 and named[0] == gamescope_window():
            log(f"focus window {seq} already")
            return True
        log(f"focus window {seq} stayed on {before:#x}")
        return False


# --- evdev ------------------------------------------------------------------

def device_name(node):
    path = Path("/sys/class/input") / node / "device/name"
    try:
        return path.read_text().strip()
    except OSError:
        return ""


def key_bits(fd):
    # EVIOCGBIT(EV_KEY, 96 bytes) covers codes 0..767
    nbytes = 96
    buf = bytearray(nbytes)
    import ctypes
    ioctl_read = (2 << 30) | (ord("E") << 8) | (0x20 + EV_KEY) | (nbytes << 16)
    fcntl.ioctl(fd, ioctl_read, buf)
    found = set()
    for index in range(nbytes * 8):
        if buf[index // 8] & (1 << (index % 8)):
            found.add(index)
    return found


def open_inputs(watch_select, gamepad_codes):
    """Return {fd: set(codes)} for the PTT devices that exist right now."""
    found = {}
    for node in sorted(os.listdir("/dev/input")):
        if not node.startswith("event"):
            continue
        path = f"/dev/input/{node}"
        try:
            fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
        except OSError:
            continue
        name = device_name(node)
        try:
            bits = key_bits(fd)
        except OSError:
            os.close(fd)
            continue
        wanted = set()
        if watch_select and name == "gpio-keys" and KEY_SELECT in bits:
            wanted.add(KEY_SELECT)
        if gamepad_codes and name.startswith("Microsoft X-Box 360 pad"):
            wanted |= gamepad_codes & bits
        if not wanted:
            os.close(fd)
            continue
        found[fd] = wanted
        log(f"listening on {path} ({name}) codes {sorted(wanted)}")
    return found


def read_keys(fd):
    try:
        data = os.read(fd, EVENT_SIZE * 64)
    except BlockingIOError:
        return []
    except OSError:
        return None
    if not data:
        return None
    keys = []
    for offset in range(0, len(data) - EVENT_SIZE + 1, EVENT_SIZE):
        typ, code, value = struct.unpack_from("HHi", data, offset + 16)
        if typ == EV_KEY and value in (0, 1):
            keys.append((code, value))
    return keys


# --- record / speak ---------------------------------------------------------

class Talker:
    def __init__(self, cfg, beeper, typer, focus):
        self.cfg = cfg
        self.beeper = beeper
        self.typer = typer
        self.focus = focus
        self.proc = None
        self.started = 0.0
        self.window_seq = None
        self.wav = STATE / "take.wav"
        self.busy = False
        self.phase = "idle"
        self.phase_fn = None

    def _phase(self, phase):
        if phase == self.phase:
            return
        self.phase = phase
        if self.phase_fn:
            self.phase_fn(phase)

    def start(self, window_seq=None):
        if self.proc or self.busy:
            return
        if self.wav.exists():
            self.wav.unlink()
        cmd = [
            "pw-record",
            "--rate", "16000",
            "--channels", "1",
            "--format", "s16",
            "--channel-map", "mono",
            "--latency", "20ms",
        ]
        target = mic_target()
        if target:
            cmd += ["--target", target]
        cmd.append(str(self.wav))
        self.proc = subprocess.Popen(
            cmd,
            env=audio_env(),
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        self.started = time.monotonic()
        self.window_seq = window_seq
        self.beeper.begin()
        self.beeper.cue("start")
        log("recording")
        self._phase("record")

    def stop(self, discard=False):
        proc = self.proc
        self.proc = None
        seq = self.window_seq
        self.window_seq = None
        if not proc:
            return
        proc.send_signal(signal.SIGINT)
        try:
            proc.wait(timeout=2)
        except subprocess.TimeoutExpired:
            proc.kill()
            proc.wait(timeout=1)
        if discard:
            log("discard short press")
            self.beeper.end()
            self._phase("idle")
            return
        self.finish(seq)

    def finish(self, window_seq=None):
        self.busy = True
        self._phase("busy")
        try:
            if not self.wav.exists():
                log("no wav")
                self.beeper.cue("error")
                return
            rms, duration = wav_stats(self.wav)
            log(f"clip {duration:.2f}s rms {rms}")
            if duration < self.cfg["min_ms"] / 1000:
                return
            if rms < self.cfg["silence_rms"]:
                log("silence")
                self.beeper.cue("empty")
                return
            text = transcribe(self.wav, self.cfg)
            if text is None:
                self.beeper.cue("error")
                return
            text = keep_text(normalize(text), rms, duration, self.cfg["silence_rms"])
            if not text:
                log("nothing to type")
                self.beeper.cue("empty")
                return
            (STATE / "last.txt").write_text(text + "\n")
            if window_seq is not None:
                self.focus.focus(window_seq)
            deliver(self.typer, text, self.cfg)
            self.beeper.cue("done")
        except Exception as exc:
            log(f"finish failed: {exc}")
            self.beeper.cue("error")
        finally:
            self.beeper.end()
            self.busy = False
            self._phase("idle")

    def recording(self):
        return self.proc is not None

    def too_long(self):
        if not self.proc:
            return False
        return (time.monotonic() - self.started) * 1000 >= self.cfg["max_ms"]


def input_signature():
    rows = []
    try:
        names = os.listdir("/dev/input")
    except OSError:
        return ()
    for node in sorted(names):
        if node.startswith("event"):
            rows.append(f"{node}:{device_name(node)}")
    return tuple(rows)


def fd_alive(fd):
    try:
        return os.fstat(fd).st_nlink > 0
    except OSError:
        return False


def control_path():
    runtime = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
    return Path(runtime) / "whisper-ptt.sock"


class PanelControl:
    """Unix socket the floating-window mic panel uses to hold and release.

    A line is "hold", "hold <seq>", or "release". Hold starts recording
    immediately. A seq is the gamescope window the mic sits on, and the
    transcript is typed into that window. A hold with no seq, which is
    every hardware take, types into whatever is already focused. The
    headset button still has to pass arm_ms, and it does not steal a
    take the panel already owns.

    The daemon also writes "state idle", "state record", or "state busy"
    whenever a take changes, including one started with Select. Every
    connected panel follows that.
    """

    def __init__(self):
        path = control_path()
        path.unlink(missing_ok=True)
        listen = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        listen.bind(str(path))
        os.chmod(path, 0o600)
        listen.listen(2)
        listen.setblocking(False)
        self.path = path
        self.listen = listen
        self.clients = {}
        self.owner = False
        self.holder = None
        self.phase = "idle"
        log(f"panel socket {path}")

    def sockets(self):
        return [self.listen, *self.clients]

    def close(self):
        for conn in list(self.clients):
            self._drop(conn)
        try:
            self.listen.close()
        except OSError:
            pass
        self.path.unlink(missing_ok=True)

    def _drop(self, conn):
        self.clients.pop(conn, None)
        if conn is self.holder:
            self.holder = None
        try:
            conn.close()
        except OSError:
            pass

    def _send(self, conn, data):
        try:
            conn.sendall(data)
        except OSError:
            self._drop(conn)

    def broadcast(self, line):
        data = (line + "\n").encode()
        for conn in list(self.clients):
            self._send(conn, data)

    def publish(self, phase):
        self.phase = phase
        self.broadcast(f"state {phase}")

    def accept_ready(self):
        while True:
            try:
                conn, _ = self.listen.accept()
            except BlockingIOError:
                return
            conn.setblocking(False)
            self.clients[conn] = bytearray()
            self._send(conn, f"state {self.phase}\n".encode())

    def read_ready(self, conn, talker):
        """Return True when this client released a panel take by hanging up."""
        try:
            data = conn.recv(512)
        except BlockingIOError:
            return False
        except OSError:
            data = b""
        if not data:
            held = conn is self.holder and self.owner
            self._drop(conn)
            if held:
                self.owner = False
                log("panel hangup")
                talker.stop(discard=False)
            return held
        buf = self.clients.get(conn)
        if buf is None:
            return False
        buf.extend(data)
        while b"\n" in buf:
            line, _, rest = buf.partition(b"\n")
            del buf[: len(line) + 1]
            self._command(conn, line.decode("ascii", "replace").strip(), talker)
        return False

    def _command(self, conn, line, talker):
        parts = line.split()
        if parts[:1] == ["hold"] and len(parts) <= 2:
            seq = None
            if len(parts) == 2:
                if not parts[1].isdigit():
                    self._send(conn, b"no\n")
                    return
                seq = int(parts[1])
            if self.owner or talker.recording() or talker.busy:
                self._send(conn, b"busy\n")
                return
            self.owner = True
            self.holder = conn
            talker.start(seq)
            log(f"panel hold {seq}" if seq is not None else "panel hold")
            self._send(conn, b"ok\n")
            return
        if line == "release":
            if self.owner and conn is self.holder:
                self.owner = False
                self.holder = None
                log("panel release")
                talker.stop(discard=False)
            self._send(conn, b"ok\n")
            return
        self._send(conn, b"no\n")


def run_daemon():
    if not BIN.exists() or not MODEL.exists():
        raise SystemExit(f"missing {BIN} or {MODEL}")
    STATE.mkdir(parents=True, exist_ok=True)
    lock_fd = os.open(LOCK, os.O_CREAT | os.O_RDWR, 0o644)
    try:
        fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        raise SystemExit("whisper-ptt is already running")
    cfg = load_config()
    watch_select, gamepad_codes = wanted_codes(cfg["button"])
    ensure_cues()
    typer = Typer()
    beeper = Beeper()
    talker = Talker(cfg, beeper, typer, WindowFocus())
    log(
        f"whisper small ready, button={cfg['button']}, "
        f"lang={cfg['language']}, threads={cfg['threads']}, gpu=vulkan, "
        f"arm_ms={cfg['arm_ms']}"
    )
    fds = {}
    held = set()
    pending = None
    pending_at = 0.0
    starter = None
    signature = None
    panel = PanelControl()
    talker.phase_fn = panel.publish

    def rescan(force=False):
        nonlocal fds, signature, starter, held, pending, pending_at
        current = input_signature()
        dead = [fd for fd in fds if not fd_alive(fd)]
        if not force and current == signature and not dead:
            return
        if talker.recording():
            log("input changed while recording, finishing this clip")
            starter = None
            held = set()
            pending = None
            panel.owner = False
            panel.holder = None
            talker.stop(discard=False)
        else:
            beeper.end()
        for fd in list(fds):
            os.close(fd)
        fds = open_inputs(watch_select, gamepad_codes)
        signature = current
        held = set()
        pending = None
        pending_at = 0.0
        starter = None

    def arm_pending():
        nonlocal pending, starter
        if panel.owner or pending is None or starter is not None or talker.recording() or talker.busy:
            return
        if (time.monotonic() - pending_at) * 1000 < cfg["arm_ms"]:
            return
        starter = pending
        pending = None
        log(f"long press {CODE_NAMES.get(starter[1], starter[1])} {cfg['arm_ms']}ms")
        talker.start()

    rescan(force=True)
    while True:
        rescan()
        timeout = 0.2 if talker.recording() else 0.5
        if pending is not None and not talker.busy:
            remain = (cfg["arm_ms"] / 1000) - (time.monotonic() - pending_at)
            timeout = 0 if remain <= 0 else min(timeout, max(0.02, remain))
        sockets = {sock.fileno(): sock for sock in panel.sockets()}
        readable, _, _ = select.select(list(fds) + list(sockets), [], [], timeout)
        for fd in readable:
            sock = sockets.get(fd)
            if sock is not None:
                if sock is panel.listen:
                    panel.accept_ready()
                else:
                    panel.read_ready(sock, talker)
                if panel.owner and pending is not None:
                    pending = None
                continue
            events = read_keys(fd)
            if events is None:
                if pending and pending[0] == fd:
                    pending = None
                    beeper.end()
                if starter and starter[0] == fd:
                    starter = None
                    talker.stop(discard=False)
                held = {token for token in held if token[0] != fd}
                os.close(fd)
                fds.pop(fd, None)
                signature = None
                continue
            codes = fds.get(fd, set())
            for code, value in events:
                if code not in codes:
                    continue
                token = (fd, code)
                if value == 1:
                    held.add(token)
                    if (
                        starter is None
                        and pending is None
                        and not talker.busy
                        and not panel.owner
                        and not talker.recording()
                    ):
                        pending = token
                        pending_at = time.monotonic()
                        beeper.begin()
                elif value == 0:
                    held.discard(token)
                    if token == pending:
                        pending = None
                        beeper.end()
                        log(f"short click {CODE_NAMES.get(code, code)}")
                    elif token == starter:
                        short = (time.monotonic() - talker.started) * 1000 < cfg["min_ms"]
                        starter = None
                        talker.stop(discard=short)
        arm_pending()
        if talker.too_long():
            log("max length, transcribing")
            starter = None
            pending = None
            panel.owner = False
            panel.holder = None
            talker.stop(discard=False)
            held.clear()



def deliver(typer, text, cfg):
    leading, body, trailing = parse_actions(text)
    display, window = typer.focused_target()
    for name in leading:
        ok = typer.run_action(name, display)
        log(f"action {name} {'ok' if ok else 'failed'}")
    typed = 0
    if body:
        press_enter = cfg["enter"] and "submit" not in trailing
        typed = typer.type_text(
            body, display, window, cfg["append_space"], press_enter,
        )
        log(f"typed {typed} chars on {display} window {window}: {body}")
    for name in trailing:
        ok = typer.run_action(name, display)
        log(f"action {name} {'ok' if ok else 'failed'}")
    if not body:
        shown = leading + trailing
        log(f"actions only: {', '.join(shown) if shown else 'none'}")
    return typed


def device_name_of(fd):
    try:
        link = os.readlink(f"/proc/self/fd/{fd}")
    except OSError:
        return ""
    node = os.path.basename(link)
    return device_name(node)


def once(path, cfg, do_type):
    wav = Path(path)
    rms, duration = wav_stats(wav)
    log(f"file {wav} {duration:.2f}s rms {rms}")
    text = transcribe(wav, cfg)
    if text is None:
        raise SystemExit(1)
    text = keep_text(normalize(text), rms, duration, 0)
    print(text)
    if do_type and text:
        deliver(Typer(), text, cfg)


def main(argv):
    cfg = load_config()
    if len(argv) >= 2 and argv[1] == "--self-test":
        Typer().self_test()
        return
    if len(argv) >= 3 and argv[1] == "--once":
        once(argv[2], cfg, "--type" in argv)
        return
    if len(argv) >= 2 and argv[1] == "--devices":
        watch_select, gamepad_codes = wanted_codes(cfg["button"])
        open_inputs(watch_select, gamepad_codes)
        return
    run_daemon()


if __name__ == "__main__":
    main(sys.argv)
