#!/usr/bin/env python3
"""
KAOS — Hand-to-MIDI CC for Logic Pro
Apple Silicon M3 Max | Low-latency hand tracking via MediaPipe → MIDI CC via IAC Driver

Architecture:
  Capture thread  → single-slot frame buffer (always overwrite, never queue)
  Vision thread   → reads latest frame, runs MediaPipe Tasks API, sends MIDI CC
  Flask server    → UI + SSE stats + optional MJPEG preview
"""

import atexit
import json
import math
import os
import signal
import threading
import time
import cv2
import mediapipe as mp
import mido
from mediapipe.tasks.python.vision import (
    HandLandmarker, HandLandmarkerOptions, RunningMode,
)
from mediapipe.tasks.python.core.base_options import BaseOptions
from flask import Flask, Response, render_template, request, jsonify

# ─── Constants ────────────────────────────────────────────────────────────────

MIDI_PORT_NAME   = "Vision MIDI"
CAPTURE_DEVICE   = 0
CAPTURE_WIDTH    = 640
CAPTURE_HEIGHT   = 480
CAPTURE_FPS_REQ  = 60
FLASK_PORT       = 5050

# Path to the MediaPipe hand landmarker model (downloaded alongside this file)
MODEL_PATH = os.path.join(os.path.dirname(__file__), "hand_landmarker.task")

# PID file — lets KAOS.command and Quit KAOS.command find & kill this process
PID_FILE = os.path.join(os.path.dirname(__file__), ".kaos.pid")


def _write_pid():
    with open(PID_FILE, "w") as f:
        f.write(str(os.getpid()))


def _remove_pid():
    try:
        os.remove(PID_FILE)
    except FileNotFoundError:
        pass


atexit.register(_remove_pid)


def _handle_sigterm(signum, frame):
    _remove_pid()
    raise SystemExit(0)


signal.signal(signal.SIGTERM, _handle_sigterm)

# MediaPipe Hands landmark indices
LM_WRIST      = 0
LM_INDEX_MCP  = 5
LM_MIDDLE_MCP = 9
LM_PINKY_MCP  = 17

# Hand skeleton connections for preview overlay
HAND_CONNECTIONS = [
    (0,1),(1,2),(2,3),(3,4),                # thumb
    (5,6),(6,7),(7,8),                      # index
    (9,10),(10,11),(11,12),                 # middle
    (13,14),(14,15),(15,16),                # ring
    (17,18),(18,19),(19,20),                # pinky
    (0,5),(5,9),(9,13),(13,17),(0,17),      # palm
]

# ─── Flask app ────────────────────────────────────────────────────────────────

app = Flask(__name__)

# ─── Single-slot raw frame buffer ─────────────────────────────────────────────

_frame_lock    = threading.Lock()
_raw_frame     = None   # latest BGR frame (numpy array)
_raw_frame_seq = 0      # increments on each write; vision detects staleness

# ─── Preview JPEG buffer ──────────────────────────────────────────────────────

_preview_lock = threading.Lock()
_preview_jpeg = None    # bytes or None

# ─── Shared mutable state (all guarded by _state_lock) ───────────────────────

_state_lock = threading.Lock()

_config = {
    "min_cutoff":      1.0,    # One Euro Filter: cutoff Hz at rest (lower = smoother)
    "beta":            0.4,    # One Euro Filter: speed responsiveness (higher = less lag)
    "deadzone":        1,      # minimum CC delta before sending
    "rate_limit":      60,     # max MIDI sends per second
    "cc_number":       11,     # default: Expression
    "mute":            False,
    "invert":          False,
    "curve":           True,   # apply x^1.6 shaping curve
    "preview_enabled": False,
    "hand_lock":       "any",  # "any" | "Left" | "Right" (MediaPipe labels, may be mirrored)
    # Pitch bend
    "pitch_enabled":   False,
    "pitch_center_x":  0.5,   # normalized X where pitch = 0 (set via button)
    "pitch_range":     0.25,  # fraction of frame width for full ±bend
    "pitch_deadzone":  0.04,  # centre null zone (fraction of range) — snaps to 0
    "pitch_invert":    False,
}

_calibration = {
    "min_proxy": None,
    "max_proxy": None,
}

_runtime = {
    "last_cc":       -1,    # last CC value sent (for hysteresis)
    "last_sent_t":   0.0,   # time.monotonic() of last MIDI send
    "last_proxy":    None,  # raw proxy value (for Set MIN / MAX)
    "last_pitch":    0,     # last pitchwheel value sent
    "last_pitch_t":  0.0,   # time.monotonic() of last pitch send
    "last_hand_x":   None,  # most recent filtered hand X (for Set Center)
}

_stats = {
    "capture_fps":   0.0,
    "process_fps":   0.0,
    "process_ms":    0.0,
    "cc_value":      0,
    "hand_detected": False,
    "pitch_value":   0,
    "active_hand":   "",    # "Left" | "Right" | "" — whichever hand is currently tracked
}

# ─── MIDI ─────────────────────────────────────────────────────────────────────

_midi_lock = threading.Lock()
_midi_out  = None


def open_midi_port() -> None:
    global _midi_out
    available = mido.get_output_names()
    print(f"[MIDI] Available ports: {available}")

    # Match exact name or IAC-prefixed variant (macOS prepends "IAC Driver ")
    match = next(
        (p for p in available if p == MIDI_PORT_NAME or p.endswith(MIDI_PORT_NAME)),
        None,
    )
    if match:
        _midi_out = mido.open_output(match)
        print(f"[MIDI] Opened '{match}'")
        return

    print(f"[MIDI] ⚠  '{MIDI_PORT_NAME}' not found!")
    print("[MIDI]    → Audio MIDI Setup → IAC Driver → enable → add port 'Vision MIDI'")
    print("[MIDI]    → Logic Pro: Settings → MIDI → Inputs → enable IAC Driver")
    print("[MIDI]    Attempting virtual port as fallback…")
    try:
        _midi_out = mido.open_output(MIDI_PORT_NAME, virtual=True)
        print(f"[MIDI] Virtual port '{MIDI_PORT_NAME}' opened (testing only)")
    except Exception as exc:
        print(f"[MIDI] Could not open virtual port: {exc}")
        _midi_out = None


def send_cc(cc_num: int, value: int) -> None:
    with _midi_lock:
        if _midi_out is None:
            return
        try:
            _midi_out.send(
                mido.Message("control_change", channel=0, control=cc_num, value=value)
            )
        except Exception as exc:
            print(f"[MIDI] Send error: {exc}")


def send_pitch(value: int) -> None:
    """Send a MIDI pitch bend message. value: -8192 (full down) … 0 … +8191 (full up)."""
    with _midi_lock:
        if _midi_out is None:
            return
        try:
            _midi_out.send(mido.Message("pitchwheel", channel=0, pitch=value))
        except Exception as exc:
            print(f"[MIDI] Pitch send error: {exc}")


# ─── Geometry helpers ─────────────────────────────────────────────────────────

def _dist(a, b) -> float:
    """Euclidean distance between two MediaPipe NormalizedLandmark objects."""
    return math.hypot(a.x - b.x, a.y - b.y)


def compute_proxy(landmarks) -> float:
    """
    Stable openness proxy: spread × height.
      spread = dist(index_mcp, pinky_mcp)   — lateral knuckle span
      height = dist(wrist, middle_mcp)       — palm depth
    Typical range: ~0.015 (closed/far) … ~0.09 (open/close)
    """
    spread = _dist(landmarks[LM_INDEX_MCP], landmarks[LM_PINKY_MCP])
    height = _dist(landmarks[LM_WRIST],     landmarks[LM_MIDDLE_MCP])
    return spread * height


def clamp01(v: float) -> float:
    return max(0.0, min(1.0, v))


# ─── One Euro Filter ──────────────────────────────────────────────────────────
# Adaptive low-pass filter designed for noisy motion signals.
# At rest: heavy smoothing (jitter removed). Moving fast: low lag (responsive).
# Reference: Casiez et al., CHI 2012 — "1€ Filter"

class OneEuroFilter:
    def __init__(self, min_cutoff: float = 1.0, beta: float = 0.3, d_cutoff: float = 1.0):
        self.min_cutoff = min_cutoff
        self.beta       = beta
        self.d_cutoff   = d_cutoff
        self._x         = None   # filtered value
        self._dx        = 0.0   # filtered derivative
        self._t         = None   # last timestamp

    @staticmethod
    def _alpha(cutoff: float, dt: float) -> float:
        """First-order low-pass alpha for a given cutoff frequency and timestep."""
        tau = 1.0 / (2.0 * math.pi * cutoff)
        return 1.0 / (1.0 + tau / dt)

    def apply(self, x: float, t: float) -> float:
        if self._t is None:
            self._t  = t
            self._x  = x
            return x

        dt = max(t - self._t, 1e-6)
        self._t = t

        # Smooth the derivative
        dx       = (x - self._x) / dt
        alpha_d  = self._alpha(self.d_cutoff, dt)
        self._dx = alpha_d * dx + (1.0 - alpha_d) * self._dx

        # Adaptive cutoff: higher speed → higher cutoff → less lag
        cutoff  = self.min_cutoff + self.beta * abs(self._dx)
        alpha   = self._alpha(cutoff, dt)
        self._x = alpha * x + (1.0 - alpha) * self._x
        return self._x

    def reset(self):
        self._x  = None
        self._dx = 0.0
        self._t  = None


# ─── Capture thread ───────────────────────────────────────────────────────────

def _capture_loop() -> None:
    """Reads camera frames and keeps only the latest one (single-slot overwrite)."""
    global _raw_frame, _raw_frame_seq

    cap = cv2.VideoCapture(CAPTURE_DEVICE, cv2.CAP_AVFOUNDATION)
    if not cap.isOpened():
        print("[Capture] ERROR: Cannot open camera.")
        print("[Capture]   System Settings → Privacy & Security → Camera → allow Terminal")
        return

    cap.set(cv2.CAP_PROP_FRAME_WIDTH,  CAPTURE_WIDTH)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, CAPTURE_HEIGHT)
    cap.set(cv2.CAP_PROP_FPS,          CAPTURE_FPS_REQ)
    try:
        cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
    except Exception:
        pass

    w   = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
    h   = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
    fps = cap.get(cv2.CAP_PROP_FPS)
    print(f"[Capture] {w:.0f}×{h:.0f} @ {fps:.0f} fps")

    fps_count = 0
    fps_t0    = time.monotonic()

    while True:
        ret, frame = cap.read()
        if not ret:
            time.sleep(0.005)
            continue

        with _frame_lock:
            _raw_frame     = frame   # single-slot overwrite
            _raw_frame_seq += 1

        fps_count += 1
        now = time.monotonic()
        if now - fps_t0 >= 1.0:
            with _state_lock:
                _stats["capture_fps"] = fps_count / (now - fps_t0)
            fps_count = 0
            fps_t0    = now


# ─── Vision thread ────────────────────────────────────────────────────────────

def _vision_loop() -> None:
    """
    Pulls the latest frame, runs MediaPipe HandLandmarker (Tasks API, VIDEO mode),
    maps the hand proxy to a MIDI CC, and sends it.
    Never queues frames — skips processing if no new frame arrived.
    """
    global _preview_jpeg

    if not os.path.exists(MODEL_PATH):
        print(f"[Vision] ERROR: Model not found at {MODEL_PATH}")
        print("[Vision]   Run: curl -L -o hand_landmarker.task "
              "https://storage.googleapis.com/mediapipe-models/hand_landmarker/"
              "hand_landmarker/float16/latest/hand_landmarker.task")
        return

    opts = HandLandmarkerOptions(
        base_options=BaseOptions(model_asset_path=MODEL_PATH),
        running_mode=RunningMode.VIDEO,
        num_hands=2,              # detect both so the locked hand is found even if the other is visible
        min_hand_detection_confidence=0.5,
        min_hand_presence_confidence=0.5,
        min_tracking_confidence=0.5,
    )
    landmarker = HandLandmarker.create_from_options(opts)

    oef            = OneEuroFilter(min_cutoff=1.0, beta=0.4)
    oef_pitch      = OneEuroFilter(min_cutoff=2.0, beta=0.8)
    frames_absent  = 0          # frames since target hand was last seen
    GRACE_FRAMES   = 8          # ~½ s at 15 fps — hold last values before resetting
    last_seq       = -1
    fps_count = 0
    fps_t0    = time.monotonic()

    while True:
        # ── 1. Grab latest frame (non-blocking) ───────────────────────────
        with _frame_lock:
            if _raw_frame is None or _raw_frame_seq == last_seq:
                frame    = None
                frame_id = last_seq
            else:
                frame    = _raw_frame.copy()
                frame_id = _raw_frame_seq

        if frame is None:
            time.sleep(0.002)
            continue

        last_seq = frame_id
        t0       = time.monotonic()

        # ── 2. MediaPipe Tasks API inference ──────────────────────────────
        # VIDEO mode needs a monotonically increasing timestamp in milliseconds
        timestamp_ms = int(t0 * 1000)
        rgb          = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        mp_image     = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
        result       = landmarker.detect_for_video(mp_image, timestamp_ms)

        # Pick the hand that matches the lock setting (or the first detected hand)
        with _state_lock:
            hand_lock = _config["hand_lock"]

        landmarks   = None
        active_hand = ""
        for i, hand_lms in enumerate(result.hand_landmarks):
            label = (result.handedness[i][0].category_name
                     if result.handedness else "")
            if hand_lock == "any" or label == hand_lock:
                landmarks   = hand_lms
                active_hand = label
                break

        hand_detected = landmarks is not None
        proxy         = compute_proxy(landmarks) if landmarks else None

        # ── 3. Snapshot config ────────────────────────────────────────────
        with _state_lock:
            min_cutoff     = _config["min_cutoff"]
            beta           = _config["beta"]
            deadzone       = _config["deadzone"]
            rate_hz        = _config["rate_limit"]
            cc_num         = _config["cc_number"]
            mute           = _config["mute"]
            invert         = _config["invert"]
            curve          = _config["curve"]
            prev_on        = _config["preview_enabled"]
            cal_min        = _calibration["min_proxy"]
            cal_max        = _calibration["max_proxy"]
            last_cc        = _runtime["last_cc"]
            last_sent      = _runtime["last_sent_t"]
            pitch_enabled  = _config["pitch_enabled"]
            pitch_center_x = _config["pitch_center_x"]
            pitch_range    = _config["pitch_range"]
            pitch_dz       = _config["pitch_deadzone"]
            pitch_invert   = _config["pitch_invert"]
            last_pitch     = _runtime["last_pitch"]
            last_pitch_t   = _runtime["last_pitch_t"]

        # ── 4. Proxy → CC value ───────────────────────────────────────────
        cc_to_send = None

        if proxy is not None:
            # Update One Euro Filter params if they changed
            oef.min_cutoff = min_cutoff
            oef.beta       = beta

            # Filter the raw proxy in physical space (before mapping)
            proxy_smooth = oef.apply(proxy, t0)

            # Normalize smoothed proxy to [0, 1]
            if cal_min is not None and cal_max is not None and cal_max > cal_min:
                norm = clamp01((proxy_smooth - cal_min) / (cal_max - cal_min))
            else:
                norm = clamp01(proxy_smooth * 15.0)   # rough uncalibrated scale

            if invert:
                norm = 1.0 - norm
            if curve:
                norm = norm ** 1.6

            cc_smooth = max(0, min(127, int(round(norm * 127))))

            # Hysteresis / deadzone
            if abs(cc_smooth - last_cc) >= max(1, deadzone):
                last_cc    = cc_smooth
                cc_to_send = cc_smooth
        else:
            frames_absent += 1
            if frames_absent >= GRACE_FRAMES:
                # Grace period expired — reset filters so they don't trail on return
                oef.reset()
                oef_pitch.reset()
                # Zero pitch bend so held notes don't stick bent
                if pitch_enabled and last_pitch != 0 and not mute:
                    send_pitch(0)
                    last_pitch   = 0
                    last_pitch_t = time.monotonic()

        if landmarks is not None:
            frames_absent = 0

        # ── 5a. Rate-limited CC send ──────────────────────────────────────
        now = time.monotonic()
        if cc_to_send is not None and not mute:
            if (now - last_sent) >= (1.0 / rate_hz):
                send_cc(cc_num, cc_to_send)
                last_sent = now

        # ── 5b. Pitch bend (left ↔ right hand position) ───────────────────
        pitch_to_send = None
        hand_x_smooth = None

        if pitch_enabled and landmarks is not None:
            # Use palm centre: average of wrist + middle MCP (stable, rotation-invariant)
            raw_x    = (landmarks[LM_WRIST].x + landmarks[LM_MIDDLE_MCP].x) * 0.5
            hand_x_smooth = oef_pitch.apply(raw_x, t0)

            rel = (hand_x_smooth - pitch_center_x) / max(pitch_range, 0.01)
            if pitch_invert:
                rel = -rel
            rel = max(-1.0, min(1.0, rel))

            # Centre deadzone — snaps to 0 like a real pitch wheel spring
            if abs(rel) < pitch_dz:
                rel = 0.0

            pb = int(rel * 8191)
            if pb != last_pitch and (now - last_pitch_t) >= (1.0 / rate_hz):
                pitch_to_send = pb
                last_pitch    = pb
                last_pitch_t  = now

        if pitch_to_send is not None and not mute:
            send_pitch(pitch_to_send)

        # ── 6. Write back shared state ────────────────────────────────────
        process_ms = (time.monotonic() - t0) * 1000
        with _state_lock:
            _runtime["last_cc"]      = last_cc
            _runtime["last_sent_t"]  = last_sent
            _runtime["last_proxy"]   = proxy
            _runtime["last_pitch"]   = last_pitch
            _runtime["last_pitch_t"] = last_pitch_t
            _runtime["last_hand_x"]  = hand_x_smooth
            _stats["hand_detected"]  = hand_detected
            _stats["active_hand"]    = active_hand
            _stats["process_ms"]     = process_ms
            _stats["pitch_value"]    = last_pitch
            if cc_to_send is not None:
                _stats["cc_value"] = cc_to_send

        # ── 7. Vision FPS counter ─────────────────────────────────────────
        fps_count += 1
        if now - fps_t0 >= 1.0:
            with _state_lock:
                _stats["process_fps"] = fps_count / (now - fps_t0)
            fps_count = 0
            fps_t0    = now

        # ── 8. Optional MJPEG preview (off critical path) ─────────────────
        if prev_on and landmarks is not None:
            vis = frame.copy()
            h_px, w_px = vis.shape[:2]
            # Draw connections
            for a, b in HAND_CONNECTIONS:
                la, lb = landmarks[a], landmarks[b]
                pt1 = (int(la.x * w_px), int(la.y * h_px))
                pt2 = (int(lb.x * w_px), int(lb.y * h_px))
                cv2.line(vis, pt1, pt2, (100, 100, 255), 2, cv2.LINE_AA)
            # Draw landmark dots
            for lm in landmarks:
                pt = (int(lm.x * w_px), int(lm.y * h_px))
                cv2.circle(vis, pt, 4, (255, 200, 50), -1, cv2.LINE_AA)
            _, buf = cv2.imencode(".jpg", vis, [cv2.IMWRITE_JPEG_QUALITY, 65])
            with _preview_lock:
                _preview_jpeg = bytes(buf)
        elif prev_on:
            _, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 65])
            with _preview_lock:
                _preview_jpeg = bytes(buf)
        else:
            with _preview_lock:
                _preview_jpeg = None

    landmarker.close()   # reached only on clean shutdown


# ─── Flask routes ─────────────────────────────────────────────────────────────

@app.route("/")
def index():
    return render_template("index.html")


@app.route("/events")
def events():
    """SSE stream: JSON stats blob at ~20 Hz."""
    def _gen():
        try:
            while True:
                with _state_lock:
                    payload = {
                        **_stats,
                        **_config,
                        "cal_min": _calibration["min_proxy"],
                        "cal_max": _calibration["max_proxy"],
                    }
                yield f"data: {json.dumps(payload)}\n\n"
                time.sleep(0.05)
        except GeneratorExit:
            pass

    return Response(
        _gen(),
        mimetype="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )


@app.route("/video_feed")
def video_feed():
    """MJPEG stream of the latest processed frame."""
    def _gen():
        try:
            while True:
                with _preview_lock:
                    jpeg = _preview_jpeg
                if jpeg:
                    yield (
                        b"--frame\r\nContent-Type: image/jpeg\r\n\r\n"
                        + jpeg + b"\r\n"
                    )
                time.sleep(0.033)
        except GeneratorExit:
            pass

    return Response(
        _gen(),
        mimetype="multipart/x-mixed-replace; boundary=frame",
    )


@app.route("/set_min", methods=["POST"])
def set_min():
    with _state_lock:
        p = _runtime["last_proxy"]
        if p is not None:
            _calibration["min_proxy"] = p
        cal = dict(_calibration)
    return jsonify({"ok": True, **cal})


@app.route("/set_max", methods=["POST"])
def set_max():
    with _state_lock:
        p = _runtime["last_proxy"]
        if p is not None:
            _calibration["max_proxy"] = p
        cal = dict(_calibration)
    return jsonify({"ok": True, **cal})


@app.route("/set_pitch_center", methods=["POST"])
def set_pitch_center():
    """Capture the current hand X position as the pitch-zero centre."""
    with _state_lock:
        x = _runtime["last_hand_x"]
        if x is not None:
            _config["pitch_center_x"] = x
        v = _config["pitch_center_x"]
    return jsonify({"ok": True, "pitch_center_x": v})


@app.route("/reset_calibration", methods=["POST"])
def reset_calibration():
    with _state_lock:
        _calibration["min_proxy"] = None
        _calibration["max_proxy"] = None
    return jsonify({"ok": True})


@app.route("/toggle_invert", methods=["POST"])
def toggle_invert():
    with _state_lock:
        _config["invert"] = not _config["invert"]
        v = _config["invert"]
    return jsonify({"invert": v})


@app.route("/toggle_curve", methods=["POST"])
def toggle_curve():
    with _state_lock:
        _config["curve"] = not _config["curve"]
        v = _config["curve"]
    return jsonify({"curve": v})


@app.route("/set_params", methods=["POST"])
def set_params():
    data = request.get_json(force=True, silent=True) or {}
    with _state_lock:
        if "min_cutoff" in data:
            _config["min_cutoff"] = max(0.1, min(10.0, float(data["min_cutoff"])))
        if "beta" in data:
            _config["beta"] = max(0.0, min(2.0, float(data["beta"])))
        if "deadzone" in data:
            _config["deadzone"] = max(0, min(5, int(data["deadzone"])))
        if "rate_limit" in data:
            _config["rate_limit"] = max(10, min(200, int(data["rate_limit"])))
        if "cc_number" in data:
            _config["cc_number"] = max(0, min(127, int(data["cc_number"])))
        if "mute" in data:
            _config["mute"] = bool(data["mute"])
        if "preview_enabled" in data:
            _config["preview_enabled"] = bool(data["preview_enabled"])
        if "pitch_enabled" in data:
            _config["pitch_enabled"] = bool(data["pitch_enabled"])
        if "pitch_range" in data:
            _config["pitch_range"] = max(0.05, min(0.5, float(data["pitch_range"])))
        if "pitch_deadzone" in data:
            _config["pitch_deadzone"] = max(0.0, min(0.2, float(data["pitch_deadzone"])))
        if "pitch_invert" in data:
            _config["pitch_invert"] = bool(data["pitch_invert"])
        if "hand_lock" in data and data["hand_lock"] in ("any", "Left", "Right"):
            _config["hand_lock"] = data["hand_lock"]
    return jsonify({"ok": True})


@app.route("/health")
def health():
    with _state_lock:
        return jsonify({
            "stats":       dict(_stats),
            "config":      dict(_config),
            "calibration": dict(_calibration),
        })


@app.route("/quit", methods=["POST"])
def quit_server():
    threading.Thread(
        target=lambda: (time.sleep(0.3), os.kill(os.getpid(), signal.SIGTERM)),
        daemon=True,
    ).start()
    return jsonify({"status": "stopping"})


# ─── Entry point ──────────────────────────────────────────────────────────────

if __name__ == "__main__":
    if not os.path.exists(MODEL_PATH):
        print(f"[KAOS] ERROR: {MODEL_PATH} not found — download it first:")
        print("  curl -L -o hand_landmarker.task \\")
        print("    https://storage.googleapis.com/mediapipe-models/hand_landmarker/")
        print("    hand_landmarker/float16/latest/hand_landmarker.task")
        raise SystemExit(1)

    open_midi_port()
    _write_pid()

    threading.Thread(target=_capture_loop, daemon=True, name="Capture").start()
    threading.Thread(target=_vision_loop,  daemon=True, name="Vision").start()

    print(f"[KAOS] → http://127.0.0.1:{FLASK_PORT}")
    app.run(
        host="127.0.0.1",
        port=FLASK_PORT,
        debug=False,
        threaded=True,
        use_reloader=False,
    )
