# voice_output.py - macOS AVFoundation TTS for speaking agent replies.
# Optional: Supertonic on-device TTS when use_supertonic_tts is set (fast, high quality).
# Requires: pyobjc-framework-AVFoundation (same as voice_input).
# Usage: call speak(text) from the main thread when "Speak agent replies" is enabled.

from __future__ import annotations

import os
import re
import tempfile
import shutil
import subprocess
import json
import sys
import threading
import selectors
import atexit
import wave
from pathlib import Path
from typing import List, Optional, Tuple

_synthesizer = None
_supertonic_tts = None
_supertonic_player = None
_supertonic_wav_path = None
_chatterbox_player = None
_chatterbox_wav_path = None
_chatterbox_proc = None
_chatterbox_proc_stderr = None
_chatterbox_proc_lock = threading.Lock()
_chatterbox_keepalive_thread = None
_chatterbox_keepalive_stop = threading.Event()
_chatterbox_keepalive_interval_s = 300.0
_chatterbox_keepalive_variant = "turbo"

# Regex to remove emoji (common Unicode ranges for symbols, pictographs, emoticons, etc.)
_EMOJI_PATTERN = re.compile(
    "["
    "\u2600-\u26FF"  # Miscellaneous Symbols
    "\u2700-\u27BF"  # Dingbats
    "\uFE00-\uFE0F"  # Variation Selectors
    "\U0001F300-\U0001F9FF"  # Misc Symbols, Pictographs, Supplemental Symbols
    "\U0001F600-\U0001F64F"  # Emoticons
    "\U0001F1E0-\U0001F1FF"  # Flags
    "]+",
    re.UNICODE,
)


def _ensure_av():
    global _synthesizer
    if _synthesizer is not None:
        return True
    try:
        from AVFoundation import AVSpeechSynthesizer, AVSpeechUtterance
        synth = AVSpeechSynthesizer.alloc().init()
        _synthesizer = synth
        return True
    except Exception:
        return False


def is_available() -> bool:
    """Return True if AVSpeechSynthesizer is available."""
    return _ensure_av()


def supertonic_available() -> bool:
    """Return True if Supertonic TTS can be used (import and optional init succeed)."""
    try:
        from supertonic import TTS
        return True
    except Exception:
        return False


# Built-in Supertonic voice names (for popup and fallback).
SUPERTONIC_BUILTIN_VOICES = ("M1", "F1", "M2", "F2", "M3", "M4", "M5", "F3", "F4", "F5")

# Directory where custom voice JSON files are copied (Option A: copy and store).
SUPERTONIC_CUSTOM_VOICES_DIR = os.path.join(os.path.expanduser("~"), ".mugi", "supertonic_voices")
CHATTERBOX_VOICES_DIR = os.path.join(os.path.expanduser("~"), ".mugi", "chatterbox_voices")
CHATTERBOX_VENV_DIR = os.path.join(os.path.expanduser("~"), ".mugi", "chatterbox-venv")
CHATTERBOX_HF_HOME = os.path.join(os.path.expanduser("~"), ".mugi", "huggingface", "chatterbox")
CHATTERBOX_STDERR_LOG = os.path.join(os.path.expanduser("~"), ".mugi", "logs", "chatterbox_worker.log")


def get_supertonic_custom_voices_dir() -> str:
    """Return the path to the folder where custom Supertonic voice files are stored."""
    return SUPERTONIC_CUSTOM_VOICES_DIR


def get_chatterbox_voices_dir() -> str:
    return CHATTERBOX_VOICES_DIR


def chatterbox_installed() -> bool:
    py = os.path.join(CHATTERBOX_VENV_DIR, "bin", "python")
    worker = _resolve_chatterbox_worker_path()
    return os.path.isfile(py) and bool(worker) and os.path.isfile(worker)


def list_chatterbox_voices() -> List[Tuple[str, str]]:
    root = get_chatterbox_voices_dir()
    out: List[Tuple[str, str]] = []
    if not os.path.isdir(root):
        return out
    for name in sorted(os.listdir(root)):
        full = os.path.join(root, name)
        if not os.path.isdir(full) or name.startswith("."):
            continue
        ref = os.path.join(full, "reference.wav")
        if not os.path.isfile(ref):
            continue
        label = name
        meta = os.path.join(full, "meta.json")
        try:
            if os.path.isfile(meta):
                with open(meta, "r", encoding="utf-8") as f:
                    data = json.load(f)
                cand = str(data.get("display_name") or "").strip()
                if cand:
                    label = cand
        except Exception:
            pass
        out.append((name, label))
    return out


def _safe_chatterbox_profile_id(raw: str) -> str:
    s = re.sub(r"[^a-zA-Z0-9._-]+", "-", str(raw or "").strip())
    s = s.strip(".-")
    return s[:64] or "voice"


def get_chatterbox_voice_path(profile_id: str) -> Optional[str]:
    pid = _safe_chatterbox_profile_id(profile_id)
    root = os.path.abspath(get_chatterbox_voices_dir())
    path = os.path.abspath(os.path.join(root, pid, "reference.wav"))
    if not path.startswith(root + os.sep):
        return None
    return path if os.path.isfile(path) else None


def import_chatterbox_voice_file(
    source_path: str,
    display_name: str = "",
    max_bytes: int = 50 * 1024 * 1024,
) -> Tuple[bool, str, str]:
    src = os.path.abspath(source_path or "")
    if not src or not os.path.isfile(src):
        return False, "", "File not found"
    if os.path.getsize(src) > int(max_bytes):
        return False, "", "Audio file is too large"
    ext = os.path.splitext(src)[1].lower()
    if ext not in (".wav", ".mp3", ".m4a", ".caf", ".aiff", ".aif", ".flac", ".ogg"):
        return False, "", "Unsupported audio format"
    root = get_chatterbox_voices_dir()
    os.makedirs(root, exist_ok=True)
    base_name = display_name.strip() or os.path.splitext(os.path.basename(src))[0]
    pid = _safe_chatterbox_profile_id(base_name)
    dst_dir = os.path.join(root, pid)
    os.makedirs(dst_dir, exist_ok=True)
    dst = os.path.join(dst_dir, "reference.wav")
    if ext == ".wav":
        shutil.copy2(src, dst)
    else:
        ffmpeg = shutil.which("ffmpeg")
        if not ffmpeg:
            return False, "", "ffmpeg is required to convert non-WAV audio"
        proc = subprocess.run(
            [ffmpeg, "-y", "-i", src, "-vn", "-ac", "1", "-ar", "24000", dst],
            capture_output=True,
            text=True,
            timeout=120,
        )
        if proc.returncode != 0 or not os.path.isfile(dst):
            return False, "", (proc.stderr or proc.stdout or "Audio conversion failed").strip()
    duration_sec = 0.0
    try:
        with wave.open(dst, "rb") as wf:
            frames = float(wf.getnframes() or 0)
            rate = float(wf.getframerate() or 1)
            duration_sec = (frames / rate) if rate > 0 else 0.0
    except Exception:
        duration_sec = 0.0
    meta_path = os.path.join(dst_dir, "meta.json")
    try:
        with open(meta_path, "w", encoding="utf-8") as f:
            json.dump(
                {
                    "display_name": (display_name or base_name).strip() or pid,
                    "duration_sec": round(duration_sec, 3),
                },
                f,
                ensure_ascii=True,
            )
    except Exception:
        pass
    warning = "Reference audio is long (>60s); best cloning quality is usually 5-10s." if duration_sec > 60 else ""
    return True, pid, warning


def list_supertonic_custom_voices() -> List[Tuple[str, str]]:
    """Return list of (voice_id, display_name) for custom voices in the app folder.
    voice_id is 'custom:filename.json' for use in prefs and synthesize; display_name is the stem (e.g. 'my_voice')."""
    result: List[Tuple[str, str]] = []
    try:
        root = get_supertonic_custom_voices_dir()
        if not os.path.isdir(root):
            return result
        for name in sorted(os.listdir(root)):
            if name.endswith(".json") and not name.startswith("."):
                voice_id = "custom:" + name
                stem = name[:-5] if name.endswith(".json") else name
                result.append((voice_id, stem))
    except Exception:
        pass
    return result


def get_supertonic_custom_voice_path(voice_id: str) -> Optional[str]:
    """Return the full path for a custom voice id ('custom:filename.json'), or None if invalid/missing."""
    if not (voice_id and str(voice_id).startswith("custom:")):
        return None
    name = str(voice_id)[7:].strip()
    if not name or ".." in name or os.path.sep in name:
        return None
    path = os.path.join(get_supertonic_custom_voices_dir(), name)
    return path if os.path.isfile(path) else None


def get_english_voices() -> List[Tuple[str, str]]:
    """Return list of (identifier, display_name) for English voices only. display_name is e.g. 'Karen (en-AU)'."""
    try:
        from AVFoundation import AVSpeechSynthesisVoice
        voices = AVSpeechSynthesisVoice.speechVoices()
        out = []
        for v in voices:
            lang = v.language() or ""
            if not lang.startswith("en"):
                continue
            ident = v.identifier() or ""
            name = (v.name() or "").strip() or ident.split(".")[-1] if ident else "Unknown"
            display = f"{name} ({lang})" if lang else name
            out.append((ident, display))
        return out
    except Exception:
        return []


def text_for_speech(text: str) -> str:
    """Strip markdown and emojis so TTS receives plain text. [text](url) -> text; remove **, *, `, etc.; remove emoji."""
    if not text:
        return ""
    s = (text or "").strip()
    # Links: [label](url) -> label
    s = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", s)
    # Bare URLs (https://..., http://...) -> remove entirely; clean up trailing punctuation
    s = re.sub(r"https?://[^\s)\]>\"']+", "", s)
    s = re.sub(r"(,\s*)+,", ",", s)  # collapse repeated commas left by adjacent URL removal
    # Fenced code blocks: ```...``` -> content only (strip fence lines)
    s = re.sub(r"```[\s\S]*?```", " ", s)
    # Inline backticks: `code` -> code
    s = re.sub(r"`([^`]*)`", r"\1", s)
    # Bold/italic: **text** and *text* and __text__ and _text_
    s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s)
    s = re.sub(r"\*([^*]+)\*", r"\1", s)
    s = re.sub(r"__([^_]+)__", r"\1", s)
    s = re.sub(r"_([^_]+)_", r"\1", s)
    # Remove remaining standalone * or _ used as emphasis
    s = re.sub(r"(?<!\w)[*_](?!\w)", "", s)
    # Remove emojis
    s = _EMOJI_PATTERN.sub("", s)
    # Collapse multiple spaces/newlines
    s = re.sub(r"[ \t]+", " ", s)
    s = re.sub(r"\n{3,}", "\n\n", s)
    return s.strip()


def stop() -> None:
    """Stop any current TTS playback. Safe to call when nothing is speaking."""
    global _supertonic_player, _supertonic_wav_path
    global _chatterbox_player, _chatterbox_wav_path
    try:
        if _supertonic_player is not None:
            try:
                _supertonic_player.stop()
            except Exception:
                pass
            _supertonic_player = None
        if _supertonic_wav_path is not None:
            try:
                if os.path.isfile(_supertonic_wav_path):
                    os.unlink(_supertonic_wav_path)
            except Exception:
                pass
            _supertonic_wav_path = None
    except Exception:
        pass
    try:
        if _chatterbox_player is not None:
            try:
                _chatterbox_player.stop()
            except Exception:
                pass
            _chatterbox_player = None
        if _chatterbox_wav_path is not None:
            try:
                if os.path.isfile(_chatterbox_wav_path):
                    os.unlink(_chatterbox_wav_path)
            except Exception:
                pass
            _chatterbox_wav_path = None
    except Exception:
        pass
    if _synthesizer is None:
        return
    try:
        from AVFoundation import AVSpeechBoundaryImmediate
        _synthesizer.stopSpeakingAtBoundary_(AVSpeechBoundaryImmediate)
    except Exception:
        pass


_IMPORT_FAILED = "import_failed"
_CHATTERBOX_UNAVAILABLE = "chatterbox_unavailable"


def synthesize_supertonic_to_wav_bytes(
    text: str,
    voice_name: str = "M1",
    lang: str = "en",
    speed: float = 1.05,
    total_steps: int = 5,
) -> Optional[bytes]:
    """Synthesize text to WAV bytes using Supertonic (no playback). Returns None if Supertonic unavailable or synthesis fails."""
    global _supertonic_tts
    text = text_for_speech(text or "")
    if not text:
        return None
    try:
        from supertonic import TTS
    except Exception:
        return None
    try:
        if _supertonic_tts is None:
            _supertonic_tts = TTS(auto_download=True)
        tts = _supertonic_tts
        if voice_name.startswith("custom:"):
            custom_path = get_supertonic_custom_voice_path(voice_name)
            if custom_path and os.path.isfile(custom_path):
                style = tts.get_voice_style_from_path(Path(custom_path))
            else:
                style = tts.get_voice_style(voice_name="M1")
        else:
            style = tts.get_voice_style(voice_name=voice_name)
        synth_text = text
        for _attempt in range(2):
            try:
                wav, _duration = tts.synthesize(
                    synth_text, voice_style=style, lang=lang, speed=speed, total_steps=total_steps
                )
                break
            except ValueError as ve:
                if "unsupported character" in str(ve).lower() and _attempt == 0:
                    bad = set(re.findall(r"'(.)'", str(ve)))
                    for ch in bad:
                        synth_text = synth_text.replace(ch, "")
                    synth_text = synth_text.strip()
                    if not synth_text:
                        return None
                    continue
                return None
        fd, path = tempfile.mkstemp(suffix=".wav")
        try:
            tts.save_audio(wav, path)
            os.close(fd)
            fd = -1
            with open(path, "rb") as f:
                return f.read()
        finally:
            if fd >= 0:
                try:
                    os.close(fd)
                except Exception:
                    pass
            try:
                if os.path.isfile(path):
                    os.unlink(path)
            except Exception:
                pass
    except Exception:
        return None


def _speak_supertonic(
    text: str,
    voice_name: str,
    lang: str = "en",
    speed: float = 1.05,
    total_steps: int = 5,
) -> bool | str:
    """Speak using Supertonic TTS.

    Returns True on success, _IMPORT_FAILED when the package is missing,
    or False for any other synthesis error (caller should fall back silently).
    """
    global _supertonic_tts
    global _supertonic_player, _supertonic_wav_path
    try:
        from supertonic import TTS
        from AVFoundation import AVAudioPlayer, NSURL
    except Exception:
        return _IMPORT_FAILED
    try:
        if _supertonic_tts is None:
            _supertonic_tts = TTS(auto_download=True)
        tts = _supertonic_tts
        if voice_name.startswith("custom:"):
            custom_path = get_supertonic_custom_voice_path(voice_name)
            if custom_path and os.path.isfile(custom_path):
                style = tts.get_voice_style_from_path(Path(custom_path))
            else:
                style = tts.get_voice_style(voice_name="M1")
        else:
            style = tts.get_voice_style(voice_name=voice_name)

        synth_text = text
        for _attempt in range(2):
            try:
                wav, _duration = tts.synthesize(
                    synth_text, voice_style=style, lang=lang, speed=speed, total_steps=total_steps
                )
                break
            except ValueError as ve:
                if "unsupported character" in str(ve).lower() and _attempt == 0:
                    bad = set(re.findall(r"'(.)'", str(ve)))
                    for ch in bad:
                        synth_text = synth_text.replace(ch, "")
                    synth_text = synth_text.strip()
                    if not synth_text:
                        return False
                    continue
                raise

        fd, path = tempfile.mkstemp(suffix=".wav")
        try:
            tts.save_audio(wav, path)
            os.close(fd)
            fd = -1
            nsurl = NSURL.fileURLWithPath_(path)
            result = AVAudioPlayer.alloc().initWithContentsOfURL_error_(nsurl, None)
            if isinstance(result, tuple):
                player, err = result
                if err or player is None:
                    return False
            else:
                player = result
                if player is None:
                    return False
            player.prepareToPlay()
            if _supertonic_wav_path is not None:
                try:
                    if os.path.isfile(_supertonic_wav_path):
                        os.unlink(_supertonic_wav_path)
                except Exception:
                    pass
            _supertonic_player = player
            _supertonic_wav_path = path
            player.play()
            return True
        finally:
            if fd >= 0:
                try:
                    os.close(fd)
                except Exception:
                    pass
            if path is not None and _supertonic_wav_path != path:
                try:
                    if os.path.isfile(path):
                        os.unlink(path)
                except Exception:
                    pass
    except Exception:
        return False


def _chatterbox_worker_python() -> Optional[str]:
    py = os.path.join(CHATTERBOX_VENV_DIR, "bin", "python")
    return py if os.path.isfile(py) else None


def _resolve_chatterbox_worker_path() -> Optional[str]:
    candidates = [
        os.path.join(os.path.dirname(__file__), "scripts", "chatterbox_worker.py"),
        os.path.join(os.environ.get("MUGI_REPO_ROOT", ""), "scripts", "chatterbox_worker.py"),
        os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), "scripts", "chatterbox_worker.py"),
    ]
    for path in candidates:
        p = (path or "").strip()
        if p and os.path.isfile(p):
            return p
    return None


_CHATTERBOX_LOG_HINT = " See ~/.mugi/logs/chatterbox_worker.log for details."


def _stderr_is_noise_for_user_message(text: str | None) -> bool:
    """tqdm / deprecation spam must never become the Settings → Preview alert body."""
    s = (text or "").strip()
    if not s:
        return False
    if len(s) > 400:
        return True
    if s.count("\n") > 5 or "\r" in s:
        return True
    low = s.lower()
    if "sampling:" in low or "it/s]" in low or "site-packages" in low:
        return True
    return False


def _chatterbox_user_visible_error(primary: str | None, *, backup: str) -> str:
    msg = (primary or "").strip()
    if not msg or _stderr_is_noise_for_user_message(msg):
        return backup.rstrip(".") + "." + _CHATTERBOX_LOG_HINT
    if len(msg) > 800:
        return msg[:800] + "…"
    return msg


def _parse_chatterbox_worker_stdout(raw: str | None) -> dict | None:
    """Parse worker stdout; tolerate junk lines before the final JSON object (HF/transformers prints)."""
    text = (raw or "").strip()
    if not text:
        return None
    try:
        data = json.loads(text)
        return data if isinstance(data, dict) else None
    except Exception:
        pass
    for line in reversed(text.splitlines()):
        line = line.strip()
        if len(line) >= 2 and line.startswith("{") and line.endswith("}"):
            try:
                data = json.loads(line)
                if isinstance(data, dict):
                    return data
            except Exception:
                continue
    return None


def _run_chatterbox_worker(payload: dict, timeout_s: float = 90.0) -> Tuple[bool, str, Optional[str]]:
    def _worker_env() -> dict:
        env = os.environ.copy()
        env["PYTHONNOUSERSITE"] = "1"
        env.setdefault("HF_HOME", CHATTERBOX_HF_HOME)
        env.setdefault("TQDM_DISABLE", "1")
        env.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
        env.setdefault("TRANSFORMERS_VERBOSITY", "error")
        env.setdefault("TOKENIZERS_PARALLELISM", "false")
        env.setdefault("PYTHONWARNINGS", "ignore")
        return env

    def _close_worker_locked() -> None:
        global _chatterbox_proc, _chatterbox_proc_stderr
        proc = _chatterbox_proc
        _chatterbox_proc = None
        if proc is not None:
            try:
                proc.terminate()
            except Exception:
                pass
            try:
                proc.wait(timeout=1.0)
            except Exception:
                try:
                    proc.kill()
                except Exception:
                    pass
        if _chatterbox_proc_stderr is not None:
            try:
                _chatterbox_proc_stderr.close()
            except Exception:
                pass
            _chatterbox_proc_stderr = None

    def _start_worker_locked() -> bool:
        global _chatterbox_proc, _chatterbox_proc_stderr
        if _chatterbox_proc is not None and _chatterbox_proc.poll() is None:
            return True
        _close_worker_locked()
        py = _chatterbox_worker_python()
        worker = _resolve_chatterbox_worker_path()
        if not py or not worker:
            return False
        os.makedirs(CHATTERBOX_HF_HOME, exist_ok=True)
        try:
            os.makedirs(os.path.dirname(CHATTERBOX_STDERR_LOG), exist_ok=True)
            _chatterbox_proc_stderr = open(CHATTERBOX_STDERR_LOG, "a", encoding="utf-8")
        except Exception:
            _chatterbox_proc_stderr = None
        stderr_target = _chatterbox_proc_stderr or subprocess.DEVNULL
        cmd = [py, "-W", "ignore", worker, "--serve"]
        _chatterbox_proc = subprocess.Popen(
            cmd,
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=stderr_target,
            text=True,
            env=_worker_env(),
            bufsize=1,
        )
        return True

    def _request_once(timeout: float) -> Tuple[bool, str, Optional[dict]]:
        with _chatterbox_proc_lock:
            if not _start_worker_locked():
                return False, _CHATTERBOX_UNAVAILABLE, None
            proc = _chatterbox_proc
            if proc is None or proc.stdin is None or proc.stdout is None:
                _close_worker_locked()
                return False, "Chatterbox worker failed to start." + _CHATTERBOX_LOG_HINT, None
            try:
                proc.stdin.write(json.dumps(payload, ensure_ascii=True) + "\n")
                proc.stdin.flush()
            except Exception:
                _close_worker_locked()
                return False, "Chatterbox worker I/O error." + _CHATTERBOX_LOG_HINT, None
            selector = selectors.DefaultSelector()
            try:
                selector.register(proc.stdout, selectors.EVENT_READ)
                events = selector.select(timeout=max(1.0, float(timeout or 0)))
                if not events:
                    _close_worker_locked()
                    return False, "Chatterbox synthesis timed out." + _CHATTERBOX_LOG_HINT, None
                line = proc.stdout.readline()
            finally:
                try:
                    selector.close()
                except Exception:
                    pass
        data = _parse_chatterbox_worker_stdout(line)
        if not isinstance(data, dict):
            return False, "Chatterbox worker returned invalid output." + _CHATTERBOX_LOG_HINT, None
        return True, "", data

    ok, msg, data = _request_once(timeout_s)
    if not ok and msg not in {_CHATTERBOX_UNAVAILABLE}:
        # Single self-heal retry in case of stale/broken worker process.
        ok, msg, data = _request_once(timeout_s)
    if not ok:
        return False, msg, None
    if not isinstance(data, dict):
        return (
            False,
            "Chatterbox worker returned invalid output." + _CHATTERBOX_LOG_HINT,
            None,
        )
    if not bool(data.get("ok")):
        msg = _chatterbox_user_visible_error(
            str(data.get("error") or ""),
            backup="Chatterbox synthesis failed",
        )
        lower = msg.lower()
        if "mps" in lower and ("out of memory" in lower or "oom" in lower):
            msg += " (try reducing prompt length or setting PYTORCH_MPS_HIGH_WATERMARK_RATIO)"
        return False, msg, None
    wav_path = str(data.get("wav_path") or "").strip()
    if not wav_path or not os.path.isfile(wav_path):
        return False, "worker produced no audio", None
    return True, "", wav_path


def _request_chatterbox_worker(payload: dict, timeout_s: float = 60.0) -> Tuple[bool, str, Optional[dict]]:
    ok, msg, wav_path = _run_chatterbox_worker(payload, timeout_s=timeout_s) if not payload.get("cmd") else (True, "", None)
    if payload.get("cmd"):
        # For command payloads we need direct JSON access; request through the worker pipe directly.
        def _worker_env() -> dict:
            env = os.environ.copy()
            env["PYTHONNOUSERSITE"] = "1"
            env.setdefault("HF_HOME", CHATTERBOX_HF_HOME)
            env.setdefault("TQDM_DISABLE", "1")
            env.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
            env.setdefault("TRANSFORMERS_VERBOSITY", "error")
            env.setdefault("TOKENIZERS_PARALLELISM", "false")
            env.setdefault("PYTHONWARNINGS", "ignore")
            return env

        def _close_worker_locked() -> None:
            global _chatterbox_proc, _chatterbox_proc_stderr
            proc = _chatterbox_proc
            _chatterbox_proc = None
            if proc is not None:
                try:
                    proc.terminate()
                except Exception:
                    pass
            if _chatterbox_proc_stderr is not None:
                try:
                    _chatterbox_proc_stderr.close()
                except Exception:
                    pass
                _chatterbox_proc_stderr = None

        def _start_worker_locked() -> bool:
            global _chatterbox_proc, _chatterbox_proc_stderr
            if _chatterbox_proc is not None and _chatterbox_proc.poll() is None:
                return True
            _close_worker_locked()
            py = _chatterbox_worker_python()
            worker = _resolve_chatterbox_worker_path()
            if not py or not worker:
                return False
            os.makedirs(CHATTERBOX_HF_HOME, exist_ok=True)
            try:
                os.makedirs(os.path.dirname(CHATTERBOX_STDERR_LOG), exist_ok=True)
                _chatterbox_proc_stderr = open(CHATTERBOX_STDERR_LOG, "a", encoding="utf-8")
            except Exception:
                _chatterbox_proc_stderr = None
            stderr_target = _chatterbox_proc_stderr or subprocess.DEVNULL
            _chatterbox_proc = subprocess.Popen(
                [py, "-W", "ignore", worker, "--serve"],
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=stderr_target,
                text=True,
                env=_worker_env(),
                bufsize=1,
            )
            return True

        with _chatterbox_proc_lock:
            if not _start_worker_locked():
                return False, _CHATTERBOX_UNAVAILABLE, None
            proc = _chatterbox_proc
            if proc is None or proc.stdin is None or proc.stdout is None:
                _close_worker_locked()
                return False, "Chatterbox worker failed to start." + _CHATTERBOX_LOG_HINT, None
            try:
                proc.stdin.write(json.dumps(payload, ensure_ascii=True) + "\n")
                proc.stdin.flush()
            except Exception:
                _close_worker_locked()
                return False, "Chatterbox worker I/O error." + _CHATTERBOX_LOG_HINT, None
            selector = selectors.DefaultSelector()
            try:
                selector.register(proc.stdout, selectors.EVENT_READ)
                events = selector.select(timeout=max(1.0, float(timeout_s or 0)))
                if not events:
                    _close_worker_locked()
                    return False, "Chatterbox command timed out." + _CHATTERBOX_LOG_HINT, None
                line = proc.stdout.readline()
            finally:
                try:
                    selector.close()
                except Exception:
                    pass
        data = _parse_chatterbox_worker_stdout(line)
        if not isinstance(data, dict):
            return False, "Chatterbox worker returned invalid output." + _CHATTERBOX_LOG_HINT, None
        if not bool(data.get("ok")):
            return False, _chatterbox_user_visible_error(str(data.get("error") or ""), backup="Chatterbox command failed"), data
        return True, "", data
    if not ok:
        return False, msg, None
    return True, "", {"ok": True, "wav_path": wav_path}


def prewarm_chatterbox(variant: str = "turbo", timeout_s: float = 120.0) -> Tuple[bool, str]:
    clean_variant = str(variant or "turbo").strip().lower() or "turbo"
    ok, msg, _data = _request_chatterbox_worker({"cmd": "prewarm", "variant": clean_variant}, timeout_s=timeout_s)
    return ok, msg


def _chatterbox_keepalive_loop() -> None:
    while not _chatterbox_keepalive_stop.wait(max(15.0, _chatterbox_keepalive_interval_s)):
        try:
            prewarm_chatterbox(variant=_chatterbox_keepalive_variant, timeout_s=30.0)
        except Exception:
            pass


def start_chatterbox_keepalive(interval_s: float = 300.0, variant: str = "turbo") -> None:
    global _chatterbox_keepalive_thread, _chatterbox_keepalive_interval_s, _chatterbox_keepalive_variant
    _chatterbox_keepalive_interval_s = max(30.0, min(3600.0, float(interval_s or 300.0)))
    _chatterbox_keepalive_variant = str(variant or "turbo").strip().lower() or "turbo"
    if _chatterbox_keepalive_thread is not None and _chatterbox_keepalive_thread.is_alive():
        return
    _chatterbox_keepalive_stop.clear()
    _chatterbox_keepalive_thread = threading.Thread(
        target=_chatterbox_keepalive_loop,
        daemon=True,
        name="chatterbox-keepalive",
    )
    _chatterbox_keepalive_thread.start()


def _cleanup_chatterbox_worker() -> None:
    global _chatterbox_proc, _chatterbox_proc_stderr, _chatterbox_keepalive_thread
    _chatterbox_keepalive_stop.set()
    with _chatterbox_proc_lock:
        proc = _chatterbox_proc
        stderr_handle = _chatterbox_proc_stderr
        _chatterbox_proc = None
        _chatterbox_proc_stderr = None
        _chatterbox_keepalive_thread = None
    if proc is not None:
        try:
            proc.terminate()
        except Exception:
            pass
        try:
            proc.wait(timeout=1.0)
        except Exception:
            try:
                proc.kill()
            except Exception:
                pass
    if stderr_handle is not None:
        try:
            stderr_handle.close()
        except Exception:
            pass


atexit.register(_cleanup_chatterbox_worker)


def synthesize_chatterbox_to_wav_bytes_with_error(
    text: str,
    voice_profile_id: str,
    variant: str = "turbo",
    language_id: str = "en",
    allow_tags: bool = False,
    *,
    worker_timeout_s: float = 90.0,
) -> Tuple[Optional[bytes], str]:
    """Synthesize via Chatterbox worker. Returns (wav_bytes, error_message); error_message is empty on success."""
    plain = (text or "").strip()
    if not allow_tags:
        plain = text_for_speech(plain)
    if not plain:
        return None, "No text to synthesize after cleaning"
    ref = get_chatterbox_voice_path(voice_profile_id)
    if not ref:
        return (
            None,
            "Chatterbox voice profile file is missing. Re-import the reference clip from Audio settings.",
        )
    ok, msg, wav_path = _run_chatterbox_worker(
        {
            "text": plain,
            "audio_prompt_path": ref,
            "variant": variant,
            "language_id": language_id,
        },
        timeout_s=worker_timeout_s,
    )
    if msg == _CHATTERBOX_UNAVAILABLE:
        return (
            None,
            "Chatterbox runtime is not available. Install it from Settings → Software.",
        )
    if not ok or not wav_path:
        detail = (msg or "").strip() or "Chatterbox synthesis failed (see ~/.mugi/logs/chatterbox_worker.log)."
        return None, detail
    data: Optional[bytes] = None
    read_err = ""
    try:
        try:
            with open(wav_path, "rb") as f:
                data = f.read()
        except OSError as exc:
            read_err = str(exc)
    finally:
        try:
            if os.path.isfile(wav_path):
                os.unlink(wav_path)
        except Exception:
            pass
    if read_err:
        return None, f"Could not read synthesized audio: {read_err}"
    if not data:
        return None, "Chatterbox produced an empty WAV file."
    return data, ""


def synthesize_chatterbox_to_wav_bytes(
    text: str,
    voice_profile_id: str,
    variant: str = "turbo",
    language_id: str = "en",
    allow_tags: bool = False,
    *,
    worker_timeout_s: float = 90.0,
) -> Optional[bytes]:
    data, err = synthesize_chatterbox_to_wav_bytes_with_error(
        text,
        voice_profile_id,
        variant=variant,
        language_id=language_id,
        allow_tags=allow_tags,
        worker_timeout_s=worker_timeout_s,
    )
    return data if not err else None


def stream_supertonic_wav_chunks(
    text: str,
    voice_name: str = "M1",
    lang: str = "en",
    speed: float = 1.05,
    total_steps: int = 5,
    chunk_size: int = 4096,
):
    """Yield Supertonic WAV bytes in chunks for HTTP streaming routes."""
    wav = synthesize_supertonic_to_wav_bytes(
        text,
        voice_name=voice_name,
        lang=lang,
        speed=speed,
        total_steps=total_steps,
    )
    if not wav:
        return
    size = max(1024, int(chunk_size or 4096))
    for i in range(0, len(wav), size):
        yield wav[i : i + size]


def stream_chatterbox_wav_chunks(
    text: str,
    voice_profile_id: str,
    variant: str = "turbo",
    language_id: str = "en",
    allow_tags: bool = False,
    chunk_size: int = 4096,
):
    """Yield Chatterbox WAV bytes in chunks for HTTP streaming routes."""
    wav = synthesize_chatterbox_to_wav_bytes(
        text,
        voice_profile_id=voice_profile_id,
        variant=variant,
        language_id=language_id,
        allow_tags=allow_tags,
    )
    if not wav:
        return
    size = max(1024, int(chunk_size or 4096))
    for i in range(0, len(wav), size):
        yield wav[i : i + size]


def _speak_chatterbox(
    text: str,
    voice_profile_id: str,
    variant: str = "turbo",
    language_id: str = "en",
    allow_tags: bool = False,
) -> bool | str:
    plain = (text or "").strip()
    if not allow_tags:
        plain = text_for_speech(plain)
    if not plain:
        return False
    ref = get_chatterbox_voice_path(voice_profile_id)
    if not ref:
        return False
    ok, msg, wav_path = _run_chatterbox_worker(
        {
            "text": plain,
            "audio_prompt_path": ref,
            "variant": variant,
            "language_id": language_id,
        }
    )
    if not ok:
        return msg if msg == _CHATTERBOX_UNAVAILABLE else False
    try:
        from AVFoundation import AVAudioPlayer, NSURL

        nsurl = NSURL.fileURLWithPath_(wav_path)
        result = AVAudioPlayer.alloc().initWithContentsOfURL_error_(nsurl, None)
        if isinstance(result, tuple):
            player, err = result
            if err or player is None:
                return False
        else:
            player = result
            if player is None:
                return False
        player.prepareToPlay()
        global _chatterbox_player, _chatterbox_wav_path
        if _chatterbox_wav_path is not None:
            try:
                if os.path.isfile(_chatterbox_wav_path):
                    os.unlink(_chatterbox_wav_path)
            except Exception:
                pass
        _chatterbox_player = player
        _chatterbox_wav_path = wav_path
        player.play()
        return True
    except Exception:
        try:
            if wav_path and os.path.isfile(wav_path):
                os.unlink(wav_path)
        except Exception:
            pass
        return False


def speak(
    text: str,
    voice_identifier: str | None = None,
    use_supertonic_tts: bool = False,
    use_chatterbox_tts: bool = False,
    chatterbox_voice: str = "",
    chatterbox_variant: str = "turbo",
    chatterbox_language_id: str = "en",
    chatterbox_allow_tags: bool = False,
    supertonic_voice: str = "M1",
    supertonic_speed: float = 1.05,
    supertonic_total_steps: int = 5,
    supertonic_lang: str = "en",
) -> None | str:
    """Speak the given text. Must be called from the main thread.
    If use_supertonic_tts is True and Supertonic is available, uses that; else uses system (AVFoundation).
    voice_identifier: for system voice only (e.g. from get_english_voices()); empty/None = system default.
    supertonic_speed: 0.7 (slow) to 2.0 (fast). supertonic_total_steps: 2-15 (quality). supertonic_lang: en, ko, es, pt, fr.
    No-op if text is empty. Stops any current speech before starting.
    Returns None on success. Returns "supertonic_unavailable" if user requested Supertonic but it was not
    available (e.g. package not installed) and we fell back to Apple."""
    text = (text or "").strip()
    if not (use_chatterbox_tts and chatterbox_allow_tags):
        text = text_for_speech(text)
    if not text:
        return None
    stop()
    if use_chatterbox_tts:
        voice = (chatterbox_voice or "").strip()
        if voice:
            result = _speak_chatterbox(
                text,
                voice_profile_id=voice,
                variant=(chatterbox_variant or "turbo").strip().lower() or "turbo",
                language_id=(chatterbox_language_id or "en").strip() or "en",
                allow_tags=bool(chatterbox_allow_tags),
            )
            if result is True:
                return None
            fallback_reason = "chatterbox_unavailable" if result == _CHATTERBOX_UNAVAILABLE else None
        else:
            fallback_reason = None
    else:
        fallback_reason = None
    if use_supertonic_tts:
        voice = (supertonic_voice or "M1").strip() or "M1"
        lang = (supertonic_lang or "en").strip() or "en"
        if lang not in ("en", "ko", "es", "pt", "fr"):
            lang = "en"
        speed = float(supertonic_speed) if supertonic_speed is not None else 1.05
        speed = max(0.7, min(2.0, speed))
        steps = int(supertonic_total_steps) if supertonic_total_steps is not None else 5
        steps = max(2, min(15, steps))
        result = _speak_supertonic(text, voice, lang=lang, speed=speed, total_steps=steps)
        if result is True:
            return None
        # Only signal "unavailable" when the package is genuinely missing;
        # synthesis errors (unsupported chars, model crash, etc.) fall back silently.
        fallback_reason = "supertonic_unavailable" if result == _IMPORT_FAILED else fallback_reason
    if not _ensure_av():
        return fallback_reason
    try:
        from AVFoundation import AVSpeechUtterance, AVSpeechSynthesisVoice
        utterance = AVSpeechUtterance.speechUtteranceWithString_(text)
        if voice_identifier and voice_identifier.strip():
            voice = AVSpeechSynthesisVoice.voiceWithIdentifier_(voice_identifier.strip())
            if voice is not None:
                utterance.setVoice_(voice)
        _synthesizer.speakUtterance_(utterance)
    except Exception:
        pass
    return fallback_reason
