Skill: kurtivy/openclaw-voice-assistant (version 1.0.4)

--- name: voice-assistant description: > Windows voice companion for OpenClaw. Custom wake word via Porcupine, local STT via faster-whisper, streamed responses over the gateway WebSocket, and ElevenLabs TTS with natural chime/thinking sounds. Supports multi-turn conversation with automatic follow-up listening, mic suppression to prevent feedback, and a system tray with pause/resume. Recommended voices: Matilda (XrExE9yKIg1WjnnlVkGX, free tier) or Ivy (MClEFoImJXBTgLwdLI5n, paid tier). Fully customizable wake word, voice, hotkey, and silence thresholds. metadata: { "openclaw": { "emoji": "🎙️", "os": ["win32"], "requires": { "bins": ["python"], "env": [ "GATEWAY_TOKEN", "GATEWAY_URL", "ELEVENLABS_API_KEY", "PORCUPINE_ACCESS_KEY", ], }, "primaryEnv": "ELEVENLABS_API_KEY", }, } --- # Voice Assistant for OpenClaw A Python companion app that gives OpenClaw a voice. Say a wake word (or press a hotkey), speak naturally, and hear the AI respond — then keep talking for multi-turn conversation. ``` Mic → Porcupine wake word → faster-whisper STT → OpenClaw Gateway → ElevenLabs TTS → Speaker ``` ## Quick Start ```bash # 1. Navigate to the skill scripts cd {baseDir}/scripts # 2. Create a virtual environment and install dependencies python -m venv venv venv\Scripts\pip install -r requirements.txt # 3. Copy .env.example to .env and fill in your keys copy .env.example .env # 4. Run the assistant venv\Scripts\python src\assistant.py ``` ## Requirements | Service | What you need | Cost | |---------|--------------|------| | **OpenClaw gateway** | Running locally on `ws://127.0.0.1:18789` with a gateway token | — | | **ElevenLabs** | API key + voice ID (free tier works with default voices) | Free+ | | **Picovoice** | Access key from [picovoice.ai](https://picovoice.ai) (free tier works) | Free | | **Python** | 3.10+ (tested on 3.14) | — | | **Microphone** | Any input device | — | ## Configuration (.env) ```ini # OpenClaw Gateway GATEWAY_URL=ws://127.0.0.1:18789 GATEWAY_TOKEN=your-gateway-token # ElevenLabs TTS [REDACTED_SECRET] ELEVENLABS_VOICE_ID=XrExE9yKIg1WjnnlVkGX # Matilda (free tier) — or MClEFoImJXBTgLwdLI5n for Ivy (paid) ELEVENLABS_MODEL_ID=eleven_v3 # Porcupine Wake Word PORCUPINE_ACCESS_KEY=your-access-key PORCUPINE_MODEL_PATH= # path to custom .ppn file (optional) # Whisper STT WHISPER_MODEL=base # tiny, base, small, medium, large # Tuning WAKE_SENSITIVITY=0.7 # 0.0–1.0 (higher = more sensitive) SILENCE_TIMEOUT=1.5 # seconds of silence to stop recording HOTKEY=ctrl+shift+k # global keyboard shortcut ``` ## Custom Wake Word 1. Go to [Picovoice Console](https://console.picovoice.ai/) 2. Create a custom wake word (e.g. "Hey Claudia", "Hey OpenClaw") 3. Download the `.ppn` file for your platform 4. Set `PORCUPINE_MODEL_PATH` in `.env` to the file path 5. Without a custom model, falls back to built-in "hey google" ## Personalized Voice Sounds The assistant plays short audio clips when activated ("Yep!", "Hi!") and while thinking ("Hmm...", "Let me think..."). Generate these in your chosen ElevenLabs voice: ```bash cd {baseDir}/scripts venv\Scripts\python generate_chime_sounds.py venv\Scripts\python generate_thinking_sounds.py ``` Re-run these after changing `ELEVENLABS_VOICE_ID`. ## Running in Background Use `start.bat` to launch without a console window (runs via `pythonw.exe`). The assistant appears as a system tray icon with Pause/Resume/Quit controls. For auto-start on Windows, create a shortcut to `start.bat` in `shell:startup`. ## How It Works 1. **Wake** — Porcupine detects the wake word (or user presses hotkey) 2. **Chime** — Plays a random activation sound ("Yep!", "Hi!") 3. **Record** — Records speech until 1.5s of silence (2s grace period for initial silence) 4. **Thinking** — Plays a filler sound ("Hmm...", "Let me think...") 5. **Transcribe** — faster-whisper converts audio to text locally (CPU, int8) 6. **Gateway** — Sends text to OpenClaw gateway via WebSocket, streams response 7. **Speak** — ElevenLabs converts response to speech, plays through speakers 8. **Follow-up** — Automatically listens for 5s after speaking for conversation continuity 9. **Idle** — Returns to wake word listening after 5s of silence Mic suppression keeps the microphone muted during all speaker output to prevent feedback loops. ## Detailed Architecture See [references/architecture.md](references/architecture.md) for source file breakdown, WebSocket protocol details, and audio pipeline internals. ## Troubleshooting See [references/troubleshooting.md](references/troubleshooting.md) for common issues with mic detection, gateway connection, TTS errors, and wake word tuning.

path: references/architecture.md
content: # Voice Assistant Architecture

## Source Files

| File | Lines | Purpose |
|------|-------|---------|
| `src/assistant.py` | ~300 | Main orchestration loop (wake → record → transcribe → gateway → speak → follow-up) |
| `src/audio_pipeline.py` | ~325 | Mic stream, Porcupine wake word, silence detection, Whisper STT, mic suppression |
| `src/gateway_client.py` | ~190 | Async WebSocket client for OpenClaw gateway with queue-based message routing |
| `src/tts_player.py` | ~140 | ElevenLabs TTS with MP3→WAV decode via PyAV, blocking playback via winsound |
| `src/config.py` | ~40 | .env loader with defaults |

## Data Flow

```
          ┌──────────────┐
          │  Microphone   │
          └──────┬───────┘
                 │ int16 PCM @ 16kHz
                 ▼
     ┌───────────────────────┐
     │   AudioPipeline       │
     │  ┌─────────────────┐  │
     │  │ Porcupine       │  │◄── wake word detection (idle mode)
     │  └────────┬────────┘  │
     │           │ wake!     │
     │  ┌────────▼────────┐  │
     │  │ Recording +     │  │◄── silence detection (RMS < 300 for 1.5s)
     │  │ Silence Detect  │  │    2s grace period ignores initial silence
     │  └────────┬────────┘  │
     │           │ audio     │
     │  ┌────────▼────────┐  │
     │  │ faster-whisper   │  │◄── CPU, int8 quantization
     │  │ (STT)           │  │
     │  └────────┬────────┘  │
     └───────────│───────────┘
                 │ text
                 ▼
     ┌───────────────────────┐
     │   GatewayClient       │
     │   WebSocket to        │──► ws://127.0.0.1:18789
     │   OpenClaw Gateway    │
     └───────────┬───────────┘
                 │ streamed response
                 ▼
     ┌───────────────────────┐
     │   tts_player          │
     │   ElevenLabs API      │──► mp3_44100_128
     │   PyAV MP3→WAV        │
     │   winsound playback   │──► Speaker
     └───────────┬───────────┘
                 │ done
                 ▼
          Follow-up listen
          (5s window)
```

## WebSocket Protocol

The gateway client uses OpenClaw's WebSocket protocol v3:

**Handshake:**
1. Gateway sends `connect.challenge` with nonce
2. Client sends `connect` request with auth token, client ID `node-host`, mode `node`
3. Gateway responds with `hello-ok`

**Chat messages:**
- Client sends `chat.send` request with `sessionKey: "main"` and text
- Gateway ACKs the request
- Gateway streams `chat` events with states: `delta` (partial), `final` (complete), `error`, `aborted`
- Response text is in `payload.message.content[0].text`

**Concurrency model:**
- WebSockets library only allows one concurrent `recv()` per connection
- Solution: single background task `_handle_events()` reads all frames
- Per-request UUID queues route responses back to callers

## Mic Suppression

Every sound playback follows:
```
suppress_mic() → play audio (blocking) → sleep(0.15s) → unsuppress_mic()
```

This prevents:
- Wake word false triggers from speaker output
- Whisper transcribing the AI's own response as user input
- Feedback loops between mic and speaker

The 0.15s delay accounts for speaker echo decay time.

## Conversation Mode

After TTS finishes, the assistant enters follow-up mode:
1. 0.15s echo decay
2. 0.8s breathing room (POST_SPEECH_PAUSE)
3. Plays chime, starts recording
4. Waits up to 5s for speech (FOLLOW_UP_WINDOW)
5. If speech detected → transcribe → send → speak → loop
6. If 5s silence → conversation ends, returns to idle wake word detection

## Platform Notes

- **Windows only**: Uses `winsound` for audio playback (avoids conflicts with active `sounddevice.InputStream`)
- **sounddevice over PyAudio**: PyAudio doesn't build on Python 3.14+ Windows
- **PyAV for MP3 decode**: ElevenLabs Starter tier only supports MP3 (PCM needs Pro at $22/mo)
- **System tray**: `pystray` + `Pillow` for background operation with Pause/Resume/Quit
- **Global hotkey**: `pynput.keyboard.GlobalHotKeys` as wake word alternative

## ElevenLabs Tier Requirements

| Tier | Cost | Supports |
|------|------|----------|
| Free | $0 | Default voices only (e.g. Matilda). 10k chars/mo |
| Starter | $5/mo | + library voices (e.g. Ivy), 30k chars/mo |
| Pro | $22/mo | + PCM format (lower latency), 100k chars/mo |

Default voice: Matilda (`XrExE9yKIg1WjnnlVkGX`, free tier).
Recommended upgrade: Ivy (`MClEFoImJXBTgLwdLI5n`, Starter tier).


path: references/troubleshooting.md
content: # Voice Assistant Troubleshooting

## Gateway Connection

**"Failed to connect to gateway"**
- Ensure OpenClaw gateway is running on the configured `GATEWAY_URL` (default: `ws://127.0.0.1:18789`)
- Check that `GATEWAY_TOKEN` in `.env` matches your gateway's auth token
- Verify the port isn't blocked by firewall

**"Gateway auth failed"**
- Token mismatch: double-check `GATEWAY_TOKEN` value
- The client must use ID `node-host` with mode `node` — other IDs are rejected

## Microphone

**No audio / wake word never triggers**
- Check that your mic is set as the default input device in Windows Sound settings
- Test the mic in another app first
- Try increasing `WAKE_SENSITIVITY` (e.g. `0.9`) — range is 0.0 to 1.0
- Ensure no other app is exclusively holding the mic

**Wake word triggers too easily (false positives)**
- Lower `WAKE_SENSITIVITY` (e.g. `0.5`)
- Use a more distinctive custom wake word via Picovoice Console

**Recording stops too early / too late**
- Adjust `SILENCE_TIMEOUT` — lower values (e.g. `1.0`) cut faster, higher values (e.g. `2.5`) wait longer
- The 2-second grace period at the start is hardcoded to prevent premature cutoffs

## Speech-to-Text

**Whisper transcribes silence as random words**
- This is a known Whisper behavior — the RMS threshold (300) and 2s grace period mitigate it
- If it persists, try the `small` model (`WHISPER_MODEL=small`) which is more accurate but slower

**First transcription is slow**
- Whisper downloads the model on first use (~150MB for `base`)
- Subsequent runs use the cached model

**Model options** (speed vs accuracy tradeoff):
| Model | Size | Speed | Accuracy |
|-------|------|-------|----------|
| tiny | ~75MB | Fastest | Lower |
| base | ~150MB | Fast | Good (default) |
| small | ~500MB | Medium | Better |
| medium | ~1.5GB | Slow | High |
| large | ~3GB | Slowest | Highest |

## Text-to-Speech

**"402 Payment Required" from ElevenLabs**
- Free tier works with default voices (e.g. Matilda: `XrExE9yKIg1WjnnlVkGX`)
- Library/community voices (e.g. Ivy: `MClEFoImJXBTgLwdLI5n`) require Starter tier ($5/mo)
- If you get a 402, switch to a default voice or upgrade your plan

**No audio plays / TTS error**
- Verify `ELEVENLABS_API_KEY` is valid
- Check `ELEVENLABS_VOICE_ID` exists in your ElevenLabs account
- Ensure `av` (PyAV) is installed: `pip install av`

**Choppy or garbled audio**
- The MP3→WAV decode handles multi-channel and float audio automatically
- If issues persist, try a different `ELEVENLABS_MODEL_ID`

## Wake Word (Porcupine)

**"PORCUPINE_ACCESS_KEY not set"**
- Sign up free at https://picovoice.ai
- Copy your access key to `.env`

**Custom wake word not recognized**
- Ensure `PORCUPINE_MODEL_PATH` points to a valid `.ppn` file
- The `.ppn` file must match your platform (download the Windows version)
- Without a custom model, falls back to built-in "hey google"

**"Access key expired"**
- Free Picovoice keys expire periodically — generate a new one at picovoice.ai

## System Tray

**No tray icon appears**
- Ensure `pystray` and `Pillow` are installed
- The assistant falls back to console mode if pystray isn't available
- Run `generate_assets.py` to create the tray icon if `assets/icon.png` is missing

## General

**Python version issues**
- Requires Python 3.10+ (uses `X | Y` union syntax)
- Tested on Python 3.14 — PyAudio doesn't build on 3.14, which is why we use `sounddevice`

**Hotkey doesn't work**
- Some hotkey combos conflict with Windows shortcuts
- Try a different combo in `.env` (e.g. `HOTKEY=ctrl+alt+v`)
- Hotkey requires `pynput` to be installed

**Speaker feedback / echo**
- Mic suppression should prevent this automatically
- If you still hear feedback, try lowering your speaker volume or using headphones
- The 0.15s post-playback buffer accounts for echo decay


path: scripts/generate_assets.py
content: """One-time script to generate the tray icon and fallback activation chime."""

import struct
import wave
import math
from pathlib import Path

ASSETS_DIR = Path(__file__).parent / "assets"
ASSETS_DIR.mkdir(exist_ok=True)


def generate_chime():
    """Generate a pleasant two-tone activation chime (WAV)."""
    sample_rate = 44100
    duration = 0.25  # seconds per tone
    volume = 0.4

    samples = []

    # Tone 1: C5 (523 Hz)
    for i in range(int(sample_rate * duration)):
        t = i / sample_rate
        env = min(t * 20, 1.0) * max(0, 1.0 - (t / duration) * 0.5)
        val = volume * env * math.sin(2 * math.pi * 523 * t)
        samples.append(int(val * 32767))

    # Tone 2: E5 (659 Hz)
    for i in range(int(sample_rate * duration)):
        t = i / sample_rate
        env = min(t * 20, 1.0) * max(0, 1.0 - (t / duration))
        val = volume * env * math.sin(2 * math.pi * 659 * t)
        samples.append(int(val * 32767))

    chime_path = ASSETS_DIR / "chime.wav"
    with wave.open(str(chime_path), "w") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(sample_rate)
        wf.writeframes(struct.pack(f"<{len(samples)}h", *samples))

    print(f"Created {chime_path}")


def generate_icon():
    """Generate a simple 64x64 PNG tray icon."""
    try:
        from PIL import Image, ImageDraw

        size = 64
        img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
        draw = ImageDraw.Draw(img)

        # Dark circle background
        draw.ellipse([4, 4, 60, 60], fill=(30, 30, 40, 255))

        # Microphone shape (simplified)
        draw.rounded_rectangle([24, 14, 40, 34], radius=6, fill=(0, 200, 130, 255))
        draw.line([32, 34, 32, 44], fill=(0, 200, 130, 255), width=3)
        draw.arc([22, 30, 42, 48], start=0, end=180, fill=(0, 200, 130, 255), width=2)
        draw.line([26, 48, 38, 48], fill=(0, 200, 130, 255), width=2)

        icon_path = ASSETS_DIR / "icon.png"
        img.save(str(icon_path))
        print(f"Created {icon_path}")

    except ImportError:
        import zlib

        def create_minimal_png(color=(0, 180, 120)):
            width, height = 16, 16
            raw_data = b""
            for _ in range(height):
                raw_data += b"\x00"
                for _ in range(width):
                    raw_data += bytes(color) + b"\xff"

            compressed = zlib.compress(raw_data)

            png = b"\x89PNG\r\n\x1a\n"
            ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
            ihdr_crc = zlib.crc32(b"IHDR" + ihdr_data) & 0xFFFFFFFF
            png += struct.pack(">I", 13) + b"IHDR" + ihdr_data + struct.pack(">I", ihdr_crc)
            idat_crc = zlib.crc32(b"IDAT" + compressed) & 0xFFFFFFFF
            png += struct.pack(">I", len(compressed)) + b"IDAT" + compressed + struct.pack(">I", idat_crc)
            iend_crc = zlib.crc32(b"IEND") & 0xFFFFFFFF
            png += struct.pack(">I", 0) + b"IEND" + struct.pack(">I", iend_crc)
            return png

        icon_path = ASSETS_DIR / "icon.png"
        icon_path.write_bytes(create_minimal_png())
        print(f"Created {icon_path} (minimal placeholder — install Pillow for a better icon)")


if __name__ == "__main__":
    generate_chime()
    generate_icon()
    print("Done! Assets generated in assets/")


path: scripts/generate_chime_sounds.py
content: """Generate natural wake word response sounds using ElevenLabs TTS.

Creates short audio clips (yep!, yes?, hi!, etc.) in the configured voice
and saves them as WAV files in assets/chime_sounds/.
Run this after changing ELEVENLABS_VOICE_ID in .env.
"""

import io
import os
import sys
import wave
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))

PHRASES = [
    "Yep!",
    "Yes?",
    "Hi!",
    "Hm?",
    "Yeah?",
    "Mhm?",
    "Hey!",
    "What's up?",
]


def _mp3_to_wav(mp3_bytes: bytes, out_path: Path) -> Path | None:
    import av
    container = av.open(io.BytesIO(mp3_bytes), format="mp3")
    stream = container.streams.audio[0]
    samples, sr = [], None
    for frame in container.decode(stream):
        if sr is None:
            sr = frame.sample_rate
        arr = frame.to_ndarray()
        if arr.ndim > 1:
            arr = arr.mean(axis=0)
        samples.append(arr.flatten())
    container.close()
    if not samples or sr is None:
        return None
    audio = np.concatenate(samples)
    if audio.dtype in (np.float32, np.float64):
        audio = (np.clip(audio, -1.0, 1.0) * 32767).astype(np.int16)
    with wave.open(str(out_path), "w") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(sr)
        wf.writeframes(audio.tobytes())
    return out_path


def generate():
    from dotenv import load_dotenv
    load_dotenv(Path(__file__).resolve().parent / ".env")

    [REDACTED_SECRET]"ELEVENLABS_API_KEY")
    voice_id = os.getenv("ELEVENLABS_VOICE_ID")
    model_id = os.getenv("ELEVENLABS_MODEL_ID", "eleven_v3")

    from elevenlabs.client import ElevenLabs
    client = ElevenLabs([REDACTED_SECRET])

    out_dir = Path(__file__).resolve().parent / "assets" / "chime_sounds"
    out_dir.mkdir(parents=True, exist_ok=True)

    for i, phrase in enumerate(PHRASES):
        print(f"  [{i+1}/{len(PHRASES)}] {phrase!r}")
        try:
            stream = client.text_to_speech.convert(
                text=phrase, voice_id=voice_id, model_id=model_id,
                output_format="mp3_44100_128",
            )
            path = _mp3_to_wav(b"".join(stream), out_dir / f"chime_{i:02d}.wav")
            print(f"    -> {path or 'FAILED'}")
        except Exception as e:
            print(f"    -> ERROR: {e}")

    print(f"\nDone! {len(list(out_dir.glob('*.wav')))} chime sounds")


if __name__ == "__main__":
    generate()


path: scripts/generate_thinking_sounds.py
content: """Generate natural thinking/filler sounds using ElevenLabs TTS.

Creates short audio clips (um, hmm, let me see, etc.) in the configured voice
and saves them as WAV files in assets/thinking_sounds/.
Run this after changing ELEVENLABS_VOICE_ID in .env.
"""

import io
import os
import sys
import wave
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))

PHRASES = [
    "Hmm...",
    "Um...",
    "Let me think...",
    "Hmm, let me see...",
    "One moment...",
    "Ah...",
    "Mmm...",
    "Let me check...",
]


def _mp3_to_wav(mp3_bytes: bytes, out_path: Path) -> Path | None:
    import av
    container = av.open(io.BytesIO(mp3_bytes), format="mp3")
    stream = container.streams.audio[0]
    samples, sr = [], None
    for frame in container.decode(stream):
        if sr is None:
            sr = frame.sample_rate
        arr = frame.to_ndarray()
        if arr.ndim > 1:
            arr = arr.mean(axis=0)
        samples.append(arr.flatten())
    container.close()
    if not samples or sr is None:
        return None
    audio = np.concatenate(samples)
    if audio.dtype in (np.float32, np.float64):
        audio = (np.clip(audio, -1.0, 1.0) * 32767).astype(np.int16)
    with wave.open(str(out_path), "w") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(sr)
        wf.writeframes(audio.tobytes())
    return out_path


def generate():
    from dotenv import load_dotenv
    load_dotenv(Path(__file__).resolve().parent / ".env")

    [REDACTED_SECRET]"ELEVENLABS_API_KEY")
    voice_id = os.getenv("ELEVENLABS_VOICE_ID")
    model_id = os.getenv("ELEVENLABS_MODEL_ID", "eleven_v3")

    from elevenlabs.client import ElevenLabs
    client = ElevenLabs([REDACTED_SECRET])

    out_dir = Path(__file__).resolve().parent / "assets" / "thinking_sounds"
    out_dir.mkdir(parents=True, exist_ok=True)

    for i, phrase in enumerate(PHRASES):
        print(f"  [{i+1}/{len(PHRASES)}] {phrase!r}")
        try:
            stream = client.text_to_speech.convert(
                text=phrase, voice_id=voice_id, model_id=model_id,
                output_format="mp3_44100_128",
            )
            path = _mp3_to_wav(b"".join(stream), out_dir / f"thinking_{i:02d}.wav")
            print(f"    -> {path or 'FAILED'}")
        except Exception as e:
            print(f"    -> ERROR: {e}")

    print(f"\nDone! {len(list(out_dir.glob('*.wav')))} thinking sounds")


if __name__ == "__main__":
    generate()


path: scripts/requirements.txt
content: pvporcupine>=3.0
faster-whisper>=1.0
elevenlabs>=2.0
av
sounddevice
numpy
websockets>=12.0
pystray>=0.19
Pillow
pynput>=1.7
python-dotenv>=1.0


path: scripts/src/assistant.py
content: """OpenClaw Voice Assistant — main entry point.

Always-on voice assistant that connects to an OpenClaw gateway.
Listens for a wake word, transcribes speech, sends to the AI, and speaks the response.
After speaking, listens for a follow-up to enable natural back-and-forth.

Mic suppression prevents the mic from hearing its own speaker output.
"""

import asyncio
import logging
import sys
import threading
import time
from pathlib import Path

# Add src to path so imports work when run directly
sys.path.insert(0, str(Path(__file__).resolve().parent))

import config
from audio_pipeline import (
    AudioPipeline,
    play_chime,
    play_thinking,
    stop_thinking,
    suppress_mic,
    unsuppress_mic,
    transcribe,
)
from gateway_client import GatewayClient
import tts_player

log = logging.getLogger("assistant")

# State
_pipeline: AudioPipeline | None = None
_gateway: GatewayClient | None = None
_loop: asyncio.AbstractEventLoop | None = None
_running = True
_paused = False
_in_conversation = False

# Timing
FOLLOW_UP_WINDOW = 5.0    # seconds to wait for follow-up after response
POST_SPEECH_PAUSE = 0.8   # breathing room after TTS before listening


def _on_wake(follow_up: bool = False):
    """Called when wake word is detected, hotkey pressed, or after TTS finishes."""
    global _in_conversation
    if _paused or not _running:
        return

    if follow_up:
        log.info("Listening for follow-up...")
        # Soft chime for follow-up — mic suppressed so it won't hear itself
        play_chime()
    else:
        log.info("Activated! Listening for command...")
        _in_conversation = True
        # Chime plays with mic suppressed internally
        play_chime()

    # Start recording (mic is unsuppressed now — chime is done)
    _pipeline.start_recording()

    # Wait for silence
    timeout = FOLLOW_UP_WINDOW if follow_up else 15.0
    got_silence = _pipeline.wait_for_silence(timeout=timeout)

    if not got_silence:
        if follow_up:
            _pipeline.stop_recording()
            _in_conversation = False
            log.info("Conversation ended (no follow-up)")
            return
        log.warning("Recording timeout — no silence detected")

    # Get recorded audio
    audio = _pipeline.stop_recording()
    if audio is None or len(audio) < 1600:
        if follow_up:
            _in_conversation = False
            log.debug("No audio in follow-up, back to idle")
        return

    # Transcribe
    text = transcribe(audio)
    if not text or len(text.strip()) < 2:
        if follow_up:
            _in_conversation = False
        return

    log.info("You said: %s", text)

    # Play thinking sound (mic suppressed during playback, then unsuppressed)
    play_thinking()

    # Send to gateway and speak response
    if _loop:
        asyncio.run_coroutine_threadsafe(_send_and_speak(text), _loop)


async def _send_and_speak(text: str):
    """Send text to gateway, collect response, speak it, then listen for follow-up."""
    global _in_conversation
    try:
        full_response = ""
        async for chunk in _gateway.send_message(text):
            full_response = chunk

        # Stop thinking sound
        stop_thinking()

        if full_response:
            log.info("Assistant: %s", full_response[:200])

            def _speak_then_listen():
                # Suppress mic during TTS so it doesn't hear itself
                suppress_mic()
                try:
                    tts_player.speak(full_response)
                finally:
                    time.sleep(0.15)
                    unsuppress_mic()

                # Breathing room — clear separation between response and listen
                time.sleep(POST_SPEECH_PAUSE)

                # Listen for follow-up
                _on_wake(follow_up=True)

            threading.Thread(target=_speak_then_listen, daemon=True).start()
        else:
            _in_conversation = False
            log.warning("Empty response from gateway")

    except Exception as e:
        stop_thinking()
        _in_conversation = False
        log.error("Error communicating with gateway: %s", e)


def _setup_hotkey():
    """Register global hotkey as alternative to wake word."""
    try:
        from pynput.keyboard import GlobalHotKeys

        parts = config.HOTKEY.split("+")
        combo_parts = []
        for p in parts:
            p = p.strip().lower()
            if p in ("ctrl", "control"):
                combo_parts.append("<ctrl>")
            elif p in ("shift",):
                combo_parts.append("<shift>")
            elif p in ("alt",):
                combo_parts.append("<alt>")
            elif p in ("cmd", "win", "super"):
                combo_parts.append("<cmd>")
            else:
                combo_parts.append(p)
        combo = "+".join(combo_parts)

        def on_hotkey():
            log.info("Hotkey pressed!")
            threading.Thread(target=_on_wake, daemon=True).start()

        hotkeys = GlobalHotKeys({combo: on_hotkey})
        hotkeys.daemon = True
        hotkeys.start()
        log.info("Global hotkey registered: %s", config.HOTKEY)
    except Exception as e:
        log.warning("Could not register hotkey: %s", e)


def _run_tray():
    """Run system tray icon (blocks on main thread)."""
    try:
        import pystray
        from PIL import Image

        icon_path = config.ASSETS_DIR / "icon.png"
        if icon_path.exists():
            image = Image.open(icon_path)
        else:
            image = Image.new("RGB", (64, 64), color=(0, 180, 120))

        def on_pause(icon, item):
            global _paused
            _paused = not _paused
            if _paused:
                _pipeline.pause()
                log.info("Paused")
            else:
                _pipeline.resume()
                log.info("Resumed")

        def on_quit(icon, item):
            global _running
            _running = False
            icon.stop()
            log.info("Quitting...")

        menu = pystray.Menu(
            pystray.MenuItem(
                lambda item: "Resume" if _paused else "Pause",
                on_pause,
            ),
            pystray.MenuItem("Quit", on_quit),
        )

        icon = pystray.Icon("openclaw-voice", image, "OpenClaw Voice", menu)
        icon.run()

    except ImportError:
        log.warning("pystray not available — running without tray icon")
        try:
            while _running:
                threading.Event().wait(1)
        except KeyboardInterrupt:
            pass


async def _async_main():
    """Async main: connect to gateway, start audio pipeline."""
    global _gateway, _pipeline, _loop
    _loop = asyncio.get_event_loop()

    _gateway = GatewayClient()
    try:
        await _gateway.connect()
    except Exception as e:
        log.error("Failed to connect to gateway: %s", e)
        log.error("Make sure OpenClaw is running and GATEWAY_TOKEN is set in .env")
        return

    _pipeline = AudioPipeline()

    if not config.PORCUPINE_ACCESS_KEY:
        log.error(
            "PORCUPINE_ACCESS_KEY not set in .env. "
            "Sign up free at https://picovoice.ai to get one."
        )
        await _gateway.disconnect()
        return

    try:
        _pipeline.start(on_wake=_on_wake)
    except Exception as e:
        log.error("Failed to start audio pipeline: %s", e)
        log.error("Check your Porcupine access key and mic permissions.")
        await _gateway.disconnect()
        return

    _setup_hotkey()
    log.info("Voice assistant ready! Say your wake word or press %s", config.HOTKEY)

    while _running:
        await asyncio.sleep(1)

    _pipeline.stop()
    await _gateway.disconnect()


def main():
    """Entry point."""
    global _running

    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
        datefmt="%H:%M:%S",
    )

    log.info("OpenClaw Voice Assistant starting...")

    def run_async():
        asyncio.run(_async_main())

    async_thread = threading.Thread(target=run_async, daemon=True)
    async_thread.start()

    time.sleep(2)

    if not _running:
        return

    _run_tray()

    _running = False
    async_thread.join(timeout=5)
    log.info("Goodbye!")


if __name__ == "__main__":
    main()


path: scripts/src/audio_pipeline.py
content: """Audio pipeline: mic capture, wake word detection, speech-to-text."""

import logging
import random
import struct
import threading
import time
import wave
import winsound
from pathlib import Path

import numpy as np
import sounddevice as sd

from config import (
    ASSETS_DIR,
    PORCUPINE_ACCESS_KEY,
    PORCUPINE_MODEL_PATH,
    SAMPLE_RATE,
    SILENCE_TIMEOUT,
    WAKE_SENSITIVITY,
    WHISPER_MODEL,
)

log = logging.getLogger(__name__)

# Lazy-loaded globals
_porcupine = None
_whisper_model = None

# Mic suppression: when True, the audio callback drops all frames.
# This prevents the mic from picking up speaker output (chime, thinking, TTS).
_mic_suppressed = False
_suppress_lock = threading.Lock()


def suppress_mic():
    """Suppress mic input (call before playing any audio through speakers)."""
    global _mic_suppressed
    with _suppress_lock:
        _mic_suppressed = True


def unsuppress_mic():
    """Re-enable mic input (call after speaker audio finishes)."""
    global _mic_suppressed
    with _suppress_lock:
        _mic_suppressed = False


def _get_porcupine():
    global _porcupine
    if _porcupine is None:
        import pvporcupine
        keyword_paths = None
        keywords = None

        if PORCUPINE_MODEL_PATH and Path(PORCUPINE_MODEL_PATH).exists():
            keyword_paths = [PORCUPINE_MODEL_PATH]
            log.info("Using wake word model: %s", PORCUPINE_MODEL_PATH)
        else:
            keywords = ["hey google"]
            log.warning(
                "No custom wake word model found. Using built-in 'hey google'. "
                "Set PORCUPINE_MODEL_PATH in .env to use a custom wake word."
            )

        _porcupine = pvporcupine.create(
            access_key=PORCUPINE_ACCESS_KEY,
            keyword_paths=keyword_paths,
            keywords=keywords,
            sensitivities=[WAKE_SENSITIVITY],
        )
    return _porcupine


def _get_whisper():
    global _whisper_model
    if _whisper_model is None:
        from faster_whisper import WhisperModel
        log.info("Loading Whisper model '%s' (first load may download ~150MB)...", WHISPER_MODEL)
        _whisper_model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
        log.info("Whisper model loaded")
    return _whisper_model


# --- Sound libraries ---

_chime_sounds: list[Path] = []
_thinking_sounds: list[Path] = []


def _load_chime_sounds():
    global _chime_sounds
    sounds_dir = ASSETS_DIR / "chime_sounds"
    if sounds_dir.exists():
        _chime_sounds = sorted(sounds_dir.glob("*.wav"))
        log.info("Loaded %d chime sounds", len(_chime_sounds))
    if not _chime_sounds:
        fallback = ASSETS_DIR / "chime.wav"
        if fallback.exists():
            _chime_sounds = [fallback]


def _load_thinking_sounds():
    global _thinking_sounds
    sounds_dir = ASSETS_DIR / "thinking_sounds"
    if sounds_dir.exists():
        _thinking_sounds = sorted(sounds_dir.glob("*.wav"))
        log.info("Loaded %d thinking sounds", len(_thinking_sounds))
    if not _thinking_sounds:
        fallback = ASSETS_DIR / "thinking.wav"
        if fallback.exists():
            _thinking_sounds = [fallback]


def play_chime():
    """Play a random chime sound. Suppresses mic while playing."""
    if not _chime_sounds:
        _load_chime_sounds()

    if _chime_sounds:
        sound = random.choice(_chime_sounds)
        try:
            log.debug("Playing chime: %s", sound.name)
            suppress_mic()
            # SND_FILENAME = play from file, blocking (no SND_ASYNC)
            # so we know exactly when it's done
            winsound.PlaySound(str(sound), winsound.SND_FILENAME)
        except Exception as e:
            log.debug("Could not play chime: %s", e)
        finally:
            # Small buffer after playback for speaker echo to die
            time.sleep(0.15)
            unsuppress_mic()
    else:
        suppress_mic()
        winsound.Beep(880, 150)
        time.sleep(0.1)
        unsuppress_mic()


_thinking_thread = None
_thinking_stop = threading.Event()


def play_thinking():
    """Play one random thinking sound, suppressing mic during playback."""
    global _thinking_thread
    stop_thinking()

    if not _thinking_sounds:
        _load_thinking_sounds()
    if not _thinking_sounds:
        return

    _thinking_stop.clear()

    def _play():
        sound = random.choice(_thinking_sounds)
        try:
            log.debug("Playing thinking sound: %s", sound.name)
            suppress_mic()
            winsound.PlaySound(str(sound), winsound.SND_FILENAME)
        except Exception:
            pass
        finally:
            time.sleep(0.15)
            unsuppress_mic()
        # Wait silently until stopped (response arrives)
        _thinking_stop.wait()

    _thinking_thread = threading.Thread(target=_play, daemon=True)
    _thinking_thread.start()


def stop_thinking():
    """Stop the thinking sound."""
    global _thinking_thread
    _thinking_stop.set()
    try:
        winsound.PlaySound(None, winsound.SND_PURGE)
    except Exception:
        pass
    _thinking_thread = None


class AudioPipeline:
    """Manages the mic stream, wake word detection, recording, and transcription."""

    def __init__(self):
        self._running = False
        self._paused = False
        self._wake_callback = None
        self._stream = None
        self._recording = False
        self._recorded_frames: list[np.ndarray] = []
        self._silence_start = 0.0
        self._recording_start = 0.0
        self._got_speech = False

    def start(self, on_wake):
        """Start the audio pipeline. Calls on_wake() when wake word is detected."""
        self._wake_callback = on_wake
        self._running = True
        self._paused = False

        porcupine = _get_porcupine()
        frame_length = porcupine.frame_length

        log.info("Audio pipeline started (frame_length=%d, rate=%d)", frame_length, SAMPLE_RATE)

        self._stream = sd.InputStream(
            samplerate=SAMPLE_RATE,
            channels=1,
            dtype="int16",
            blocksize=frame_length,
            callback=self._audio_callback,
        )
        self._stream.start()

    def stop(self):
        self._running = False
        if self._stream:
            self._stream.stop()
            self._stream.close()
            self._stream = None

        global _porcupine
        if _porcupine is not None:
            _porcupine.delete()
            _porcupine = None

    def pause(self):
        self._paused = True

    def resume(self):
        self._paused = False

    def _audio_callback(self, indata, frames, time_info, status):
        """Called by sounddevice for each audio frame."""
        if not self._running or self._paused:
            return

        # Drop frames while speaker is playing to prevent self-hearing
        if _mic_suppressed:
            return

        pcm = indata[:, 0]  # mono int16

        if self._recording:
            self._recorded_frames.append(pcm.copy())

            rms = np.sqrt(np.mean(pcm.astype(np.float32) ** 2))
            elapsed = time.monotonic() - self._recording_start

            # Grace period: ignore silence for the first 2 seconds
            if elapsed < 2.0:
                if rms >= 300:
                    self._got_speech = True
                return

            # After grace period, apply silence detection
            if rms < 300:
                if self._silence_start == 0:
                    self._silence_start = time.monotonic()
                elif time.monotonic() - self._silence_start > SILENCE_TIMEOUT:
                    self._recording = False
                    log.debug("Silence detected, stopping recording (got_speech=%s)", self._got_speech)
            else:
                self._got_speech = True
                self._silence_start = 0
        else:
            # Feed to Porcupine for wake word detection
            try:
                porcupine = _get_porcupine()
                pcm_list = struct.unpack(f"{len(pcm)}h", pcm.tobytes())
                result = porcupine.process(pcm_list)
                if result >= 0:
                    log.info("Wake word detected!")
                    if self._wake_callback:
                        threading.Thread(
                            target=self._wake_callback, daemon=True
                        ).start()
            except Exception as e:
                log.debug("Porcupine error: %s", e)

    def start_recording(self):
        """Begin recording audio (called after wake word or hotkey)."""
        self._recorded_frames = []
        self._silence_start = 0
        self._recording_start = time.monotonic()
        self._got_speech = False
        self._recording = True
        log.debug("Recording started (2s grace period)")

    def stop_recording(self) -> np.ndarray | None:
        self._recording = False
        if not self._recorded_frames:
            return None
        audio = np.concatenate(self._recorded_frames)
        self._recorded_frames = []
        return audio

    def wait_for_silence(self, timeout: float = 15.0) -> bool:
        start = time.monotonic()
        while self._recording and (time.monotonic() - start) < timeout:
            time.sleep(0.1)
        return not self._recording

    @property
    def is_recording(self) -> bool:
        return self._recording


def transcribe(audio: np.ndarray) -> str:
    """Transcribe int16 audio array to text using faster-whisper."""
    model = _get_whisper()
    audio_float = audio.astype(np.float32) / 32768.0
    segments, info = model.transcribe(audio_float, beam_size=1, language="en")
    text = " ".join(seg.text.strip() for seg in segments).strip()
    log.info("Transcribed (%s): %s", f"{info.duration:.1f}s", text)
    return text


path: scripts/src/config.py
content: """Configuration loader — reads settings from .env file."""

import os
from pathlib import Path
from dotenv import load_dotenv

# Load .env from project root
_project_root = Path(__file__).resolve().parent.parent
load_dotenv(_project_root / ".env")

# OpenClaw Gateway
GATEWAY_URL = os.getenv("GATEWAY_URL", "ws://127.0.0.1:18789")
GATEWAY_TOKEN = os.getenv("GATEWAY_TOKEN", "")

# ElevenLabs TTS
[REDACTED_SECRET]"ELEVENLABS_API_KEY", "")
ELEVENLABS_VOICE_ID = os.getenv("ELEVENLABS_VOICE_ID", "XrExE9yKIg1WjnnlVkGX")  # Matilda (free)
ELEVENLABS_MODEL_ID = os.getenv("ELEVENLABS_MODEL_ID", "eleven_v3")

# Porcupine Wake Word
PORCUPINE_ACCESS_KEY = os.getenv("PORCUPINE_ACCESS_KEY", "")
_raw_model_path = os.getenv("PORCUPINE_MODEL_PATH", "")
# Resolve relative paths against project root
if _raw_model_path and not Path(_raw_model_path).is_absolute():
    PORCUPINE_MODEL_PATH = str(_project_root / _raw_model_path)
else:
    PORCUPINE_MODEL_PATH = _raw_model_path

# Whisper STT
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "base")

# Audio settings
WAKE_SENSITIVITY = float(os.getenv("WAKE_SENSITIVITY", "0.7"))
SILENCE_TIMEOUT = float(os.getenv("SILENCE_TIMEOUT", "1.5"))
SAMPLE_RATE = 16000

# Hotkey
HOTKEY = os.getenv("HOTKEY", "ctrl+shift+k")

# Paths
ASSETS_DIR = _project_root / "assets"


path: scripts/src/gateway_client.py
content: """WebSocket client for OpenClaw gateway."""

import asyncio
import json
import logging
import uuid
from typing import AsyncGenerator

import websockets

from config import GATEWAY_URL, GATEWAY_TOKEN

log = logging.getLogger(__name__)


class GatewayClient:
    """Connects to an OpenClaw gateway and sends/receives chat messages."""

    def __init__(self):
        self.ws = None
        self._connected = False
        self._chat_queues: dict[str, asyncio.Queue] = {}
        self._response_queues: dict[str, asyncio.Queue] = {}
        self._event_task = None

    async def connect(self):
        """Establish WebSocket connection and authenticate."""
        log.info("Connecting to gateway at %s", GATEWAY_URL)
        self.ws = await websockets.connect(GATEWAY_URL, max_size=2**24)

        # Step 1: Receive connect.challenge
        raw = await self.ws.recv()
        challenge = json.loads(raw)
        if challenge.get("event") != "connect.challenge":
            raise ConnectionError(f"Expected connect.challenge, got: {challenge}")
        log.debug("Received challenge: %s", challenge["payload"].get("nonce"))

        # Step 2: Send connect request
        connect_id = str(uuid.uuid4())
        await self.ws.send(json.dumps({
            "type": "req",
            "id": connect_id,
            "method": "connect",
            "params": {
                "minProtocol": 3,
                "maxProtocol": 3,
                "client": {
                    "id": "node-host",
                    "version": "1.0.0",
                    "platform": "windows",
                    "mode": "node"
                },
                "role": "operator",
                "scopes": ["operator.write"],
                "caps": [],
                "auth": {
                    "token": GATEWAY_TOKEN
                }
            }
        }))

        # Step 3: Receive hello-ok
        raw = await self.ws.recv()
        hello = json.loads(raw)
        if not hello.get("ok"):
            error = hello.get("error", {})
            raise ConnectionError(
                f"Gateway auth failed: {error.get('code')} - {error.get('message')}"
            )

        server = hello.get("payload", {}).get("server", {})
        log.info("Connected to gateway v%s (conn: %s)",
                 server.get("version"), server.get("connId"))
        self._connected = True

        # Start the SINGLE background reader — all reads go through here
        self._event_task = asyncio.create_task(self._handle_events())

    async def disconnect(self):
        """Close the WebSocket connection."""
        self._connected = False
        if self._event_task:
            self._event_task.cancel()
        if self.ws:
            await self.ws.close()
        log.info("Disconnected from gateway")

    async def send_message(self, text: str) -> AsyncGenerator[str, None]:
        """Send a chat message and yield response text as it streams in."""
        if not self._connected:
            raise RuntimeError("Not connected to gateway")

        run_id = str(uuid.uuid4())
        req_id = str(uuid.uuid4())

        # Register queues BEFORE sending so we don't miss the response
        chat_queue: asyncio.Queue = asyncio.Queue()
        response_queue: asyncio.Queue = asyncio.Queue()
        self._chat_queues[run_id] = chat_queue
        self._response_queues[req_id] = response_queue

        try:
            # Send the request (write-only, no recv here)
            await self.ws.send(json.dumps({
                "type": "req",
                "id": req_id,
                "method": "chat.send",
                "params": {
                    "sessionKey": "main",
                    "message": text,
                    "idempotencyKey": run_id
                }
            }))

            # Wait for ack — routed here by _handle_events
            ack = await asyncio.wait_for(response_queue.get(), timeout=10.0)
            if not ack.get("ok"):
                error = ack.get("error", {})
                raise RuntimeError(f"chat.send failed: {error.get('message')}")

            # Stream chat events — also routed by _handle_events
            while True:
                try:
                    event = await asyncio.wait_for(chat_queue.get(), timeout=120.0)
                except asyncio.TimeoutError:
                    log.warning("Timed out waiting for chat response")
                    break

                state = event.get("state")
                if state == "delta":
                    content = event.get("message", {}).get("content", [])
                    if content and content[0].get("type") == "text":
                        yield content[0]["text"]
                elif state == "final":
                    content = event.get("message", {}).get("content", [])
                    if content and content[0].get("type") == "text":
                        yield content[0]["text"]
                    break
                elif state == "error":
                    raise RuntimeError(
                        f"Agent error: {event.get('errorMessage', 'unknown')}"
                    )
                elif state == "aborted":
                    break
        finally:
            self._chat_queues.pop(run_id, None)
            self._response_queues.pop(req_id, None)

    async def _handle_events(self):
        """Single background reader — routes ALL incoming frames to queues."""
        try:
            async for raw in self.ws:
                try:
                    frame = json.loads(raw)
                except json.JSONDecodeError:
                    continue

                frame_type = frame.get("type")

                if frame_type == "event":
                    event_name = frame.get("event")
                    payload = frame.get("payload", {})

                    if event_name == "chat":
                        run_id = payload.get("runId")
                        queue = self._chat_queues.get(run_id)
                        if queue:
                            await queue.put(payload)

                    elif event_name == "tick":
                        pass  # keepalive

                elif frame_type == "res":
                    req_id = frame.get("id")
                    queue = self._response_queues.get(req_id)
                    if queue:
                        await queue.put(frame)

        except websockets.ConnectionClosed:
            log.warning("Gateway connection closed")
            self._connected = False
        except asyncio.CancelledError:
            pass

    @property
    def connected(self) -> bool:
        return self._connected


path: scripts/src/tts_player.py
content: """ElevenLabs text-to-speech playback."""

import io
import logging
import os
import tempfile
import wave
import winsound

import numpy as np

from config import ELEVENLABS_API_KEY, ELEVENLABS_MODEL_ID, ELEVENLABS_VOICE_ID

log = logging.getLogger(__name__)

_client = None
_interrupted = False


def _get_client():
    global _client
    if _client is None:
        from elevenlabs.client import ElevenLabs
        _client = ElevenLabs([REDACTED_SECRET])
    return _client


def _decode_mp3_to_wav(mp3_bytes: bytes) -> str | None:
    """Decode MP3 bytes to a temporary WAV file using av (PyAV). Returns path."""
    try:
        import av

        container = av.open(io.BytesIO(mp3_bytes), format="mp3")
        audio_stream = container.streams.audio[0]

        samples = []
        sample_rate = None

        for frame in container.decode(audio_stream):
            if sample_rate is None:
                sample_rate = frame.sample_rate
            arr = frame.to_ndarray()
            if arr.ndim > 1:
                arr = arr.mean(axis=0)
            samples.append(arr.flatten())

        container.close()

        if not samples or sample_rate is None:
            return None

        audio = np.concatenate(samples)

        if audio.dtype in (np.float32, np.float64):
            audio = np.clip(audio, -1.0, 1.0)
            audio_int16 = (audio * 32767).astype(np.int16)
        else:
            audio_int16 = audio.astype(np.int16)

        tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
        tmp_path = tmp.name
        tmp.close()

        with wave.open(tmp_path, "w") as wf:
            wf.setnchannels(1)
            wf.setsampwidth(2)
            wf.setframerate(sample_rate)
            wf.writeframes(audio_int16.tobytes())

        return tmp_path

    except Exception as e:
        log.error("MP3 decode error: %s", e)
        return None


def speak(text: str):
    """Convert text to speech and play it. Blocks until done or interrupted."""
    global _interrupted
    _interrupted = False

    if not [REDACTED_SECRET]"No ElevenLabs API key configured. Skipping TTS.")
        return

    if not text.strip():
        return

    try:
        client = _get_client()
        log.debug("Generating speech for: %s", text[:80])

        audio_stream = client.text_to_speech.convert(
            text=text,
            voice_id=ELEVENLABS_VOICE_ID,
            model_id=ELEVENLABS_MODEL_ID,
            output_format="mp3_44100_128",
        )

        mp3_bytes = b"".join(audio_stream)

        if _interrupted:
            log.debug("TTS interrupted before playback")
            return

        wav_path = _decode_mp3_to_wav(mp3_bytes)
        if not wav_path:
            log.error("Failed to decode TTS audio")
            return

        if _interrupted:
            os.unlink(wav_path)
            return

        try:
            log.info("Speaking response...")
            winsound.PlaySound(wav_path, winsound.SND_FILENAME)
            log.debug("Playback finished")
        finally:
            try:
                os.unlink(wav_path)
            except OSError:
                pass

    except Exception as e:
        log.error("TTS error: %s", e)


def interrupt():
    """Stop current TTS playback."""
    global _interrupted
    _interrupted = True
    try:
        winsound.PlaySound(None, winsound.SND_PURGE)
    except Exception:
        pass
    log.debug("TTS playback interrupted")


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)