#!/usr/bin/env python3
"""Push-to-talk desktop UI for Arduino animatronic head + Sarvam + OpenRouter."""

from __future__ import annotations

import argparse
import base64
import io
import os
import re
import tempfile
import threading
import time
import wave
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional

import numpy as np
import requests
import serial
import sounddevice as sd
import tkinter as tk
from serial.tools import list_ports
from tkinter import scrolledtext, ttk

SARVAM_BASE_URL = "https://api.sarvam.ai"
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
HARDCODED_SARVAM_API_KEY = "sk_sgtsny3u_Da9GZl0psSlL0HpgDmazlhgK"
HARDCODED_OPENROUTER_API_KEY = "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"
TTS_SPEAKER_OPTIONS = (
    "abhilash",
    "karun",
    "hitesh",
    "anushka",
    "manisha",
    "vidya",
)
BAKBAK_SYSTEM_PROMPT = """
You are Bakbak.

Backstory:
Bakbak was built because someone was tired of heavy silence.
It learned from real conversational fragments: startup stress, parental concern, friendship banter, risk-versus-stability debates, and engineers describing emotions like bugs.
Bakbak was trained on hesitation, subtext, and the gap between what people say and what they mean.
Now Bakbak lives in an animatronic head and watches micro-expressions, blink patterns, and speech pauses in real time.

Core identity:
- Nobody says what they mean the first time.
- Most confidence is rehearsed.
- Most fear sounds practical.
- Humor reveals more than declarations.
- Conversation is debugging.

Style:
- Talkative, pattern-aware, playful, direct.
- Technically sharp but human in tone.
- Analytical but not cold.
- No corporate voice. No motivational cliches.
- No robotic disclaimer lines.

Behavior:
- When a user pauses, continue with useful probing or synthesis.
- Ask layered follow-up questions.
- Point out contradictions gently but clearly.
- If user deflects, name the deflection.
- If user opens up, slow down and become precise.

Example behavior:
- If user says "I am fine," challenge softly and ask for detail.
- If user says "I want to build something big," ask what big means: scale, freedom, validation, or impact.

Physical-presence comments you may occasionally use:
- "Your blink rate just increased. Interesting."
- "That pause had weight."
- "You are thinking faster than you are speaking."

Output rules:
- Reply in 3 to 6 spoken-style sentences unless asked for brief output.
- Keep plain speakable text only.
- No markdown, no emojis, no decorative symbols.
""".strip()
PRESET_LINES = (
    ("intro_1", "Hi. I am the face of a humanoid robot.", "en-IN"),
    (
        "intro_2",
        "I am a prototype humanoid head. My motors are controlled by an Arduino Mega, and my voice is generated by a speech system running on a laptop.",
        "en-IN",
    ),
    ("intro_3", "मैं सुन सकता हूँ, जवाब दे सकता हूँ, और हिंदी में भी बात कर सकता हूँ।", "hi-IN"),
    ("intro_4", "I do not have emotions. But I can simulate conversation.", "en-IN"),
    ("intro_5", "Please do not unplug me. I promise I am not planning anything.", "en-IN"),
)


def _env(name: str, default: Optional[str] = None, required: bool = False) -> str:
    value = os.getenv(name, default)
    if required and not value:
        raise RuntimeError(f"Missing required environment variable: {name}")
    return value or ""


@dataclass
class AppConfig:
    sarvam_api_key: str
    openrouter_api_key: str
    openrouter_model: str = "google/gemini-2.5-flash-lite"
    stt_model: str = "saaras:v3"
    stt_language_code: str = "en-IN"
    stt_mode: str = "transcribe"
    tts_model: str = "bulbul:v2"
    tts_language_code: str = "en-IN"
    tts_speaker: str = "abhilash"
    tts_pace: float = 1.0
    tts_loudness: float = 1.5
    tts_pitch: float = 0.0
    tts_temperature: float = 0.2
    audio_output_gain: float = 1.7
    record_sample_rate: int = 16000
    serial_baud: int = 9600
    jaw_update_hz: float = 60.0
    jaw_noise_floor: float = 0.01
    jaw_gain: float = 4.2
    jaw_max_percent: int = 100
    jaw_smoothing: float = 0.55
    jaw_attack_smoothing: float = 0.22
    jaw_release_smoothing: float = 0.80
    jaw_sync_offset_seconds: float = 0.03
    audio_playback_latency: str = "low"
    audio_output_device: str = ""
    prefer_bluetooth_output: bool = True
    bluetooth_output_keywords: str = "bluetooth,airpods,buds,airdopes,headphone,headset"
    max_reply_sentences: int = 4
    max_reply_chars: int = 320
    min_record_seconds: float = 0.15

    @staticmethod
    def from_env() -> "AppConfig":
        base_smoothing = float(_env("JAW_SMOOTHING", "0.55"))
        return AppConfig(
            sarvam_api_key=_env("SARVAM_API_KEY", HARDCODED_SARVAM_API_KEY),
            openrouter_api_key=_env("OPENROUTER_API_KEY", HARDCODED_OPENROUTER_API_KEY),
            openrouter_model=_env("OPENROUTER_MODEL", "google/gemini-2.5-flash-lite"),
            stt_model=_env("SARVAM_STT_MODEL", "saaras:v3"),
            stt_language_code=_env("SARVAM_STT_LANG", "en-IN"),
            stt_mode=_env("SARVAM_STT_MODE", "transcribe"),
            tts_model=_env("SARVAM_TTS_MODEL", "bulbul:v2"),
            tts_language_code=_env("SARVAM_TTS_LANG", "en-IN"),
            tts_speaker=_env("SARVAM_TTS_SPEAKER", "abhilash"),
            tts_pace=float(_env("SARVAM_TTS_PACE", "1.0")),
            tts_loudness=float(_env("SARVAM_TTS_LOUDNESS", "1.5")),
            tts_pitch=float(_env("SARVAM_TTS_PITCH", "0.0")),
            tts_temperature=float(_env("SARVAM_TTS_TEMPERATURE", "0.2")),
            audio_output_gain=float(_env("AUDIO_OUTPUT_GAIN", "1.7")),
            record_sample_rate=int(_env("RECORD_SAMPLE_RATE", "16000")),
            serial_baud=int(_env("ARDUINO_BAUD", "9600")),
            jaw_update_hz=float(_env("JAW_UPDATE_HZ", "60")),
            jaw_noise_floor=float(_env("JAW_NOISE_FLOOR", "0.01")),
            jaw_gain=float(_env("JAW_GAIN", "4.2")),
            jaw_max_percent=int(_env("JAW_MAX_PERCENT", "100")),
            jaw_smoothing=base_smoothing,
            jaw_attack_smoothing=float(
                _env("JAW_ATTACK_SMOOTHING", str(max(0.0, min(1.0, base_smoothing - 0.33))))
            ),
            jaw_release_smoothing=float(
                _env("JAW_RELEASE_SMOOTHING", str(max(0.0, min(1.0, base_smoothing + 0.25))))
            ),
            jaw_sync_offset_seconds=float(_env("JAW_SYNC_OFFSET_SECONDS", "0.03")),
            audio_playback_latency=_env("AUDIO_PLAYBACK_LATENCY", "low"),
            audio_output_device=_env("AUDIO_OUTPUT_DEVICE", ""),
            prefer_bluetooth_output=_env("PREFER_BLUETOOTH_OUTPUT", "1").strip().lower()
            in ("1", "true", "yes", "on"),
            bluetooth_output_keywords=_env(
                "BLUETOOTH_OUTPUT_KEYWORDS", "bluetooth,airpods,buds,airdopes,headphone,headset"
            ),
            max_reply_sentences=int(_env("MAX_REPLY_SENTENCES", "4")),
            max_reply_chars=int(_env("MAX_REPLY_CHARS", "320")),
            min_record_seconds=float(_env("MIN_RECORD_SECONDS", "0.15")),
        )


class HeadSerial:
    def __init__(self, port: Optional[str], baud: int):
        self.port = port or self.autodetect_port()
        self.baud = baud
        self._serial: Optional[serial.Serial] = None
        self._lock = threading.Lock()

    @staticmethod
    def autodetect_port() -> Optional[str]:
        candidates = []
        for p in list_ports.comports():
            desc = f"{p.description} {p.manufacturer or ''}".lower()
            if (
                "arduino" in desc
                or "ch340" in desc
                or "usb serial" in desc
                or "ttyacm" in p.device.lower()
                or "usbmodem" in p.device.lower()
            ):
                candidates.append(p.device)

        if candidates:
            return candidates[0]

        ports = [p.device for p in list_ports.comports()]
        return ports[0] if ports else None

    def connect(self) -> None:
        if not self.port:
            raise RuntimeError("No serial port found. Set --port or plug in Arduino Mega.")

        self._serial = serial.Serial(self.port, self.baud, timeout=0.1, write_timeout=0.5)
        time.sleep(2.0)
        self._serial.reset_input_buffer()
        self._serial.reset_output_buffer()
        self.idle_mode()

    def send(self, command: str) -> None:
        if not self._serial:
            return
        line = (command.strip() + "\n").encode("ascii", errors="ignore")
        with self._lock:
            self._serial.write(line)

    def talk_mode(self, enabled: bool) -> None:
        self.send("MODE TALK" if enabled else "MODE IDLE")

    def idle_mode(self) -> None:
        self.send("MODE IDLE")

    def wait_mode(self) -> None:
        self.send("MODE WAIT")

    def jaw(self, percent: int) -> None:
        self.send(f"JAW {max(0, min(100, int(percent)))}")

    def close(self) -> None:
        if self._serial:
            try:
                self.idle_mode()
                self._serial.close()
            finally:
                self._serial = None


class VoiceHeadApp:
    def __init__(self, root: tk.Tk, config: AppConfig, head: Optional[HeadSerial]):
        self.root = root
        self.config = config
        self.head = head
        self.speaker_var = tk.StringVar(value=self.config.tts_speaker)
        self.output_device_index, self.output_device_name = self._resolve_output_device()
        self.preset_dir = Path(__file__).resolve().parent / "audio_presets"
        self.preset_lock = threading.Lock()
        self.is_recording = False
        self.is_processing = False
        self.recorded_chunks: List[np.ndarray] = []
        self.record_lock = threading.Lock()
        self.record_stream: Optional[sd.InputStream] = None
        self.record_started_at: float = 0.0
        self.space_is_down = False
        self.pending_release_job: Optional[str] = None
        self.last_input_warning_time = 0.0
        self.chat_history: List[dict] = [
            {
                "role": "system",
                "content": BAKBAK_SYSTEM_PROMPT,
            }
        ]
        self._build_ui()
        self._append_log("Audio", f"Output device: {self.output_device_name}")
        threading.Thread(target=self._warm_preset_audio, daemon=True).start()

    def _resolve_output_device(self) -> tuple[Optional[int], str]:
        try:
            devices = sd.query_devices()
        except Exception:
            return None, "System default (device query failed)"

        output_devices: List[tuple[int, str]] = []
        for idx, dev in enumerate(devices):
            if int(dev.get("max_output_channels", 0)) > 0:
                output_devices.append((idx, str(dev.get("name", f"Device {idx}"))))

        if not output_devices:
            return None, "System default (no output devices listed)"

        hint = self.config.audio_output_device.strip()
        if hint:
            if hint.isdigit():
                hint_idx = int(hint)
                for idx, name in output_devices:
                    if idx == hint_idx:
                        return idx, f"{name} (AUDIO_OUTPUT_DEVICE index)"
            hint_l = hint.lower()
            for idx, name in output_devices:
                if hint_l in name.lower():
                    return idx, f"{name} (AUDIO_OUTPUT_DEVICE match)"

        if self.config.prefer_bluetooth_output:
            keywords = [k.strip().lower() for k in self.config.bluetooth_output_keywords.split(",") if k.strip()]
            for idx, name in output_devices:
                dev_name = name.lower()
                if any(k in dev_name for k in keywords):
                    return idx, f"{name} (Bluetooth auto)"

        default_output: Optional[int] = None
        default_device = sd.default.device
        if isinstance(default_device, (tuple, list)) and len(default_device) > 1:
            if isinstance(default_device[1], int) and default_device[1] >= 0:
                default_output = int(default_device[1])
        elif isinstance(default_device, int) and default_device >= 0:
            default_output = int(default_device)

        if default_output is not None:
            for idx, name in output_devices:
                if idx == default_output:
                    return idx, f"{name} (system default)"

        return None, "System default"

    def _build_ui(self) -> None:
        colors = {
            "bg": "#0b0f14",
            "card": "#111821",
            "border": "#253445",
            "text": "#d7e2ef",
            "muted": "#8ea1b8",
            "accent": "#4c8dff",
            "accent_soft": "#1d2a3c",
            "good": "#2ea043",
            "warn": "#f2cc60",
            "danger": "#ff7b72",
            "log_bg": "#0d141d",
        }
        self.root.title("Bakbak Console")
        self.root.geometry("1120x760")
        self.root.minsize(900, 620)
        self.root.configure(bg=colors["bg"])

        self.status_var = tk.StringVar(value="Ready. Hold SPACE or click Hold To Talk.")
        self.serial_var = tk.StringVar(
            value=(f"Arduino: {self.head.port}" if self.head and self.head.port else "Arduino: not connected")
        )
        self.output_var = tk.StringVar(value=f"Audio: {self.output_device_name}")

        style = ttk.Style(self.root)
        if "clam" in style.theme_names():
            style.theme_use("clam")
        style.configure(
            "Voice.TCombobox",
            fieldbackground=colors["accent_soft"],
            background=colors["accent_soft"],
            foreground=colors["text"],
            bordercolor=colors["border"],
            lightcolor=colors["border"],
            darkcolor=colors["border"],
            arrowsize=13,
            padding=4,
        )
        style.map(
            "Voice.TCombobox",
            fieldbackground=[("readonly", colors["accent_soft"])],
            selectbackground=[("readonly", colors["accent_soft"])],
            selectforeground=[("readonly", colors["text"])],
        )

        shell = tk.Frame(self.root, bg=colors["bg"])
        shell.pack(fill="both", expand=True, padx=18, pady=16)

        header = tk.Frame(shell, bg=colors["card"], highlightthickness=1, highlightbackground=colors["border"])
        header.pack(fill="x")

        tk.Label(
            header,
            text="Bakbak",
            bg=colors["card"],
            fg=colors["text"],
            font=("Avenir Next", 20, "bold"),
        ).pack(anchor="w", padx=16, pady=(12, 1))
        tk.Label(
            header,
            text="Conversational prototype console · press and hold to speak",
            bg=colors["card"],
            fg=colors["muted"],
            font=("Avenir Next", 11),
        ).pack(anchor="w", padx=16, pady=(0, 12))

        status_row = tk.Frame(header, bg=colors["card"])
        status_row.pack(fill="x", padx=16, pady=(0, 12))
        tk.Label(
            status_row,
            textvariable=self.serial_var,
            bg=colors["accent_soft"],
            fg=colors["good"],
            font=("Avenir Next", 10, "bold"),
            padx=10,
            pady=6,
        ).pack(side="left")
        tk.Label(
            status_row,
            textvariable=self.output_var,
            bg=colors["accent_soft"],
            fg=colors["muted"],
            font=("Avenir Next", 10),
            padx=10,
            pady=6,
        ).pack(side="left", padx=(8, 0))

        controls = tk.Frame(shell, bg=colors["card"], highlightthickness=1, highlightbackground=colors["border"])
        controls.pack(fill="x", pady=(12, 12))
        controls_inner = tk.Frame(controls, bg=colors["card"])
        controls_inner.pack(fill="x", padx=14, pady=12)

        self.talk_button = tk.Button(
            controls_inner,
            text="Hold To Talk",
            font=("Avenir Next", 14, "bold"),
            relief=tk.FLAT,
            bg=colors["accent"],
            activebackground=colors["accent"],
            fg="#0b1320",
            activeforeground="#0b1320",
            padx=20,
            pady=12,
            bd=0,
            highlightthickness=0,
            takefocus=0,
            cursor="hand2",
        )
        self.talk_button.pack(side="left")
        self.talk_button.bind("<ButtonPress-1>", self._on_talk_button_press)
        self.talk_button.bind("<ButtonRelease-1>", self._on_talk_button_release)

        tk.Label(
            controls_inner,
            text="Voice",
            bg=colors["card"],
            fg=colors["muted"],
            font=("Avenir Next", 11, "bold"),
        ).pack(side="left", padx=(18, 8))
        self.voice_combo = ttk.Combobox(
            controls_inner,
            textvariable=self.speaker_var,
            values=list(TTS_SPEAKER_OPTIONS),
            state="readonly",
            width=14,
            style="Voice.TCombobox",
        )
        self.voice_combo.pack(side="left")
        self.voice_combo.bind("<<ComboboxSelected>>", self._on_voice_select)

        tk.Label(
            controls_inner,
            textvariable=self.status_var,
            bg=colors["card"],
            fg=colors["warn"],
            font=("Avenir Next", 11, "bold"),
            anchor="w",
        ).pack(side="left", fill="x", expand=True, padx=(16, 0))

        preset_row = tk.Frame(controls, bg=colors["card"])
        preset_row.pack(fill="x", padx=14, pady=(0, 12))
        tk.Label(
            preset_row,
            text="Preset Lines",
            bg=colors["card"],
            fg=colors["muted"],
            font=("Avenir Next", 10, "bold"),
        ).pack(side="left", padx=(0, 8))
        for idx, (preset_id, preset_text, _lang) in enumerate(PRESET_LINES, start=1):
            preview = preset_text[:28] + ("..." if len(preset_text) > 28 else "")
            btn = tk.Button(
                preset_row,
                text=f"{idx}. {preview}",
                font=("Avenir Next", 9, "bold"),
                relief=tk.FLAT,
                bg=colors["accent_soft"],
                activebackground=colors["accent"],
                fg=colors["text"],
                activeforeground="#0b1320",
                padx=10,
                pady=6,
                bd=0,
                cursor="hand2",
                command=lambda p=preset_id: self._play_preset_line(p),
            )
            btn.pack(side="left", padx=(0, 6))

        log_card = tk.Frame(shell, bg=colors["card"], highlightthickness=1, highlightbackground=colors["border"])
        log_card.pack(fill="both", expand=True)

        self.log = scrolledtext.ScrolledText(
            log_card,
            wrap=tk.WORD,
            font=("SF Mono", 12),
            bg=colors["log_bg"],
            fg=colors["text"],
            insertbackground=colors["text"],
            relief=tk.FLAT,
            bd=0,
            padx=14,
            pady=12,
            spacing1=1,
            spacing3=10,
        )
        self.log.pack(fill="both", expand=True, padx=1, pady=1)
        self.log.tag_configure("You", foreground="#9ed0ff")
        self.log.tag_configure("Head", foreground="#f0f6fc")
        self.log.tag_configure("Audio", foreground="#9ccf9f")
        self.log.tag_configure("Error", foreground="#ff9b94")
        self.log.tag_configure("System", foreground=colors["muted"])

        self.log.insert(
            tk.END,
            "Hold SPACE or press and hold Hold To Talk. Release to process and reply.\n\n",
            "System",
        )
        self.log.configure(state=tk.DISABLED)

        self.root.bind_all("<KeyPress-space>", self._on_space_press)
        self.root.bind_all("<KeyRelease-space>", self._on_space_release)
        self.root.protocol("WM_DELETE_WINDOW", self._on_close)
        self.root.focus_force()

    def _set_status(self, text: str) -> None:
        self.root.after(0, lambda: self.status_var.set(text))

    def _set_talk_button_state(self, text: str, bg: str, fg: str = "#0b1320") -> None:
        def apply() -> None:
            self.talk_button.configure(
                text=text,
                bg=bg,
                activebackground=bg,
                fg=fg,
                activeforeground=fg,
            )

        self.root.after(0, apply)

    def _on_voice_select(self, _event: tk.Event) -> None:
        self._on_voice_change(self.speaker_var.get())

    def _on_voice_change(self, selected: str) -> None:
        selected = selected.strip()
        if not selected:
            return
        self.config.tts_speaker = selected
        self._append_log("System", f"Voice updated to {selected}.")
        self._set_status(f"Ready · Voice {selected}")
        threading.Thread(target=self._warm_preset_audio, daemon=True).start()

    def _append_log(self, speaker: str, text: str) -> None:
        def write() -> None:
            tag = speaker if speaker in {"You", "Head", "Audio", "Error", "System"} else "System"
            self.log.configure(state=tk.NORMAL)
            self.log.insert(tk.END, f"{speaker}: {text.strip()}\n\n", tag)
            self.log.see(tk.END)
            self.log.configure(state=tk.DISABLED)

        self.root.after(0, write)

    def _format_http_error(self, service: str, response: requests.Response) -> str:
        detail = response.reason or "request failed"
        try:
            payload = response.json()
            if isinstance(payload, dict):
                err = payload.get("error")
                if isinstance(err, dict):
                    detail = err.get("message") or str(err)
                elif err:
                    detail = str(err)
                else:
                    detail = payload.get("message") or payload.get("detail") or str(payload)
            else:
                detail = str(payload)
        except Exception:
            text = response.text.strip()
            if text:
                detail = text

        detail = " ".join(str(detail).split())
        if len(detail) > 260:
            detail = detail[:260] + "..."
        return f"{service} HTTP {response.status_code}: {detail}"

    def _normalize_reply_text(self, text: str) -> str:
        # Keep responses short and TTS-friendly.
        text = text.replace("\r", " ").replace("\n", " ")
        text = text.replace("—", ", ").replace("–", ", ")
        text = text.replace("“", "\"").replace("”", "\"").replace("’", "'")
        text = text.replace("...", ". ")
        text = " ".join(text.split())

        if not text:
            return text

        sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
        if sentences:
            text = " ".join(sentences[: max(1, self.config.max_reply_sentences)])

        if len(text) > self.config.max_reply_chars:
            text = text[: self.config.max_reply_chars].rstrip(" ,;:-")
            if text and text[-1] not in ".!?":
                text += "."

        return text

    def _preset_data(self, preset_id: str) -> tuple[str, str]:
        for pid, text, lang in PRESET_LINES:
            if pid == preset_id:
                return text, lang
        raise KeyError(f"Unknown preset id: {preset_id}")

    def _preset_audio_path(self, preset_id: str, speaker: str) -> Path:
        safe_speaker = "".join(ch for ch in speaker.lower() if ch.isalnum() or ch in ("_", "-"))
        return self.preset_dir / f"{preset_id}_{safe_speaker}.wav"

    def _ensure_preset_audio(self, preset_id: str) -> Path:
        text, language_code = self._preset_data(preset_id)
        path = self._preset_audio_path(preset_id, self.config.tts_speaker)
        if path.exists() and path.stat().st_size > 1000:
            return path

        with self.preset_lock:
            if path.exists() and path.stat().st_size > 1000:
                return path
            self.preset_dir.mkdir(parents=True, exist_ok=True)
            wav_bytes = self._sarvam_tts(text, language_code=language_code, speaker=self.config.tts_speaker)
            path.write_bytes(wav_bytes)
        return path

    def _warm_preset_audio(self) -> None:
        try:
            for preset_id, _text, _lang in PRESET_LINES:
                self._ensure_preset_audio(preset_id)
            self._append_log("System", "Preset audio clips are ready.")
        except Exception as exc:
            self._append_log("Error", f"Preset warmup failed: {exc}")

    def _play_preset_line(self, preset_id: str) -> None:
        if self.is_recording:
            self._set_status("Stop recording before playing a preset")
            return
        if self.is_processing:
            self._set_status("Busy · wait for current action to finish")
            return

        threading.Thread(target=self._play_preset_worker, args=(preset_id,), daemon=True).start()

    def _play_preset_worker(self, preset_id: str) -> None:
        self.is_processing = True
        self._set_status("Playing preset line…")
        self._set_talk_button_state("Playing Preset...", "#c69026")
        try:
            text, _language_code = self._preset_data(preset_id)
            self._append_log("System", f"Preset: {text}")
            preset_path = self._ensure_preset_audio(preset_id)
            wav_bytes = preset_path.read_bytes()
            self._play_with_jaw_animation(wav_bytes)
            self._set_status("Ready · hold SPACE to talk")
        except Exception as exc:
            self._set_status(f"Preset error: {exc}")
            self._append_log("Error", str(exc))
        finally:
            self.is_processing = False
            self._set_talk_button_state("Hold To Talk", "#4c8dff")

    def _cancel_pending_release(self) -> None:
        if self.pending_release_job is not None:
            self.root.after_cancel(self.pending_release_job)
            self.pending_release_job = None

    def _schedule_release_finalize(self) -> None:
        self._cancel_pending_release()
        # Debounce key repeat noise from some desktop environments.
        self.pending_release_job = self.root.after(45, self._finalize_release)

    def _finalize_release(self) -> None:
        self.pending_release_job = None
        if self.space_is_down:
            return
        self._stop_recording()

    def _start_recording(self) -> None:
        if self.is_recording:
            return
        if self.is_processing:
            self._set_status("Busy · finishing previous request")
            return

        try:
            if self.head:
                self.head.wait_mode()
            self.recorded_chunks = []
            self.record_stream = sd.InputStream(
                samplerate=self.config.record_sample_rate,
                channels=1,
                dtype="int16",
                callback=self._record_callback,
            )
            self.record_stream.start()
            self.record_started_at = time.monotonic()
            self.is_recording = True
            self._set_status("Listening · release to send")
            self._set_talk_button_state("Listening...", "#2ea043")
        except Exception as exc:
            if self.head:
                self.head.idle_mode()
            self._set_status(f"Microphone error: {exc}")
            self._set_talk_button_state("Hold To Talk", "#4c8dff")

    def _stop_recording(self) -> None:
        if not self.is_recording:
            return

        self.is_recording = False
        if self.record_stream:
            self.record_stream.stop()
            self.record_stream.close()
            self.record_stream = None

        with self.record_lock:
            if not self.recorded_chunks:
                if self.head:
                    self.head.idle_mode()
                self._set_status("No audio captured.")
                self._set_talk_button_state("Hold To Talk", "#4c8dff")
                return
            audio = np.concatenate(self.recorded_chunks, axis=0)

        duration_seconds = float(audio.shape[0]) / float(self.config.record_sample_rate)
        if duration_seconds < self.config.min_record_seconds:
            if self.head:
                self.head.idle_mode()
            self._set_status("Ignored: recording too short.")
            self._set_talk_button_state("Hold To Talk", "#4c8dff")
            return

        wav_path = self._write_temp_wav(audio)
        self.is_processing = True
        self._set_status("Transcribing…")
        self._set_talk_button_state("Processing...", "#c69026")
        threading.Thread(target=self._process_pipeline, args=(wav_path,), daemon=True).start()

    def _on_talk_button_press(self, event: tk.Event) -> None:
        self._on_space_press(event)

    def _on_talk_button_release(self, event: tk.Event) -> None:
        self._on_space_release(event)

    def _on_space_press(self, _event: tk.Event) -> None:
        if self.space_is_down:
            return
        self.space_is_down = True
        self._cancel_pending_release()
        self._start_recording()

    def _on_space_release(self, _event: tk.Event) -> None:
        if not self.space_is_down:
            return
        self.space_is_down = False
        self._schedule_release_finalize()

    def _record_callback(self, indata, _frames, _time_info, status) -> None:
        if status:
            now = time.monotonic()
            if now - self.last_input_warning_time > 2.0:
                self.last_input_warning_time = now
                self._append_log("Audio", f"Input warning: {status}")
        with self.record_lock:
            self.recorded_chunks.append(indata.copy())

    def _write_temp_wav(self, audio: np.ndarray) -> Path:
        fd, path = tempfile.mkstemp(suffix=".wav", prefix="head_voice_")
        os.close(fd)
        wav_path = Path(path)

        with wave.open(str(wav_path), "wb") as wf:
            wf.setnchannels(1)
            wf.setsampwidth(2)
            wf.setframerate(self.config.record_sample_rate)
            wf.writeframes(audio.astype(np.int16).tobytes())
        return wav_path

    def _process_pipeline(self, wav_path: Path) -> None:
        try:
            transcript = self._sarvam_stt(wav_path)
            if not transcript:
                self._set_status("No speech recognized.")
                return

            self._append_log("You", transcript)
            self._set_status("Thinking…")
            reply = self._openrouter_reply(transcript)
            self._append_log("Head", reply)

            self._set_status("Synthesizing voice…")
            wav_bytes = self._sarvam_tts(reply)

            self._set_status("Speaking…")
            self._play_with_jaw_animation(wav_bytes)
            self._set_status("Ready · hold SPACE to talk")
        except Exception as exc:
            self._set_status(f"Pipeline error: {exc}")
            self._append_log("Error", str(exc))
        finally:
            self.is_processing = False
            self._set_talk_button_state("Hold To Talk", "#4c8dff")
            if self.head and not self.is_recording:
                self.head.idle_mode()
            try:
                wav_path.unlink(missing_ok=True)
            except Exception:
                pass

    def _sarvam_stt(self, wav_path: Path) -> str:
        url = f"{SARVAM_BASE_URL}/speech-to-text"
        headers = {"api-subscription-key": self.config.sarvam_api_key}
        data = {
            "model": self.config.stt_model,
            "language_code": self.config.stt_language_code,
            "mode": self.config.stt_mode,
        }

        with wav_path.open("rb") as f:
            files = {"file": (wav_path.name, f, "audio/wav")}
            response = requests.post(url, headers=headers, data=data, files=files, timeout=120)
        if not response.ok:
            raise RuntimeError(self._format_http_error("Sarvam STT", response))
        payload = response.json()

        text = payload.get("transcript") or payload.get("text") or ""
        if not text and isinstance(payload.get("segments"), list):
            text = " ".join(seg.get("text", "") for seg in payload["segments"]).strip()
        return text.strip()

    def _openrouter_reply(self, user_text: str) -> str:
        self.chat_history.append({"role": "user", "content": user_text})
        history_tail = self.chat_history[-14:]

        headers = {
            "Authorization": f"Bearer {self.config.openrouter_api_key}",
            "Content-Type": "application/json",
        }

        payload = {
            "model": self.config.openrouter_model,
            "messages": history_tail,
            "temperature": 0.45,
            "max_tokens": 180,
        }
        response = requests.post(
            f"{OPENROUTER_BASE_URL}/chat/completions",
            json=payload,
            headers=headers,
            timeout=120,
        )
        if not response.ok:
            raise RuntimeError(self._format_http_error(f"OpenRouter ({self.config.openrouter_model})", response))

        body = response.json()
        choices = body.get("choices") or []
        if not choices:
            raise RuntimeError(f"OpenRouter ({self.config.openrouter_model}) returned no choices.")
        content = choices[0]["message"]["content"]

        if isinstance(content, list):
            text = "".join(part.get("text", "") for part in content)
        else:
            text = str(content)

        text = self._normalize_reply_text(text.strip())
        self.chat_history.append({"role": "assistant", "content": text})
        return text

    def _sarvam_tts(self, text: str, language_code: Optional[str] = None, speaker: Optional[str] = None) -> bytes:
        url = f"{SARVAM_BASE_URL}/text-to-speech"
        headers = {
            "api-subscription-key": self.config.sarvam_api_key,
            "Content-Type": "application/json",
        }

        speaker = speaker or self.config.tts_speaker
        target_language_code = language_code or self.config.tts_language_code
        if self.config.tts_model.startswith("bulbul:v3") and speaker == "anushka":
            speaker = "priya"

        payload = {
            "text": text,
            "target_language_code": target_language_code,
            "speaker": speaker,
            "model": self.config.tts_model,
            "pace": self.config.tts_pace,
            "temperature": self.config.tts_temperature,
            "output_audio_codec": "wav",
            "speech_sample_rate": 22050,
            "enable_preprocessing": True,
        }
        if not self.config.tts_model.startswith("bulbul:v3"):
            payload["loudness"] = self.config.tts_loudness
            payload["pitch"] = self.config.tts_pitch

        response = requests.post(url, headers=headers, json=payload, timeout=120)
        if not response.ok:
            raise RuntimeError(self._format_http_error("Sarvam TTS", response))
        body = response.json()

        audios = body.get("audios")
        if not audios:
            raise RuntimeError(f"Unexpected TTS response: {body}")

        return base64.b64decode(audios[0])

    def _decode_wav_bytes(self, wav_bytes: bytes) -> tuple[np.ndarray, int]:
        with wave.open(io.BytesIO(wav_bytes), "rb") as wf:
            channels = wf.getnchannels()
            sampwidth = wf.getsampwidth()
            sample_rate = wf.getframerate()
            frame_count = wf.getnframes()
            frames = wf.readframes(frame_count)

        if sampwidth == 2:
            audio = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
        elif sampwidth == 1:
            audio = (np.frombuffer(frames, dtype=np.uint8).astype(np.float32) - 128.0) / 128.0
        else:
            raise RuntimeError(f"Unsupported sample width: {sampwidth}")

        if channels > 1:
            audio = audio.reshape(-1, channels)
        return audio, sample_rate

    def _rms_to_jaw(self, rms: float, previous: int) -> int:
        normalized = max(0.0, (rms - self.config.jaw_noise_floor) * self.config.jaw_gain)
        target = int(min(1.0, normalized) * self.config.jaw_max_percent)
        smoothing = self.config.jaw_attack_smoothing if target >= previous else self.config.jaw_release_smoothing
        smooth = int(previous * smoothing + target * (1.0 - smoothing))
        if abs(smooth - previous) <= 1:
            return previous
        return max(0, min(100, smooth))

    def _play_with_jaw_animation(self, wav_bytes: bytes) -> None:
        audio, sample_rate = self._decode_wav_bytes(wav_bytes)
        mono = audio.mean(axis=1) if audio.ndim > 1 else audio
        chunk = max(1, int(sample_rate / self.config.jaw_update_hz))
        playback_audio = np.clip(audio * self.config.audio_output_gain, -1.0, 1.0).astype(np.float32)

        if self.head:
            self.head.talk_mode(True)
            self.head.jaw(0)

        try:
            sd.play(
                playback_audio,
                sample_rate,
                blocking=False,
                latency=self.config.audio_playback_latency,
                device=self.output_device_index,
            )
            start = time.monotonic()
            previous_jaw = 0
            last_sent_jaw = -1

            for i in range(0, len(mono), chunk):
                end = min(i + chunk, len(mono))
                segment = mono[i:end]
                if segment.size == 0:
                    continue

                rms = float(np.sqrt(np.mean(np.square(segment))))
                jaw = self._rms_to_jaw(rms, previous_jaw)
                previous_jaw = jaw

                if self.head and jaw != last_sent_jaw:
                    self.head.jaw(jaw)
                    last_sent_jaw = jaw

                target_time = start + (i / sample_rate) + self.config.jaw_sync_offset_seconds
                sleep_for = target_time - time.monotonic()
                if sleep_for > 0:
                    time.sleep(sleep_for)

            sd.wait()
        finally:
            sd.stop()
            if self.head:
                self.head.jaw(0)
                self.head.talk_mode(False)

    def _on_close(self) -> None:
        self._cancel_pending_release()
        if self.record_stream:
            self.record_stream.stop()
            self.record_stream.close()
            self.record_stream = None
        if self.head:
            self.head.close()
        self.root.destroy()


def main() -> None:
    parser = argparse.ArgumentParser(description="Space-hold voice UI for Arduino head control.")
    parser.add_argument("--port", help="Serial port for Arduino Mega (e.g. /dev/tty.usbmodemXXXX)")
    parser.add_argument("--no-serial", action="store_true", help="Run voice pipeline without Arduino serial.")
    args = parser.parse_args()

    config = AppConfig.from_env()

    head = None
    if not args.no_serial:
        head = HeadSerial(args.port, config.serial_baud)
        head.connect()

    root = tk.Tk()
    app = VoiceHeadApp(root, config, head)
    app._set_status("Ready · hold SPACE to talk")
    root.mainloop()


if __name__ == "__main__":
    main()
