#!/usr/bin/env python3
"""
DBJ Rec - Voice-Activated Recorder & Real-time VU Meter App
Linux-compatible GUI App (480x320) built with PyGObject (GTK3/Cairo).
Includes modern glassmorphic VU meter, default 62% threshold,
latest recording file link launcher, font support for KMKDSPK,
MP3 64kbps mono conversion, incremental device recording numbering,
and Server-Sent Events (SSE) notification streaming.
"""

import sys
import os
import time
import datetime
import threading
import queue
import math
import wave
import struct
import subprocess
import hashlib
import json
import socket
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn

import sounddevice as sd
import numpy as np

import gi
import cairo
gi.require_version('Gtk', '3.0')
gi.require_version('Gdk', '3.0')
from gi.repository import Gtk, Gdk, GLib

# Constants
WINDOW_WIDTH = 480
WINDOW_HEIGHT = 320
RECORDINGS_DIR = os.path.expanduser("~/dbj_recordings")
SSE_PORT = 8899
DEFAULT_THRESHOLD_PCT = 62

def get_device_id():
    """
    Derive a deterministic device ID based on sound card hardware info or fallback serial hash.
    Format: 4-digit hex alphanumeric tag (e.g. DEV-8A3F)
    """
    try:
        cards_info = ""
        if os.path.exists("/proc/asound/cards"):
            with open("/proc/asound/cards", "r") as f:
                cards_info = f.read()
        
        card_id = ""
        if os.path.exists("/proc/asound/card0/id"):
            with open("/proc/asound/card0/id", "r") as f:
                card_id = f.read().strip()

        raw_str = f"{card_id}_{cards_info}".encode('utf-8')
        sha = hashlib.sha256(raw_str).hexdigest().upper()
        return f"DEV-{sha[:4]}"
    except Exception:
        return "DEV-1001"

# Global SSE Queue and Subscribers list
sse_subscribers = []
sse_lock = threading.Lock()

class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
    """Handle SSE connections in separate threads"""
    daemon_threads = True

class SSEHandler(BaseHTTPRequestHandler):
    def log_message(self, format, *args):
        pass  # Quiet HTTP request logs

    def do_GET(self):
        if self.path in ('/', '/sub', '/subscribe'):
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.end_headers()
            html = """<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>DBJ Rec - Live Event Notifications</title>
    <style>
        body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #f8fafc; padding: 24px; max-width: 720px; margin: 0 auto; }
        h1 { color: #38bdf8; font-size: 24px; margin-bottom: 4px; }
        p.subtitle { color: #94a3b8; font-size: 14px; margin-top: 0; margin-bottom: 24px; }
        .status { display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; background: #0284c7; color: #fff; margin-bottom: 20px; }
        .events-list { display: flex; flex-direction: column; gap: 12px; }
        .card { background: #1e293b; border-left: 4px solid #38bdf8; border-radius: 6px; padding: 14px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.3); }
        .card .time { font-size: 11px; color: #64748b; margin-bottom: 6px; }
        .card .path-link { display: inline-block; font-size: 15px; font-weight: bold; color: #38bdf8; text-decoration: underline; word-break: break-all; margin-top: 4px; }
        .card .path-link:hover { color: #7dd3fc; }
    </style>
</head>
<body>
    <h1>DBJ Rec - Live Event Feed</h1>
    <p class="subtitle">Real-time Server-Sent Events (SSE) Broadcast Stream</p>
    <div id="status" class="status">Connecting to SSE Feed...</div>
    
    <div class="events-list" id="eventsList"></div>

    <script>
        const evtSource = new EventSource('/events');
        const statusEl = document.getElementById('status');
        const listEl = document.getElementById('eventsList');

        evtSource.onopen = () => {
            statusEl.textContent = 'CONNECTED ● LIVE STREAM';
            statusEl.style.background = '#059669';
        };

        evtSource.addEventListener('connected', (e) => {
            console.log('Connected:', e.data);
        });

        evtSource.addEventListener('conversion_completed', (e) => {
            const data = JSON.parse(e.data);
            const card = document.createElement('div');
            card.className = 'card';
            
            const timeStr = new Date(data.timestamp).toLocaleTimeString();
            const filePath = data.file_path;
            const fileUri = 'file://' + filePath;
            const filename = filePath.split('/').pop();
            const finalVar = filename.includes('_') ? filename.split('_').pop() : filename;

            card.innerHTML = `
                <div class="time">${timeStr} - MP3 Conversion Completed</div>
                <div>Click to launch file in OS player:</div>
                <a class="path-link" href="${fileUri}" target="_blank">${finalVar}</a>
            `;
            listEl.prepend(card);
        });

        evtSource.onerror = () => {
            statusEl.textContent = 'DISCONNECTED - RECONNECTING...';
            statusEl.style.background = '#dc2626';
        };
    </script>
</body>
</html>"""
            self.wfile.write(html.encode('utf-8'))

        elif self.path == '/events':
            self.send_response(200)
            self.send_header('Content-Type', 'text/event-stream')
            self.send_header('Cache-Control', 'no-cache')
            self.send_header('Connection', 'keep-alive')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()

            client_q = queue.Queue()
            with sse_lock:
                sse_subscribers.append(client_q)

            init_data = json.dumps({"status": "connected", "message": "Subscribed to DBJ Rec completion notifications"})
            try:
                self.wfile.write(f"event: connected\ndata: {init_data}\n\n".encode('utf-8'))
                self.wfile.flush()
            except Exception:
                with sse_lock:
                    if client_q in sse_subscribers:
                        sse_subscribers.remove(client_q)
                return

            try:
                while True:
                    msg = client_q.get()
                    if msg is None:
                        break
                    self.wfile.write(f"event: conversion_completed\ndata: {json.dumps(msg)}\n\n".encode('utf-8'))
                    self.wfile.flush()
            except Exception:
                pass
            finally:
                with sse_lock:
                    if client_q in sse_subscribers:
                        sse_subscribers.remove(client_q)
        else:
            self.send_response(404)
            self.end_headers()

def notify_sse_subscribers(file_path):
    """Send conversion completed event to all active SSE subscribers & trigger desktop notification"""
    filename = os.path.basename(file_path)
    
    # 1. Trigger Omarchy Desktop System Notification with Clickable Playback Action (--exec)
    try:
        cmd = [
            "omarchy-notification-send",
            "--app-name", "DBJ Rec",
            "-g", "󰍬",
            "-i", "audio-recorder",
            "DBJ Rec - MP3 Conversion Completed",
            f"File Path: {file_path}",
            "--exec", "mpv", "--force-window", file_path
        ]
        subprocess.Popen(cmd)
    except Exception:
        try:
            subprocess.Popen([
                "notify-send",
                "-a", "DBJ Rec",
                "-i", "audio-recorder",
                "DBJ Rec - MP3 Conversion Completed",
                f"File Path: {file_path}"
            ])
        except Exception as e:
            print(f"Desktop notification error: {e}")

    # 2. Dispatch SSE payload to stream subscribers
    payload = {
        "event": "conversion_completed",
        "file_path": file_path,
        "filename": filename,
        "timestamp": datetime.datetime.now().isoformat()
    }
    with sse_lock:
        for q in list(sse_subscribers):
            q.put(payload)

def start_sse_server():
    server = ThreadedHTTPServer(('0.0.0.0', SSE_PORT), SSEHandler)
    server_thread = threading.Thread(target=server.serve_forever, daemon=True)
    server_thread.start()

THEMES = {
    "Yaesu (dark)": {
        "bg_color": Gdk.RGBA(0.04, 0.06, 0.11, 1),
        "bg_css": "background-color: #0a0f1d;",
        "title_color": "#00e5ff",
        "subtitle_color": "#ffb700",
        "link_color": "#00e5ff",
        "sub_link_color": "#38bdf8",
        "slider_knob": "#ffb700",
        "slider_trough": "#121d30",
        "slider_highlight": "#00e5ff",
        "switch_bg": "#121d30",
        "switch_active": "#00e5ff",
        "combo_bg": "#121d30",
        "combo_fg": "#00e5ff",
        "style_type": "sdr_scope",
        "canvas_border": (0.11, 0.18, 0.29),
        "grid_color": (0.08, 0.14, 0.24, 0.5),
        "s_mark_normal": (0.0, 0.9, 1.0),
        "s_mark_red": (1.0, 0.2, 0.3),
        "segment_normal": (0.0, 0.9, 1.0),
        "segment_mid": (0.0, 1.0, 0.4),
        "segment_high": (1.0, 0.7, 0.0),
        "segment_overload": (1.0, 0.15, 0.25),
        "idle_segment": (0.06, 0.10, 0.17, 0.7),
        "marker_color": (1.0, 0.75, 0.0),
        "peak_color": (1.0, 0.2, 0.3),
        "status_stby_color": Gdk.RGBA(0.0, 0.9, 1.0, 1),
        "status_rec_color": Gdk.RGBA(1.0, 0.2, 0.2, 1),
        "status_off_color": Gdk.RGBA(0.4, 0.45, 0.5, 1),
    },
    "Icom (light)": {
        "bg_color": Gdk.RGBA(0.82, 0.88, 0.83, 1),
        "bg_css": "background-color: #d1e0d4;",
        "title_color": "#121c16",
        "subtitle_color": "#283b2e",
        "link_color": "#0d5c2c",
        "sub_link_color": "#164e28",
        "slider_knob": "#1f2d24",
        "slider_trough": "#b8c9bc",
        "slider_highlight": "#283e30",
        "switch_bg": "#b8c9bc",
        "switch_active": "#283e30",
        "combo_bg": "#b8c9bc",
        "combo_fg": "#121c16",
        "style_type": "icom_lcd",
        "canvas_border": (0.45, 0.55, 0.48),
        "grid_color": (0.65, 0.73, 0.67, 0.4),
        "s_mark_normal": (0.10, 0.16, 0.12),
        "s_mark_red": (0.75, 0.10, 0.10),
        "segment_normal": (0.12, 0.20, 0.15),
        "segment_mid": (0.15, 0.28, 0.20),
        "segment_high": (0.65, 0.45, 0.05),
        "segment_overload": (0.75, 0.10, 0.10),
        "idle_segment": (0.72, 0.79, 0.73, 0.8),
        "marker_color": (0.10, 0.16, 0.12),
        "peak_color": (0.75, 0.10, 0.10),
        "status_stby_color": Gdk.RGBA(0.08, 0.45, 0.20, 1),
        "status_rec_color": Gdk.RGBA(0.75, 0.10, 0.10, 1),
        "status_off_color": Gdk.RGBA(0.40, 0.45, 0.42, 1),
    },
    "Kenwood (blue)": {
        "bg_color": Gdk.RGBA(0.02, 0.06, 0.14, 1),
        "bg_css": "background-image: radial-gradient(circle at 50% 35%, #1d4ed8 0%, #172e6e 45%, #09183b 75%, #030919 100%);",
        "title_color": "#ffffff",
        "subtitle_color": "#93c5fd",
        "link_color": "#ffffff",
        "sub_link_color": "#7dd3fc",
        "slider_knob": "#ffffff",
        "slider_trough": "rgba(15, 23, 42, 0.5)",
        "slider_highlight": "#60a5fa",
        "switch_bg": "rgba(15, 23, 42, 0.5)",
        "switch_active": "#38bdf8",
        "combo_bg": "rgba(15, 23, 42, 0.6)",
        "combo_fg": "#ffffff",
        "style_type": "kenwood_lcd",
        "led_center": (0.18, 0.38, 0.80),
        "led_mid": (0.08, 0.18, 0.46),
        "led_outer": (0.015, 0.05, 0.13),
        "canvas_border": (0.30, 0.48, 0.75),
        "grid_color": (0.35, 0.55, 0.85, 0.30),
        "s_mark_normal": (1.0, 1.0, 1.0),
        "s_mark_red": (1.0, 0.35, 0.45),
        "segment_normal": (0.06, 0.10, 0.18),
        "segment_mid": (0.06, 0.10, 0.18),
        "segment_high": (0.08, 0.12, 0.20),
        "segment_overload": (0.80, 0.15, 0.20),
        "idle_segment": (0.16, 0.32, 0.68, 0.25),
        "marker_color": (1.0, 1.0, 1.0),
        "peak_color": (1.0, 0.35, 0.45),
        "status_stby_color": Gdk.RGBA(0.22, 0.74, 0.97, 1),
        "status_rec_color": Gdk.RGBA(1.0, 0.30, 0.35, 1),
        "status_off_color": Gdk.RGBA(0.60, 0.70, 0.85, 1),
    },
    "Kenwood (green)": {
        "bg_color": Gdk.RGBA(0.01, 0.08, 0.03, 1),
        "bg_css": "background-image: radial-gradient(circle at 50% 35%, #128a3e 0%, #104224 45%, #06260f 75%, #010e05 100%);",
        "title_color": "#ffffff",
        "subtitle_color": "#86efac",
        "link_color": "#ffffff",
        "sub_link_color": "#a7f3d0",
        "slider_knob": "#ffffff",
        "slider_trough": "rgba(6, 78, 59, 0.5)",
        "slider_highlight": "#4ade80",
        "switch_bg": "rgba(6, 78, 59, 0.5)",
        "switch_active": "#4ade80",
        "combo_bg": "rgba(6, 78, 59, 0.6)",
        "combo_fg": "#ffffff",
        "style_type": "kenwood_lcd",
        "led_center": (0.10, 0.58, 0.25),
        "led_mid": (0.05, 0.29, 0.13),
        "led_outer": (0.008, 0.08, 0.025),
        "canvas_border": (0.24, 0.58, 0.32),
        "grid_color": (0.30, 0.65, 0.38, 0.30),
        "s_mark_normal": (1.0, 1.0, 1.0),
        "s_mark_red": (1.0, 0.40, 0.30),
        "segment_normal": (0.04, 0.12, 0.08),
        "segment_mid": (0.04, 0.12, 0.08),
        "segment_high": (0.06, 0.14, 0.10),
        "segment_overload": (0.80, 0.15, 0.20),
        "idle_segment": (0.12, 0.50, 0.20, 0.25),
        "marker_color": (1.0, 1.0, 1.0),
        "peak_color": (1.0, 0.40, 0.30),
        "status_stby_color": Gdk.RGBA(0.29, 0.87, 0.50, 1),
        "status_rec_color": Gdk.RGBA(1.0, 0.30, 0.35, 1),
        "status_off_color": Gdk.RGBA(0.60, 0.85, 0.70, 1),
    },
    "Kenwood (amber)": {
        "bg_color": Gdk.RGBA(0.10, 0.03, 0.0, 1),
        "bg_css": "background-image: radial-gradient(circle at 50% 35%, #d98209 0%, #612a0c 45%, #331403 75%, #120600 100%);",
        "title_color": "#ffffff",
        "subtitle_color": "#fcd34d",
        "link_color": "#ffffff",
        "sub_link_color": "#fde68a",
        "slider_knob": "#ffffff",
        "slider_trough": "rgba(69, 26, 3, 0.5)",
        "slider_highlight": "#fbbf24",
        "switch_bg": "rgba(69, 26, 3, 0.5)",
        "switch_active": "#fbbf24",
        "combo_bg": "rgba(69, 26, 3, 0.6)",
        "combo_fg": "#ffffff",
        "style_type": "kenwood_lcd",
        "led_center": (0.80, 0.52, 0.04),
        "led_mid": (0.42, 0.18, 0.03),
        "led_outer": (0.10, 0.03, 0.0),
        "canvas_border": (0.68, 0.45, 0.12),
        "grid_color": (0.72, 0.50, 0.16, 0.30),
        "s_mark_normal": (1.0, 1.0, 1.0),
        "s_mark_red": (1.0, 0.35, 0.30),
        "segment_normal": (0.12, 0.05, 0.02),
        "segment_mid": (0.12, 0.05, 0.02),
        "segment_high": (0.15, 0.07, 0.03),
        "segment_overload": (0.80, 0.15, 0.20),
        "idle_segment": (0.62, 0.38, 0.08, 0.25),
        "marker_color": (1.0, 1.0, 1.0),
        "peak_color": (1.0, 0.35, 0.30),
        "status_stby_color": Gdk.RGBA(0.98, 0.75, 0.14, 1),
        "status_rec_color": Gdk.RGBA(1.0, 0.30, 0.35, 1),
        "status_off_color": Gdk.RGBA(0.85, 0.70, 0.55, 1),
    }
}

class DBJRecApp(Gtk.Window):
    def __init__(self):
        super().__init__(title="DBJ Rec")
        self.set_default_size(WINDOW_WIDTH, WINDOW_HEIGHT)
        self.set_resizable(False)
        self.set_size_request(WINDOW_WIDTH, WINDOW_HEIGHT)

        self.device_id = get_device_id()

        # State Variables
        self.current_theme = "Yaesu (dark)"      # Active radio theme
        self.is_enabled = True                  # Master enable/disable toggle
        self.threshold = DEFAULT_THRESHOLD_PCT  # Default 62%
        self.current_rms_pct = 0.0
        self.smooth_rms_pct = 0.0               # Smooth interpolation for fluid VU meter animation
        self.peak_rms_pct = 0.0                 # Peak hold decay indicator
        self.latest_mp3_path = self.get_most_recent_mp3()
        self.is_recording = False
        self.rec_frames = []
        self.silence_start = None
        self.rec_start_time = None
        self.sample_rate = 16000
        self.channels = 1
        self.anim_frame = 0
        self.css_provider = None

        # Main Vertical Container
        main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        main_box.set_margin_start(16)
        main_box.set_margin_end(16)
        main_box.set_margin_top(10)
        main_box.set_margin_bottom(10)
        self.add(main_box)

        # Header Row
        header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        
        title_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
        title_lbl = Gtk.Label(label="DBJ Rec")
        title_lbl.get_style_context().add_class("title-label")
        title_lbl.set_halign(Gtk.Align.START)
        
        dev_lbl = Gtk.Label(label=f"Device ID: {self.device_id}")
        dev_lbl.get_style_context().add_class("subtitle-label")
        dev_lbl.set_halign(Gtk.Align.START)

        title_vbox.pack_start(title_lbl, False, False, 0)
        title_vbox.pack_start(dev_lbl, False, False, 0)
        header_box.pack_start(title_vbox, True, True, 0)

        # Theme Selector ComboBox, Enable Switch & Status Pill
        controls_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)

        self.theme_combo = Gtk.ComboBoxText()
        for t_name in THEMES.keys():
            self.theme_combo.append_text(t_name)
        self.theme_combo.set_active(0)
        self.theme_combo.set_valign(Gtk.Align.CENTER)
        self.theme_combo.connect("changed", self.on_theme_changed)
        
        self.enable_switch = Gtk.Switch()
        self.enable_switch.set_active(True)
        self.enable_switch.set_valign(Gtk.Align.CENTER)
        self.enable_switch.connect("notify::active", self.on_enable_toggled)
        
        self.status_lbl = Gtk.Label(label="READY")
        self.status_lbl.get_style_context().add_class("status-label")
        self.status_lbl.set_margin_start(4)
        self.status_lbl.set_margin_end(4)
        self.status_lbl.set_margin_top(4)
        self.status_lbl.set_margin_bottom(4)
        self.status_lbl.set_valign(Gtk.Align.CENTER)

        controls_box.pack_start(self.theme_combo, False, False, 0)
        controls_box.pack_start(self.enable_switch, False, False, 0)
        controls_box.pack_start(self.status_lbl, False, False, 0)
        header_box.pack_end(controls_box, False, False, 0)

        main_box.pack_start(header_box, False, False, 0)

        # Modern High-Resolution VU Meter Canvas
        self.vu_drawing_area = Gtk.DrawingArea()
        self.vu_drawing_area.set_size_request(448, 76)
        self.vu_drawing_area.connect("draw", self.on_vu_draw)
        main_box.pack_start(self.vu_drawing_area, False, False, 0)

        # Threshold Control Header
        thresh_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
        thresh_title = Gtk.Label(label="Recording Sensitivity Threshold")
        thresh_title.get_style_context().add_class("subtitle-label")
        thresh_title.set_halign(Gtk.Align.START)
        
        self.thresh_val_lbl = Gtk.Label(label=f"{self.threshold}%")
        self.thresh_val_lbl.get_style_context().add_class("title-label")
        self.thresh_val_lbl.set_halign(Gtk.Align.END)
        
        thresh_header.pack_start(thresh_title, True, True, 0)
        thresh_header.pack_end(self.thresh_val_lbl, False, False, 0)
        main_box.pack_start(thresh_header, False, False, 0)

        # Slider Control (Default set to 62%)
        self.thresh_scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 1, 100, 1)
        self.thresh_scale.set_value(self.threshold)
        self.thresh_scale.set_draw_value(False)
        self.thresh_scale.connect("value-changed", self.on_threshold_changed)
        main_box.pack_start(self.thresh_scale, False, False, 0)

        # Latest Recording Hyperlink Row
        latest_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        self.latest_link_lbl = Gtk.Label()
        self.latest_link_lbl.get_style_context().add_class("recent-link")
        self.latest_link_lbl.set_halign(Gtk.Align.START)
        self.latest_link_lbl.connect("activate-link", self.on_link_clicked)
        latest_box.pack_start(self.latest_link_lbl, True, True, 0)
        main_box.pack_start(latest_box, False, False, 0)

        # Footer / SSE Subscribe Link
        footer_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
        
        sse_url = f"http://localhost:{SSE_PORT}/"
        sse_lbl = Gtk.Label()
        sse_lbl.set_markup(f'SSE Feed: <a href="{sse_url}" title="Open Web Subscriber Feed">{sse_url}</a>')
        sse_lbl.get_style_context().add_class("sub-link")
        sse_lbl.set_halign(Gtk.Align.START)
        sse_lbl.connect("activate-link", self.on_link_clicked)

        rec_dir_lbl = Gtk.Label(label=f"Target: ~/dbj_recordings/")
        rec_dir_lbl.get_style_context().add_class("subtitle-label")
        rec_dir_lbl.set_halign(Gtk.Align.END)

        footer_box.pack_start(sse_lbl, True, True, 0)
        footer_box.pack_end(rec_dir_lbl, False, False, 0)
        main_box.pack_start(footer_box, False, False, 0)

        # Apply initial theme after all widgets exist
        self.apply_theme(self.current_theme)

        # Start Audio Ingestion Thread
        self.audio_thread = threading.Thread(target=self.audio_capture_loop, daemon=True)
        self.audio_thread.start()

        # Timer for ~60 FPS smooth VU animations & UI state refresh
        GLib.timeout_add(16, self.update_ui_tick)

    def get_most_recent_mp3(self):
        """Find the newest MP3 file in RECORDINGS_DIR"""
        if not os.path.exists(RECORDINGS_DIR):
            return None
        files = [
            os.path.join(RECORDINGS_DIR, f)
            for f in os.listdir(RECORDINGS_DIR)
            if f.endswith(".mp3")
        ]
        if not files:
            return None
        return max(files, key=os.path.getmtime)

    def update_latest_recording_link(self, file_path):
        if not hasattr(self, 'latest_link_lbl') or self.latest_link_lbl is None:
            return
        if file_path and os.path.exists(file_path):
            file_uri = f"file://{file_path}"
            filename = os.path.basename(file_path)
            # Truncate link text to display only the final variable in filename
            final_var = filename.split('_')[-1] if '_' in filename else filename
            markup = f'Latest Rec: <a href="{file_uri}" title="Click to play recording in OS">{final_var}</a>'
        else:
            markup = 'Latest Rec: <i>None captured yet</i>'
        
        GLib.idle_add(self.latest_link_lbl.set_markup, markup)

    def apply_theme(self, theme_name):
        if theme_name not in THEMES:
            theme_name = "Yaesu (dark)"
        self.current_theme = theme_name
        t = THEMES[theme_name]

        self.override_background_color(Gtk.StateFlags.NORMAL, t["bg_color"])

        bg_style = t.get("bg_css", f"background-color: {t.get('bg_hex', '#0a0f1d')};")

        css_content = f"""
        window, grid, box {{
            {bg_style}
            font-family: 'Liberation Mono', 'JetBrainsMono Nerd Font', 'Noto Sans Mono', 'DejaVu Sans Mono', monospace;
        }}
        label.title-label {{
            font-family: 'Liberation Sans', 'Ubuntu', sans-serif;
            font-size: 16px;
            font-weight: bold;
            color: {t['title_color']};
            letter-spacing: 1.5px;
        }}
        label.subtitle-label {{
            font-family: 'Liberation Mono', 'JetBrainsMono Nerd Font', 'Noto Sans Mono', monospace;
            font-size: 10px;
            color: {t['subtitle_color']};
            font-weight: bold;
            letter-spacing: 0.5px;
        }}
        label.status-label {{
            font-family: 'Liberation Mono', 'JetBrainsMono Nerd Font', 'Noto Sans Mono', monospace;
            font-size: 11px;
            font-weight: bold;
            letter-spacing: 1px;
        }}
        label.sub-link {{
            font-family: 'Liberation Mono', 'JetBrainsMono Nerd Font', 'Noto Sans Mono', monospace;
            font-size: 10px;
            color: {t['sub_link_color']};
            text-decoration: underline;
        }}
        label.recent-link {{
            font-family: 'Liberation Mono', 'JetBrainsMono Nerd Font', 'Noto Sans Mono', monospace;
            font-size: 20px;
            color: {t['link_color']};
            font-weight: bold;
            text-decoration: underline;
        }}
        scale slider {{
            min-width: 16px;
            min-height: 16px;
            border-radius: 8px;
            background-color: {t['slider_knob']};
        }}
        scale trough {{
            min-height: 8px;
            border-radius: 4px;
            background-color: {t['slider_trough']};
        }}
        scale highlight {{
            border-radius: 4px;
            background-color: {t['slider_highlight']};
        }}
        switch {{
            min-width: 48px;
            min-height: 24px;
            background-color: {t['switch_bg']};
            border-radius: 12px;
        }}
        switch:checked {{
            background-color: {t['switch_active']};
        }}
        combobox button, combobox {{
            background-color: {t['combo_bg']};
            color: {t['combo_fg']};
            border: 1px solid {t['slider_highlight']};
            border-radius: 4px;
            font-family: 'Liberation Mono', monospace;
            font-size: 10px;
            font-weight: bold;
        }}
        """
        if hasattr(self, 'css_provider') and self.css_provider:
            Gtk.StyleContext.remove_provider_for_screen(
                Gdk.Screen.get_default(),
                self.css_provider
            )
        self.css_provider = Gtk.CssProvider()
        self.css_provider.load_from_data(css_content.encode('utf-8'))
        Gtk.StyleContext.add_provider_for_screen(
            Gdk.Screen.get_default(),
            self.css_provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
        )

        if hasattr(self, 'vu_drawing_area'):
            self.vu_drawing_area.queue_draw()
        if hasattr(self, 'latest_mp3_path'):
            self.update_latest_recording_link(self.latest_mp3_path)

    def on_theme_changed(self, combo):
        selected = combo.get_active_text()
        if selected:
            self.apply_theme(selected)

    def on_enable_toggled(self, switch, gparam):
        self.is_enabled = switch.get_active()
        if not self.is_enabled and self.is_recording:
            self.is_recording = False
            self.rec_frames = []
            self.silence_start = None
        self.vu_drawing_area.queue_draw()

    def on_threshold_changed(self, scale):
        self.threshold = int(scale.get_value())
        self.thresh_val_lbl.set_text(f"{self.threshold}%")
        self.vu_drawing_area.queue_draw()

    def on_link_clicked(self, label, uri):
        try:
            if uri.startswith("file://") or uri.endswith(".mp3"):
                file_path = uri.replace("file://", "")
                # Launch GUI audio player mpv directly for instant playback
                subprocess.Popen(["mpv", "--force-window", file_path])
            else:
                subprocess.Popen(["xdg-open", uri])
        except Exception as e:
            print(f"Error launching media player for {uri}: {e}")
        return True

    def on_vu_draw(self, widget, cr):
        width = widget.get_allocated_width()
        height = widget.get_allocated_height()

        t = THEMES.get(self.current_theme, THEMES["Yaesu (dark)"])
        style_type = t.get("style_type", "sdr_scope")

        # 1. Background rendering based on display style_type
        if style_type == "kenwood_lcd":
            # Photo-realistic Cairo Radial LED Bulb Backlight Glow
            pattern = cairo.RadialGradient(
                width * 0.5, height * 0.4, 10,
                width * 0.5, height * 0.4, width * 0.65
            )
            cr1, cg1, cb1 = t["led_center"]
            cr2, cg2, cb2 = t["led_mid"]
            cr3, cg3, cb3 = t["led_outer"]
            pattern.add_color_stop_rgb(0.0, cr1, cg1, cb1)
            pattern.add_color_stop_rgb(0.45, cr2, cg2, cb2)
            pattern.add_color_stop_rgb(1.0, cr3, cg3, cb3)
            cr.set_source(pattern)
            cr.rectangle(0, 0, width, height)
            cr.fill()
        elif style_type == "icom_lcd":
            # Light Reflective LCD linear gradient
            pattern = cairo.LinearGradient(0, 0, 0, height)
            pattern.add_color_stop_rgb(0.0, 0.85, 0.90, 0.86)
            pattern.add_color_stop_rgb(1.0, 0.76, 0.83, 0.77)
            cr.set_source(pattern)
            cr.rectangle(0, 0, width, height)
            cr.fill()
        else:
            # SDR Spectrum Scope dark background
            bg_r, bg_g, bg_b = t.get("canvas_bg", (0.04, 0.06, 0.11))
            cr.set_source_rgb(bg_r, bg_g, bg_b)
            cr.rectangle(0, 0, width, height)
            cr.fill()

        # Outer radio display bezel border
        br, bg, bb = t["canvas_border"]
        cr.set_source_rgb(br, bg, bb)
        cr.set_line_width(1.5)
        cr.rectangle(1, 1, width - 2, height - 2)
        cr.stroke()

        # SDR / LCD Grid lines background
        gr, gg, gb, ga = t["grid_color"]
        cr.set_source_rgba(gr, gg, gb, ga)
        cr.set_line_width(0.8)
        for grid_x in range(40, width, 40):
            cr.move_to(grid_x, 16)
            cr.line_to(grid_x, height - 14)
            cr.stroke()
        for grid_y in range(22, height - 12, 10):
            cr.move_to(4, grid_y)
            cr.line_to(width - 4, grid_y)
            cr.stroke()

        # Draw S-Meter Scale Marks
        cr.select_font_face("Liberation Mono", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
        cr.set_font_size(8)
        s_marks = [("S1", 0.10, False), ("S3", 0.25, False), ("S5", 0.40, False),
                   ("S7", 0.55, False), ("S9", 0.70, False), ("+20", 0.82, True),
                   ("+40", 0.91, True), ("+60", 0.98, True)]
        for label, pos_ratio, is_red in s_marks:
            mx = pos_ratio * width
            if is_red:
                mr, mg, mb = t["s_mark_red"]
            else:
                mr, mg, mb = t["s_mark_normal"]
            cr.set_source_rgb(mr, mg, mb)
            cr.move_to(mx - 6, 11)
            cr.show_text(label)
            cr.move_to(mx, 12)
            cr.line_to(mx, 15)
            cr.stroke()

        # Multi-segment S-Meter bar (50 segments)
        num_segments = 50
        padding = 2
        segment_width = (width - (num_segments + 1) * padding) / num_segments
        segment_y = 16
        segment_height = height - 30

        active_segments = (self.smooth_rms_pct / 100.0) * num_segments
        peak_segment = (self.peak_rms_pct / 100.0) * num_segments
        thresh_segment = (self.threshold / 100.0) * num_segments

        for i in range(num_segments):
            x = padding + i * (segment_width + padding)
            pct = i / num_segments

            if pct < 0.55:
                sr, sg, sb = t["segment_normal"]
            elif pct < 0.75:
                sr, sg, sb = t["segment_mid"]
            elif pct < 0.88:
                sr, sg, sb = t["segment_high"]
            else:
                sr, sg, sb = t["segment_overload"]

            fill_ratio = max(0.0, min(1.0, active_segments - i))

            if fill_ratio > 0.0:
                cr.set_source_rgba(sr, sg, sb, 0.45 + 0.55 * fill_ratio)
            else:
                ir, ig, ib, ia = t["idle_segment"]
                cr.set_source_rgba(ir, ig, ib, ia)

            cr.rectangle(x, segment_y, segment_width, segment_height)
            cr.fill()

        # Peak Hold Line Marker
        if peak_segment > 0:
            peak_x = padding + peak_segment * (segment_width + padding)
            pr, pg, pb = t["peak_color"]
            cr.set_source_rgba(pr, pg, pb, 0.95)
            cr.set_line_width(2.0)
            cr.move_to(peak_x, segment_y)
            cr.line_to(peak_x, segment_y + segment_height)
            cr.stroke()

        # Squelch / Recording Threshold Marker Line
        thresh_x = padding + thresh_segment * (segment_width + padding)
        kr, kg, kb = t["marker_color"]
        cr.set_source_rgba(kr, kg, kb, 0.95)
        cr.set_line_width(2.5)
        cr.move_to(thresh_x, 12)
        cr.line_to(thresh_x, height - 12)
        cr.stroke()

        # Threshold Marker Cap Arrow
        cr.set_source_rgb(kr, kg, kb)
        cr.move_to(thresh_x - 4, 12)
        cr.line_to(thresh_x + 4, 12)
        cr.line_to(thresh_x, 16)
        cr.close_path()
        cr.fill()

        # Threshold Scale Label Text
        cr.set_source_rgb(kr, kg, kb)
        cr.select_font_face("Liberation Mono", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
        cr.set_font_size(9)
        cr.move_to(max(4, thresh_x - 26), height - 2)
        cr.show_text(f"SQL {self.threshold}%")

    def update_ui_tick(self):
        self.anim_frame += 1

        # Exponential Smoothing for fluid 60 FPS motion
        self.smooth_rms_pct += (self.current_rms_pct - self.smooth_rms_pct) * 0.25
        
        # Peak hold decay logic
        if self.current_rms_pct > self.peak_rms_pct:
            self.peak_rms_pct = self.current_rms_pct
        else:
            self.peak_rms_pct = max(0.0, self.peak_rms_pct - 1.2)

        self.vu_drawing_area.queue_draw()

        t = THEMES.get(self.current_theme, THEMES["Yaesu (dark)"])

        if not self.is_enabled:
            self.status_lbl.set_text("OFF")
            self.status_lbl.override_color(Gtk.StateFlags.NORMAL, t["status_off_color"])
        elif self.is_recording:
            pulse = "●" if (self.anim_frame % 20 < 10) else "◉"
            self.status_lbl.set_text(f"REC {pulse}")
            self.status_lbl.override_color(Gtk.StateFlags.NORMAL, t["status_rec_color"])
        else:
            self.status_lbl.set_text("STBY")
            self.status_lbl.override_color(Gtk.StateFlags.NORMAL, t["status_stby_color"])

        return True  # Keep timer active

        return True  # Keep timer active

    def audio_capture_loop(self):
        """Audio ingestion loop using sounddevice audio stream"""
        block_size = 1024
        
        def callback(indata, frames, time_info, status):
            if status:
                pass
            
            # Calculate RMS energy of current block
            audio_samples = indata[:, 0]
            rms = np.sqrt(np.mean(audio_samples**2))
            
            # Map RMS (0.0 .. 0.3) logarithmically to 0 .. 100%
            if rms < 0.0005:
                pct = 0.0
            else:
                db = 20 * math.log10(rms / 0.0005)
                pct = min(100.0, max(0.0, (db / 50.0) * 100.0))

            self.current_rms_pct = pct
            now = time.time()

            # Master Enable/Disable Control
            if not self.is_enabled:
                self.silence_start = None
                if self.is_recording:
                    self.is_recording = False
                    self.rec_frames = []
                return

            # Voice Activation Decision Logic
            if pct >= self.threshold:
                self.silence_start = None
                if not self.is_recording:
                    self.is_recording = True
                    self.rec_start_time = datetime.datetime.now()
                    self.rec_frames = [indata.copy()]
                else:
                    self.rec_frames.append(indata.copy())
            else:
                if self.is_recording:
                    self.rec_frames.append(indata.copy())
                    if self.silence_start is None:
                        self.silence_start = now
                    elif now - self.silence_start >= 3.0:
                        # 3 Seconds of silence reached -> Stop recording & trigger conversion
                        self.is_recording = False
                        captured = list(self.rec_frames)
                        start_timestamp = self.rec_start_time
                        self.rec_frames = []
                        self.silence_start = None

                        # Process conversion asynchronously in worker thread
                        threading.Thread(
                            target=self.process_recording_conversion,
                            args=(captured, start_timestamp),
                            daemon=True
                        ).start()

        try:
            with sd.InputStream(samplerate=self.sample_rate, channels=self.channels, callback=callback, blocksize=block_size):
                while True:
                    time.sleep(0.05)
        except Exception as e:
            print(f"Error in audio input stream: {e}")

    def process_recording_conversion(self, frames_list, start_time):
        """Convert captured raw PCM frames to 64kbps mono MP3 with incremental file numbering"""
        os.makedirs(RECORDINGS_DIR, exist_ok=True)

        # 1. Compute total existing recordings count for this DeviceID
        existing_files = [
            f for f in os.listdir(RECORDINGS_DIR)
            if f.startswith(f"{self.device_id}_") and f.endswith(".mp3")
        ]
        recording_number = len(existing_files) + 1

        # 2. Format Timestamp string: %MM-DD-YYYY_HHMMSS
        date_str = start_time.strftime("%m-%d-%Y_%H%M%S")

        # 3. Construct Filename: %DeviceID_%MM-DD-YYYY_HHMMSS_%Recordingnumber.mp3
        filename = f"{self.device_id}_{date_str}_{recording_number}.mp3"
        final_mp3_path = os.path.abspath(os.path.join(RECORDINGS_DIR, filename))
        temp_wav_path = os.path.abspath(os.path.join(RECORDINGS_DIR, f"temp_{int(time.time()*1000)}.wav"))

        try:
            # Concatenate raw audio numpy arrays
            audio_data = np.concatenate(frames_list, axis=0)

            # Dynamic trailing silence trim: trim silent tail only where no human speech is present
            samples_flat = audio_data.flatten()
            win_size = int(0.02 * self.sample_rate)  # 20ms audio windows
            num_windows = len(samples_flat) // win_size
            speech_thresh_pct = 20.0
            last_speech_end = len(samples_flat)

            found_speech = False
            for i in range(num_windows - 1, -1, -1):
                win = samples_flat[i * win_size : (i + 1) * win_size]
                rms = np.sqrt(np.mean(win**2))
                if rms >= 0.0005:
                    db = 20 * math.log10(rms / 0.0005)
                    pct = min(100.0, max(0.0, (db / 50.0) * 100.0))
                else:
                    pct = 0.0
                if pct >= speech_thresh_pct:
                    safety_pad = int(0.40 * self.sample_rate)  # Keep 0.40s padding after final spoken word
                    last_speech_end = min(len(samples_flat), (i + 1) * win_size + safety_pad)
                    found_speech = True
                    break

            if found_speech:
                audio_data = audio_data[:last_speech_end]

            # Write temporary WAV file (16-bit PCM Mono 16kHz)
            scaled_data = (audio_data * 32767).astype(np.int16)
            with wave.open(temp_wav_path, 'wb') as wf:
                wf.setnchannels(self.channels)
                wf.setsampwidth(2)
                wf.setframerate(self.sample_rate)
                wf.writeframes(scaled_data.tobytes())

            # Convert to mono 64kbps MP3 with noise reduction (afftdn + highpass/lowpass), loudnorm, and volume=2.0
            cmd = [
                "ffmpeg", "-loglevel", "quiet", "-y",
                "-i", temp_wav_path,
                "-filter:a", "afftdn=nr=12:nf=-25,highpass=f=120,lowpass=f=7500,loudnorm=I=-14:LRA=11:TP=-1.5,volume=2.0",
                "-codec:a", "libmp3lame",
                "-ac", "1",
                "-b:a", "64k",
                final_mp3_path
            ]
            res = subprocess.run(cmd)

            if os.path.exists(temp_wav_path):
                os.remove(temp_wav_path)

            if res.returncode == 0:
                print(f"[DBJ Rec] Successfully generated MP3: {final_mp3_path}")
                self.latest_mp3_path = final_mp3_path
                self.update_latest_recording_link(final_mp3_path)
                # Dispatch SSE Event Notification to all subscribers
                notify_sse_subscribers(final_mp3_path)

        except Exception as e:
            print(f"Error converting recording: {e}")
            if os.path.exists(temp_wav_path):
                os.remove(temp_wav_path)

def main():
    # Single instance lock check
    try:
        lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        lock_sock.bind(('127.0.0.1', 8898))
    except Exception:
        print("[DBJ Rec] Another instance is already running.")
        sys.exit(0)

    # Start SSE Notification Web Server
    start_sse_server()
    print(f"[DBJ Rec] SSE Notification Stream active at http://localhost:{SSE_PORT}/events")

    # Launch GTK App Window
    win = DBJRecApp()
    win.connect("destroy", Gtk.main_quit)
    win.show_all()
    Gtk.main()

if __name__ == "__main__":
    main()
