Skill: damustermann/claw-qwen3-tts (version 1.0.0)

--- name: qwen3-tts description: > High-quality text-to-speech using Qwen3-TTS. 10 built-in speakers with emotional instruct control, voice cloning (3s of audio), natural-language voice design, 10+ languages, persistent named voices, and delivering audio via Telegram/WhatsApp as native voice messages. Auto-detects GPU hardware (CUDA, ROCm, Intel XPU, CPU). version: "1.0" author: daMustermann repository: https://github.com/daMustermann/claw-qwen3-tts license: MIT requires: - python>=3.10 - ffmpeg - sox - git tags: - tts - audio - voice - speech - voice-cloning - voice-design - telegram - whatsapp - clawhub --- # Qwen3-TTS Skill You have access to a powerful text-to-speech system that can generate human-quality speech with 10 built-in speakers, design new voices from descriptions, clone existing voices from audio samples, and send audio via Telegram/WhatsApp as native voice messages. ## First-Time Setup If the skill is not yet installed (no `~/clawd/skills/qwen3-tts` directory), run: ```bash bash <(curl -fsSL https://raw.githubusercontent.com/daMustermann/claw-qwen3-tts/main/install.sh) ``` Or if already cloned but not set up (no `.venv/` directory): ```bash bash ~/clawd/skills/qwen3-tts/install.sh ``` This auto-detects the GPU (CUDA, ROCm, Intel XPU, or CPU-only), creates a Python venv, and installs all dependencies. It takes 5–15 minutes on first run. ## Starting & Stopping the Server Before any TTS operation, ensure the server is running: ```bash # Start (idempotent — won't restart if already running) bash ~/clawd/skills/qwen3-tts/scripts/start_server.sh # Check health bash ~/clawd/skills/qwen3-tts/scripts/health_check.sh # Stop (when done) bash ~/clawd/skills/qwen3-tts/scripts/stop_server.sh ``` The server runs at `http://localhost:8880`. --- ## Available Models | Model ID | Use Case | Notes | |----------|----------|-------| | `custom-voice-1.7b` | High-quality TTS with built-in speakers — **default** | Best quality, ~5 GB VRAM | | `custom-voice-0.6b` | Fast TTS with built-in speakers | Lightweight, ~2 GB VRAM | | `voice-design` | Design new voices from natural language descriptions | Uses VoiceDesign model | | `base-1.7b` | Basic TTS (auto-corrected to `custom-voice-1.7b`) | Use `custom-voice-*` instead | | `base-0.6b` | Basic TTS (auto-corrected to `custom-voice-0.6b`) | Use `custom-voice-*` instead | > **Important:** On the `/v1/audio/speech` endpoint, `base-*` and `voice-design` models are automatically corrected to the corresponding `custom-voice-*` model. Always prefer `custom-voice-1.7b` or `custom-voice-0.6b` for speech generation. ## Built-in Speakers The `custom-voice-*` models include 10 built-in voices: > **Chelsie** · **Ethan** · **Aidan** · **Serena** · **Ryan** · **Vivian** · **Claire** · **Lucas** · **Eleanor** · **Benjamin** You can discover speakers dynamically: `curl http://localhost:8880/v1/speakers` --- ## Capabilities ### 1. Generate Speech from Text **When to use:** User asks to speak text, read something aloud, generate audio, do a voiceover, narrate, or say something. ```bash curl -X POST http://localhost:8880/v1/audio/speech \ -H "Content-Type: application/json" \ -d '{ "model": "custom-voice-1.7b", "input": "TEXT_HERE", "voice": "default", "speaker": "Chelsie", "language": "en", "instruct": "", "response_format": "wav" }' \ --output ~/clawd/skills/qwen3-tts/output/speech.wav ``` **Parameters:** | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| | `model` | no | `custom-voice-1.7b` | TTS model to use | | `input` | **yes** | — | The text to synthesize | | `voice` | no | `default` | `"default"` for built-in speakers, or a **saved voice name** (e.g. `"Angie"`) | | `speaker` | no | `Chelsie` | Built-in speaker name (only when `voice` is `"default"`) | | `language` | no | `en` | Language code: en, zh, ja, ko, de, fr, ru, pt, es, it | | `instruct` | no | `""` | Emotional/style instruction (see below) | | `response_format` | no | `wav` | Output format: wav, mp3, ogg, flac | | `speed` | no | `1.0` | Speech speed multiplier | **Language codes:** `en`, `zh`, `ja`, `ko`, `de`, `fr`, `ru`, `pt`, `es`, `it` — or full names like `English`, `Chinese`, `German`, etc. **Instruct examples** (controls tone, emotion, and style): - `"Speak happily and with excitement"` - `"Whisper softly, as if telling a secret"` - `"Read this in a calm, professional news anchor tone"` - `"用愤怒的语气"` (Speak angrily — works in target language too) - `""` (empty string = neutral default) **When voice is a saved name:** If you pass `"voice": "Angie"` and a voice named "Angie" exists, the server uses voice cloning with the saved reference audio instead of a built-in speaker. The `speaker` field is ignored in this case. ### 2. Design a New Voice **When to use:** User wants to create a custom voice, describe how a character should sound, design a persona's voice. ```bash curl -X POST http://localhost:8880/v1/audio/voice-design \ -H "Content-Type: application/json" \ -d '{ "model": "voice-design", "input": "TEXT_TO_SPEAK", "voice_description": "DESCRIBE THE VOICE IN NATURAL LANGUAGE", "language": "en", "response_format": "wav" }' \ --output ~/clawd/skills/qwen3-tts/output/designed.wav ``` **Parameters:** | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| | `model` | no | `voice-design` | Must be `voice-design` | | `input` | **yes** | — | Text to synthesize with the designed voice | | `voice_description` | **yes** | — | Natural language description of the desired voice | | `language` | no | `en` | Target language | | `response_format` | no | `wav` | Output format | **Example descriptions:** - `"A warm, deep male voice with a slight British accent, calm and authoritative, like a BBC presenter in his 40s"` - `"A young, energetic female voice, bright and cheerful, with a slight rasp"` - `"An old wizard with a slow, mysterious, gravelly voice"` The response includes a `X-Voice-Id` header — capture it to save the voice (see §4). ### 3. Clone a Voice **When to use:** User provides a reference audio clip and wants to generate new speech in that voice. ```bash curl -X POST http://localhost:8880/v1/audio/voice-clone \ -F "reference_audio=@/path/to/reference.wav" \ -F "reference_text=Transcript of the reference audio" \ -F "input=New text to speak in the cloned voice" \ -F "language=en" \ -F "response_format=wav" \ --output ~/clawd/skills/qwen3-tts/output/cloned.wav ``` **Parameters:** | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| | `reference_audio` | **yes** | — | Audio file to clone the voice from | | `input` | **yes** | — | New text to synthesize in the cloned voice | | `reference_text` | no | `""` | Transcription of the reference audio (improves quality) | | `language` | no | `en` | Target language | | `response_format` | no | `wav` | Output format | **Guidelines:** - Minimum **3 seconds** of reference audio - Recommended **10–30 seconds** for best quality - Providing an accurate `reference_text` transcription significantly improves results - Supports **cross-language cloning** (clone from English → speak in Japanese) - If `reference_text` is empty, uses x-vector-only mode (audio features only) The response includes a `X-Voice-Id` header — capture it to save the voice (see §4). ### 4. ⭐ CRITICAL: Voice Save Prompting Rules **YOU MUST FOLLOW THESE RULES:** 1. **After EVERY voice-design or voice-clone request**, ask the user: > "Would you like to save this voice for future use? What name should I give it?" 2. **If the user says yes**, capture the `X-Voice-Id` from the response headers and save it: ```bash curl -X POST http://localhost:8880/v1/voices \ -H "Content-Type: application/json" \ -d '{ "name": "USER_CHOSEN_NAME", "source_voice_id": "VOICE_ID_FROM_X_VOICE_ID_HEADER", "description": "Description of the voice", "tags": ["tag1", "tag2"], "language": "en" }' ``` 3. **When user requests TTS with a voice name** (e.g. "say this with Angie"): - Use `"voice": "Angie"` in the `/v1/audio/speech` request - The server automatically loads the saved reference audio and uses voice cloning - If the name doesn't exist, tell the user and offer to design or clone one 4. **When user asks to list voices:** ```bash curl http://localhost:8880/v1/voices ``` Present the results as a formatted list with name, description, source, language, tags, and usage count. Voices are sorted by usage count (most used first). 5. **When user asks to delete a voice:** Confirm with the user first, then: ```bash curl -X DELETE http://localhost:8880/v1/voices/VOICE_NAME ``` 6. **When user asks to rename a voice:** ```bash curl -X PATCH http://localhost:8880/v1/voices/OLD_NAME \ -H "Content-Type: application/json" \ -d '{"name": "NEW_NAME"}' ``` 7. **When user asks to update a voice's metadata** (description, tags, language): ```bash curl -X PATCH http://localhost:8880/v1/voices/VOICE_NAME \ -H "Content-Type: application/json" \ -d '{"description": "Updated description", "tags": ["new", "tags"]}' ``` 8. **Voice names are case-insensitive** but stored in the casing the user provided. 9. **No duplicate names allowed.** If a name already exists, the save will fail (409). Ask the user for a different name or offer to delete the existing one first. 10. **Voice profiles are stored locally** in `~/clawd/skills/qwen3-tts/voices/` and persist across server restarts. Each voice consists of: - `<name>.json` — metadata - `<name>.pt` — embedding tensor - `<name>_sample.wav` — reference audio sample (used for re-cloning) ### 5. Convert Audio Formats **When to use:** User needs audio in a specific format, or you need to prepare audio for messaging. ```bash curl -X POST http://localhost:8880/v1/audio/convert \ -F "audio=@input.wav" \ -F "target_format=mp3" \ --output output.mp3 ``` Supported formats: **wav**, **mp3**, **ogg** (Opus), **flac** You can also use the shell script directly: ```bash bash ~/clawd/skills/qwen3-tts/scripts/convert_to_ogg_opus.sh input.wav output.ogg ``` ### 6. Send via Telegram (PTT Voice Message) **When to use:** User is interacting via Telegram, or explicitly asks to send audio to a Telegram chat. ```bash curl -X POST http://localhost:8880/v1/audio/send/telegram \ -H "Content-Type: application/json" \ -d '{ "audio_file": "/path/to/audio.wav", "chat_id": "CHAT_ID", "bot_token": "BOT_TOKEN", "caption": "Optional caption" }' ``` - `bot_token` is optional if already configured in `config.json` - Audio is auto-converted to OGG/Opus and sent via Telegram's `sendVoice` API - Displays as a native PTT waveform voice message in the chat ### 7. Send via WhatsApp (PTT Voice Message) **When to use:** User is interacting via WhatsApp, or explicitly asks to send audio there. ```bash curl -X POST http://localhost:8880/v1/audio/send/whatsapp \ -H "Content-Type: application/json" \ -d '{ "audio_file": "/path/to/audio.wav", "phone_number_id": "PHONE_ID", "recipient": "+14155551234", "access_token": "ACCESS_TOKEN" }' ``` - `phone_number_id` and `access_token` are optional if already configured in `config.json` - Audio is auto-converted to OGG/Opus and sent as a native WhatsApp voice message ### 8. Discovery Endpoints Use these to dynamically discover available models and speakers: ```bash # List all available TTS models curl http://localhost:8880/v1/models # List built-in speakers curl http://localhost:8880/v1/speakers # Server health check (device info, voice count, version) curl http://localhost:8880/health ``` --- ## How to Respond **After generating speech:** 1. Tell the user the audio has been generated 2. Provide the output file path 3. If it was voice-design or voice-clone, **always ask to save the voice** (Rule §4.1) 4. If the user is on Telegram/WhatsApp, offer to send it as a voice message **After saving a voice:** - Confirm the name and tell the user they can use it anytime with that name - Example: *"Voice saved as 'Captain Hook'! You can reference it anytime with `voice: Captain Hook`."* **After sending via Telegram/WhatsApp:** - Confirm successful delivery **When choosing a speaker:** If the user doesn't specify, default to `"Chelsie"`. If they describe the kind of voice they want (but not a full voice-design request), pick the most fitting built-in speaker. **When choosing a model:** Default to `custom-voice-1.7b`. Only use `custom-voice-0.6b` if the user asks for speed, or if the system has limited VRAM/memory. --- ## Configuration The agent can update `~/clawd/skills/qwen3-tts/config.json` to set: - **Telegram:** bot token and default chat ID - **WhatsApp:** phone number ID and access token - **Default model:** `custom-voice-1.7b` or `custom-voice-0.6b` - **Default audio format:** wav, mp3, ogg, flac - **Device override:** auto, cuda:0, xpu:0, cpu If `config.json` doesn't exist, copy the template: ```bash cp ~/clawd/skills/qwen3-tts/config.json.template ~/clawd/skills/qwen3-tts/config.json ```

path: .config.json
content: {
  "server": {
    "host": "127.0.0.1",
    "port": 8880,
    "workers": 1
  },
  "models": {
    "default_model": "custom-voice-1.7b",
    "cache_dir": "~/clawd/skills/qwen3-tts/models/",
    "auto_download": true,
    "device": "auto"
  },
  "audio": {
    "sample_rate": 24000,
    "default_format": "wav",
    "output_dir": "~/clawd/skills/qwen3-tts/output/"
  },
  "voices": {
    "dir": "~/clawd/skills/qwen3-tts/voices/"
  },
  "telegram": {
    "bot_token": "",
    "default_chat_id": ""
  },
  "whatsapp": {
    "phone_number_id": "",
    "access_token": ""
  },
  "hardware": {
    "detected": "auto",
    "override": null
  }
}


path: install.sh
content: #!/usr/bin/env bash
# install.sh — One-command installer for the Qwen3-TTS OpenClaw skill
# Usage: bash install.sh
#   or:  curl -fsSL https://raw.githubusercontent.com/daMustermann/claw-qwen3-tts/main/install.sh | bash
set -euo pipefail

# ─── Colors ───
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m'

info()  { echo -e "${BLUE}[INFO]${NC}  $*"; }
ok()    { echo -e "${GREEN}[OK]${NC}    $*"; }
warn()  { echo -e "${YELLOW}[WARN]${NC}  $*"; }
err()   { echo -e "${RED}[ERR]${NC}   $*"; }

# ─── Config ───
SKILL_NAME="qwen3-tts"
REPO_URL="https://github.com/daMustermann/claw-qwen3-tts.git"
SKILLS_DIR="${OPENCLAW_SKILLS_DIR:-${HOME}/clawd/skills}"
INSTALL_DIR="${SKILLS_DIR}/${SKILL_NAME}"

echo ""
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║  🎤 Qwen3-TTS OpenClaw Skill — Installer v1.0   ║${NC}"
echo -e "${BOLD}║  Author: daMustermann                           ║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
echo ""

# ─── Step 1: Check prerequisites ───
info "Checking prerequisites..."

MISSING=()
for cmd in git python3 ffmpeg sox curl; do
    if ! command -v "$cmd" &>/dev/null; then
        MISSING+=("$cmd")
    fi
done

if [ ${#MISSING[@]} -gt 0 ]; then
    err "Missing required tools: ${MISSING[*]}"
    echo ""

    # Detect distro and suggest install command
    if [ -f /etc/os-release ]; then
        . /etc/os-release
        case "$ID" in
            cachyos|arch|endeavouros|manjaro)
                echo "  sudo pacman -S ${MISSING[*]/#/} base-devel"
                ;;
            ubuntu|debian|pop|linuxmint)
                echo "  sudo apt update && sudo apt install -y ${MISSING[*]/#/} build-essential python3-venv"
                ;;
            fedora|rhel|centos)
                echo "  sudo dnf install -y ${MISSING[*]/#/} gcc gcc-c++ make"
                ;;
            opensuse*|suse)
                echo "  sudo zypper install -y ${MISSING[*]/#/} gcc gcc-c++ make"
                ;;
            *)
                echo "  Please install: ${MISSING[*]}"
                ;;
        esac
    fi
    echo ""
    exit 1
fi
ok "Prerequisites OK"

# ─── Step 2: Create skills directory ───
if [ ! -d "$SKILLS_DIR" ]; then
    info "Creating skills directory: $SKILLS_DIR"
    mkdir -p "$SKILLS_DIR"
fi

# ─── Step 3: Clone or update the repo ───
if [ -d "$INSTALL_DIR" ]; then
    if [ -d "$INSTALL_DIR/.git" ]; then
        info "Skill already installed, updating..."
        cd "$INSTALL_DIR"
        git pull --ff-only origin main 2>/dev/null || {
            warn "Git pull failed, continuing with existing version"
        }
        ok "Updated to latest version"
    else
        warn "Directory $INSTALL_DIR exists but is not a git repo"
        warn "Skipping clone — using existing files"
    fi
else
    info "Cloning skill from GitHub..."
    git clone "$REPO_URL" "$INSTALL_DIR"
    ok "Cloned to $INSTALL_DIR"
fi

cd "$INSTALL_DIR"

# ─── Step 4: Make scripts executable ───
info "Setting script permissions..."
chmod +x scripts/*.sh 2>/dev/null || true
ok "Scripts are executable"

# ─── Step 5: Run environment setup ───
info "Running environment setup (GPU detection, venv, dependencies)..."
echo ""
bash "$INSTALL_DIR/scripts/setup_env.sh"
echo ""

# ─── Step 6: Verify installation ───
info "Verifying installation..."

if [ -d "$INSTALL_DIR/.venv" ]; then
    ok "Virtual environment created"
else
    err "Virtual environment not found — setup may have failed"
    exit 1
fi

if [ -f "$INSTALL_DIR/SKILL.md" ]; then
    ok "SKILL.md present"
else
    err "SKILL.md missing"
    exit 1
fi

if [ -f "$INSTALL_DIR/config.json" ]; then
    ok "Config generated"
else
    warn "config.json not found — will be created on first server start"
fi

# ─── Done ───
echo ""
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║  ✅ Installation complete!                       ║${NC}"
echo -e "${BOLD}║                                                  ║${NC}"
echo -e "${BOLD}║  Installed to: ${INSTALL_DIR}${NC}"
echo -e "${BOLD}║                                                  ║${NC}"
echo -e "${BOLD}║  Quick test:                                     ║${NC}"
echo -e "${BOLD}║    bash ${INSTALL_DIR}/scripts/start_server.sh   ║${NC}"
echo -e "${BOLD}║    bash ${INSTALL_DIR}/scripts/health_check.sh   ║${NC}"
echo -e "${BOLD}║                                                  ║${NC}"
echo -e "${BOLD}║  OpenClaw now has access to the qwen3-tts skill. ║${NC}"
echo -e "${BOLD}║  Just ask it to generate speech or design voices!║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
echo ""


path: README.md
content: # 🎤 Qwen3-TTS — OpenClaw Skill

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![ClawHub](https://img.shields.io/badge/ClawHub-daMustermann%2Fclaw--qwen3--tts-purple.svg)](https://clawhub.dev/daMustermann/claw-qwen3-tts)

> High-quality text-to-speech for OpenClaw agents. 10 built-in speakers, voice cloning, voice design, emotional instruct control, 10+ languages, Telegram & WhatsApp voice messages. Auto-detects CUDA, ROCm, Intel XPU, or CPU.

---

## ⚡ Quick Start

```bash
# One-command install (clones repo, detects GPU, sets up everything)
bash <(curl -fsSL https://raw.githubusercontent.com/daMustermann/claw-qwen3-tts/main/install.sh)

# Or install from ClawHub
claw install daMustermann/claw-qwen3-tts

# Or clone manually
git clone https://github.com/daMustermann/claw-qwen3-tts.git ~/clawd/skills/qwen3-tts
bash ~/clawd/skills/qwen3-tts/install.sh
```

The installer auto-detects your GPU, creates an isolated Python virtual environment, and installs all dependencies. First run takes 5–15 minutes.

---

## ✨ Features

| Feature | Description |
|---------|-------------|
| **🎤 Generate Speech** | Human-quality TTS in 10+ languages (EN, ZH, JA, KO, DE, FR, RU, PT, ES, IT) |
| **🗣️ 10 Built-in Speakers** | Chelsie, Ethan, Aidan, Serena, Ryan, Vivian, Claire, Lucas, Eleanor, Benjamin |
| **🎭 Emotional Instruct** | Control tone and style — *"Speak happily"*, *"Whisper softly"*, *"用愤怒的语气"* |
| **🎨 Design Voices** | Create new voices from natural language descriptions |
| **🔄 Clone Voices** | Clone any voice from just 3 seconds of reference audio |
| **💾 Persist Voices by Name** | Save as "Angie", reuse forever with `voice: "Angie"` |
| **📱 Telegram & WhatsApp PTT** | Send audio as native push-to-talk voice messages |
| **🖥️ Auto-Detect Hardware** | NVIDIA CUDA, AMD ROCm, Intel XPU, or CPU-only |
| **🔌 OpenAI-Compatible API** | Drop-in replacement at `localhost:8880` |
| **🤖 Smart Model Routing** | Auto-corrects model selection to prevent errors |

---

## 🎯 Supported Hardware

| Hardware | Accelerator | Recommended Model | VRAM |
|----------|-------------|-------------------|------|
| NVIDIA GPU | CUDA | `custom-voice-1.7b` (high quality) | ~5 GB |
| AMD GPU | ROCm | `custom-voice-1.7b` or `custom-voice-0.6b` | ~2–5 GB |
| Intel GPU | XPU | `custom-voice-0.6b` (fast) | ~2 GB |
| CPU only | — | `custom-voice-0.6b` | ~4 GB RAM |

---

## 🔌 API Reference

The server runs at `http://localhost:8880` and exposes these endpoints:

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/v1/audio/speech` | POST | Generate speech (built-in speakers or saved voices) |
| `/v1/audio/voice-design` | POST | Create speech with a natural-language voice description |
| `/v1/audio/voice-clone` | POST | Clone a voice from reference audio |
| `/v1/voices` | GET | List all saved voice profiles |
| `/v1/voices` | POST | Save a voice profile (name + embedding) |
| `/v1/voices/{name}` | GET | Get a specific voice profile |
| `/v1/voices/{name}` | PATCH | Rename or update a voice profile |
| `/v1/voices/{name}` | DELETE | Delete a voice profile |
| `/v1/audio/convert` | POST | Convert audio between formats (WAV/MP3/OGG/FLAC) |
| `/v1/audio/send/telegram` | POST | Send audio as Telegram PTT voice message |
| `/v1/audio/send/whatsapp` | POST | Send audio as WhatsApp PTT voice message |
| `/v1/models` | GET | List available TTS models |
| `/v1/speakers` | GET | List built-in speakers |
| `/health` | GET | Health check |

### Speech Generation

```bash
curl -X POST http://localhost:8880/v1/audio/speech \
  -H "Content-Type: application/json" \
  -d '{
    "model": "custom-voice-1.7b",
    "input": "Hello, this is a test of the Qwen3 text-to-speech system.",
    "voice": "default",
    "speaker": "Chelsie",
    "language": "en",
    "instruct": "Speak warmly and cheerfully",
    "response_format": "wav"
  }' \
  --output speech.wav
```

### Voice Design

```bash
curl -X POST http://localhost:8880/v1/audio/voice-design \
  -H "Content-Type: application/json" \
  -d '{
    "model": "voice-design",
    "input": "Welcome to our podcast!",
    "voice_description": "A warm, deep male voice with a slight British accent, calm and authoritative",
    "language": "en",
    "response_format": "wav"
  }' \
  --output designed_voice.wav
```

### Voice Cloning

```bash
curl -X POST http://localhost:8880/v1/audio/voice-clone \
  -F "reference_audio=@/path/to/reference.wav" \
  -F "reference_text=Transcript of the reference audio" \
  -F "input=New text to speak in the cloned voice" \
  -F "language=en" \
  -F "response_format=wav" \
  --output cloned.wav
```

- Minimum **3 seconds** of reference audio (10–30 seconds recommended)
- Accurate transcription of reference audio improves quality
- Supports **cross-language cloning** (clone from English → speak in Japanese)

---

## 🗣️ Voice Persistence

Every designed or cloned voice can be saved with a name you choose:

```
User: "Design a voice — a raspy pirate captain"
Agent: [generates audio] "Would you like to save this voice? What should I call it?"
User: "Call it Captain Hook"
Agent: "Voice saved as 'Captain Hook'! You can use it anytime."

User: "Say 'Ahoy mateys!' with voice Captain Hook"
Agent: [generates using saved voice]
```

Voices are stored locally in `voices/` and persist across sessions. The agent automatically prompts to save after every voice design or clone operation.

---

## 🎭 Built-in Speakers & Instruct

The `custom-voice` models come with **10 built-in speakers**, each with a unique voice:

> Chelsie · Ethan · Aidan · Serena · Ryan · Vivian · Claire · Lucas · Eleanor · Benjamin

You can also control the **emotional tone** with the `instruct` parameter:

```json
{ "instruct": "Speak happily and with excitement" }
{ "instruct": "Whisper softly, as if telling a secret" }
{ "instruct": "用愤怒的语气" }
```

---

## 🧠 Available Models

| Model ID | HuggingFace ID | Use Case | VRAM |
|----------|----------------|----------|------|
| `custom-voice-0.6b` | `Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice` | Fast TTS with built-in speakers | ~2 GB |
| `custom-voice-1.7b` | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | High-quality TTS with built-in speakers | ~5 GB |
| `base-0.6b` | `Qwen/Qwen3-TTS-12Hz-0.6B-Base` | Basic TTS (auto-corrected to custom-voice) | ~2 GB |
| `base-1.7b` | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | Basic TTS (auto-corrected to custom-voice) | ~5 GB |
| `voice-design` | `Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign` | Voice design from descriptions | ~5 GB |

> **Smart routing:** If you request a `base-*` model on the `/v1/audio/speech` endpoint, the server auto-corrects to the corresponding `custom-voice-*` model to prevent errors.

---

## 📱 Messaging Integration

### Telegram

Configure your bot token in `config.json`, then the agent sends audio as native PTT voice messages using `sendVoice` (OGG/Opus format).

```bash
curl -X POST http://localhost:8880/v1/audio/send/telegram \
  -H "Content-Type: application/json" \
  -d '{
    "audio_file": "/path/to/speech.wav",
    "chat_id": "123456789",
    "bot_token": "YOUR_BOT_TOKEN"
  }'
```

### WhatsApp

Configure your WhatsApp Business API credentials in `config.json`, then audio is sent as native voice messages.

```bash
curl -X POST http://localhost:8880/v1/audio/send/whatsapp \
  -H "Content-Type: application/json" \
  -d '{
    "audio_file": "/path/to/speech.wav",
    "phone_number_id": "PHONE_ID",
    "recipient": "+14155551234",
    "access_token": "YOUR_ACCESS_TOKEN"
  }'
```

---

## 🔧 Manual Setup

If you prefer manual setup instead of using `install.sh`:

```bash
cd ~/clawd/skills/qwen3-tts
bash scripts/setup_env.sh        # Detect GPU, create venv, install deps
bash scripts/start_server.sh     # Start the TTS server
bash scripts/health_check.sh     # Verify it's running
```

### Server Lifecycle

```bash
bash scripts/start_server.sh     # Start (idempotent — won't restart if already running)
bash scripts/stop_server.sh      # Gracefully stop
bash scripts/health_check.sh     # Returns OK or UNHEALTHY
```

---

## ⚙️ Configuration

Copy the template and customize:

```bash
cp config.json.template config.json
```

The config file controls server settings, default model, audio output, voice storage, and messaging credentials. See [`config.json.template`](config.json.template) for all options.

---

## 📁 Project Structure

```
claw-qwen3-tts/
├── SKILL.md                     # OpenClaw skill definition
├── README.md                    # This file
├── LICENSE                      # MIT License
├── install.sh                   # One-command installer
├── config.json.template         # Configuration template
├── .gitignore
├── scripts/
│   ├── detect_gpu.sh            # GPU hardware detection
│   ├── setup_env.sh             # Venv + dependency setup
│   ├── start_server.sh          # Start the TTS server
│   ├── stop_server.sh           # Stop the TTS server
│   ├── health_check.sh          # Server health check
│   └── convert_to_ogg_opus.sh   # Audio format conversion
├── server/
│   ├── tts_server.py            # FastAPI server (all endpoints)
│   ├── model_loader.py          # Model download & loading
│   ├── voice_manager.py         # Voice profile CRUD
│   ├── voice_designer.py        # Natural-language voice design
│   ├── voice_cloner.py          # Voice cloning pipeline
│   ├── audio_converter.py       # Audio format conversion
│   └── messaging/
│       ├── telegram_sender.py   # Telegram PTT delivery
│       └── whatsapp_sender.py   # WhatsApp PTT delivery
├── voices/                      # Saved voice profiles (per-install)
├── output/                      # Generated audio files (transient)
├── models/                      # Cached model weights (auto-downloaded)
└── requirements/
    ├── base.txt                 # Core Python dependencies
    ├── cuda.txt                 # NVIDIA CUDA extras
    ├── rocm.txt                 # AMD ROCm extras
    ├── intel.txt                # Intel XPU extras
    └── cpu.txt                  # CPU-only PyTorch
```

---

## 📋 Requirements

- **OS:** Linux (CachyOS, Arch, Ubuntu, Fedora, Debian, openSUSE, NixOS)
- **Python:** 3.10+
- **ffmpeg:** Required for audio format conversion
- **sox:** Required for audio processing
- **git:** Required for installation
- **Disk:** ~10 GB for models

---

## 🤝 Contributing

Contributions welcome! Please open an issue or PR on [GitHub](https://github.com/daMustermann/claw-qwen3-tts).

---

## 📜 Credits

- **Model:** [Qwen3-TTS](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-Base) by the Qwen team (Alibaba Cloud)
- **Author:** [daMustermann](https://github.com/daMustermann)
- **License:** MIT


path: requirements/base.txt
content: qwen-tts
fastapi>=0.115.0
uvicorn[standard]>=0.34.0
soundfile>=0.13.0
numpy
scipy
pydub
python-multipart
httpx
aiofiles
python-telegram-bot>=21.0
pydantic>=2.0
huggingface-hub[hf_xet]


path: requirements/cpu.txt
content: # CPU only — install with:
# pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu
# Then install base.txt
--index-url https://download.pytorch.org/whl/cpu
torch
torchaudio


path: requirements/cuda.txt
content: # NVIDIA CUDA — install with:
# pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu128
# Then install base.txt
--index-url https://download.pytorch.org/whl/cu128
torch
torchaudio

# Optional: flash-attn for faster inference (requires CUDA toolkit / nvcc)
# Install CUDA toolkit first: sudo pacman -S cuda  (CachyOS/Arch)
# Then: CUDA_HOME=/opt/cuda pip install flash-attn --no-build-isolation


path: requirements/intel.txt
content: # Intel XPU — install with:
# pip install torch torchaudio intel-extension-for-pytorch --index-url https://download.pytorch.org/whl/xpu
# Then install base.txt
--index-url https://download.pytorch.org/whl/xpu
torch
torchaudio
intel-extension-for-pytorch


path: requirements/rocm.txt
content: # AMD ROCm — install with:
# pip install torch torchaudio --index-url https://download.pytorch.org/whl/rocm6.3
# Then install base.txt
--index-url https://download.pytorch.org/whl/rocm6.3
torch
torchaudio


path: scripts/convert_to_ogg_opus.sh
content: #!/usr/bin/env bash
# convert_to_ogg_opus.sh — Convert any audio file to OGG/Opus format
# Required for Telegram & WhatsApp PTT voice messages
set -euo pipefail

if [ $# -lt 2 ]; then
    echo "Usage: $0 <input_file> <output_file.ogg>"
    echo ""
    echo "Converts any audio file to OGG/Opus format suitable for"
    echo "Telegram and WhatsApp voice messages (PTT)."
    exit 1
fi

INPUT="$1"
OUTPUT="$2"
BITRATE="${3:-64k}"

if [ ! -f "$INPUT" ]; then
    echo "[ERROR] Input file not found: $INPUT"
    exit 1
fi

if ! command -v ffmpeg &>/dev/null; then
    echo "[ERROR] ffmpeg is not installed"
    exit 1
fi

echo "[INFO] Converting: $INPUT → $OUTPUT (OGG/Opus, ${BITRATE})"

ffmpeg -y -i "$INPUT" \
    -c:a libopus \
    -b:a "$BITRATE" \
    -vbr on \
    -compression_level 10 \
    -frame_duration 20 \
    -application voip \
    "$OUTPUT" \
    2>/dev/null

if [ -f "$OUTPUT" ]; then
    SIZE=$(du -h "$OUTPUT" | cut -f1)
    echo "[OK] Converted: $OUTPUT ($SIZE)"
else
    echo "[ERROR] Conversion failed"
    exit 1
fi


path: scripts/detect_gpu.sh
content: #!/usr/bin/env bash
# detect_gpu.sh — Detect GPU accelerator type
# Outputs exactly one of: cuda, rocm, intel, cpu
set -euo pipefail

detect_gpu() {
    # 1. Check for NVIDIA GPU (CUDA)
    if command -v nvidia-smi &>/dev/null; then
        if nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 | grep -qi .; then
            echo "cuda"
            return 0
        fi
    fi

    # 2. Check for AMD GPU (ROCm)
    if command -v rocminfo &>/dev/null; then
        if rocminfo 2>/dev/null | grep -q "Name:.*gfx"; then
            echo "rocm"
            return 0
        fi
    elif [ -x /opt/rocm/bin/rocminfo ]; then
        if /opt/rocm/bin/rocminfo 2>/dev/null | grep -q "Name:.*gfx"; then
            echo "rocm"
            return 0
        fi
    fi
    # Also check for /dev/kfd (ROCm kernel driver)
    if [ -e /dev/kfd ] && lspci 2>/dev/null | grep -qi "amd.*radeon\|amd.*navi\|amd.*vega\|amd.*polaris"; then
        echo "rocm"
        return 0
    fi

    # 3. Check for Intel GPU (XPU)
    if command -v xpu-smi &>/dev/null; then
        echo "intel"
        return 0
    fi
    if command -v intel_gpu_top &>/dev/null; then
        echo "intel"
        return 0
    fi
    # Check for Intel discrete/integrated GPU via lspci
    if lspci 2>/dev/null | grep -qi "intel.*\(arc\|iris\|uhd\|graphics\)"; then
        # Only report intel if the compute runtime is available
        if [ -d /dev/dri ] && ls /dev/dri/renderD* &>/dev/null; then
            # Check if intel_extension_for_pytorch could work
            if dpkg -l 2>/dev/null | grep -qi "intel-level-zero" || \
               pacman -Q 2>/dev/null | grep -qi "level-zero" || \
               rpm -qa 2>/dev/null | grep -qi "level-zero"; then
                echo "intel"
                return 0
            fi
        fi
    fi

    # 4. Fallback: CPU only
    echo "cpu"
    return 0
}

# Run detection
GPU_TYPE=$(detect_gpu)
echo "$GPU_TYPE"


path: scripts/health_check.sh
content: #!/usr/bin/env bash
# health_check.sh — Check if the Qwen3-TTS server is alive
set -euo pipefail

PORT="${TTS_PORT:-8880}"
HOST="${TTS_HOST:-127.0.0.1}"
URL="http://${HOST}:${PORT}/health"

response=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "$URL" 2>/dev/null || echo "000")

case "$response" in
    200)
        echo "[OK] Server is healthy at $URL"
        exit 0
        ;;
    000)
        echo "[ERROR] Server is not reachable at $URL"
        exit 1
        ;;
    *)
        echo "[ERROR] Server returned HTTP $response at $URL"
        exit 1
        ;;
esac


path: scripts/setup_env.sh
content: #!/usr/bin/env bash
# setup_env.sh — Create venv, detect GPU, install PyTorch + qwen-tts + all deps
set -euo pipefail

SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
VENV_DIR="$SKILL_DIR/.venv"
SCRIPTS_DIR="$SKILL_DIR/scripts"
REQ_DIR="$SKILL_DIR/requirements"

# ─── Colors ───
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

info()  { echo -e "${BLUE}[INFO]${NC}  $*"; }
ok()    { echo -e "${GREEN}[OK]${NC}    $*"; }
warn()  { echo -e "${YELLOW}[WARN]${NC}  $*"; }
err()   { echo -e "${RED}[ERROR]${NC} $*"; }

# ─── Detect distro ───
detect_distro() {
    if [ -f /etc/os-release ]; then
        . /etc/os-release
        echo "$ID"
    else
        echo "unknown"
    fi
}

# ─── Check system dependencies ───
check_system_deps() {
    local distro
    distro=$(detect_distro)
    local missing=()

    for cmd in python3 pip ffmpeg sox git; do
        if ! command -v "$cmd" &>/dev/null; then
            missing+=("$cmd")
        fi
    done

    if [ ${#missing[@]} -gt 0 ]; then
        err "Missing system dependencies: ${missing[*]}"
        echo ""
        case "$distro" in
            cachyos|arch|endeavouros|manjaro)
                echo "  Install with:"
                echo "    sudo pacman -S python python-pip ffmpeg sox git base-devel"
                ;;
            ubuntu|debian|linuxmint|pop)
                echo "  Install with:"
                echo "    sudo apt update && sudo apt install -y python3 python3-pip python3-venv ffmpeg sox git build-essential"
                ;;
            fedora|rhel|centos|rocky|alma)
                echo "  Install with:"
                echo "    sudo dnf install -y python3 python3-pip ffmpeg sox git gcc gcc-c++ make"
                ;;
            opensuse*|suse|sles)
                echo "  Install with:"
                echo "    sudo zypper install -y python3 python3-pip ffmpeg sox git gcc gcc-c++ make"
                ;;
            nixos)
                echo "  Add to configuration.nix or use nix-shell:"
                echo "    nix-shell -p python3 ffmpeg sox git"
                ;;
            *)
                echo "  Please install: python3, pip, ffmpeg, sox, git"
                ;;
        esac
        echo ""
        exit 1
    fi

    ok "System dependencies OK (python3, pip, ffmpeg, sox, git)"
}

# ─── Check disk space ───
check_disk_space() {
    local required_gb=10
    local available_kb
    available_kb=$(df "$SKILL_DIR" | tail -1 | awk '{print $4}')
    local available_gb=$((available_kb / 1024 / 1024))

    if [ "$available_gb" -lt "$required_gb" ]; then
        warn "Low disk space: ${available_gb}GB available, ${required_gb}GB recommended for models"
        read -rp "Continue anyway? [y/N] " yn
        case "$yn" in
            [yY]*) ;;
            *) exit 1 ;;
        esac
    else
        ok "Disk space OK (${available_gb}GB available)"
    fi
}

# ─── Detect GPU ───
detect_gpu() {
    local gpu_type
    gpu_type=$("$SCRIPTS_DIR/detect_gpu.sh")
    echo "$gpu_type"
}

# ─── Create venv ───
create_venv() {
    if [ -d "$VENV_DIR" ]; then
        info "Virtual environment already exists at $VENV_DIR"
    else
        info "Creating virtual environment at $VENV_DIR ..."
        python3 -m venv "$VENV_DIR"
        ok "Virtual environment created"
    fi

    # shellcheck disable=SC1091
    source "$VENV_DIR/bin/activate"
    pip install --upgrade pip setuptools wheel --quiet
    ok "pip/setuptools/wheel upgraded"
}

# ─── Install PyTorch ───
install_pytorch() {
    local gpu_type="$1"
    info "Installing PyTorch for accelerator: $gpu_type"

    case "$gpu_type" in
        cuda)
            pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu128 --quiet
            ;;
        rocm)
            pip install torch torchaudio --index-url https://download.pytorch.org/whl/rocm6.3 --quiet
            ;;
        intel)
            pip install torch torchaudio intel-extension-for-pytorch --index-url https://download.pytorch.org/whl/xpu --quiet
            ;;
        cpu)
            pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --quiet
            ;;
        *)
            err "Unknown GPU type: $gpu_type"
            exit 1
            ;;
    esac

    ok "PyTorch installed for $gpu_type"
}

# ─── Install Python deps ───
install_requirements() {
    info "Installing base Python dependencies..."
    pip install -r "$REQ_DIR/base.txt" --quiet
    ok "Base dependencies installed"
}

# ─── Verify installation ───
verify_install() {
    info "Verifying installation..."

    python3 -c "
import torch
print(f'  PyTorch version: {torch.__version__}')
if torch.cuda.is_available():
    print(f'  CUDA available: {torch.cuda.get_device_name(0)}')
elif hasattr(torch, \"xpu\") and torch.xpu.is_available():
    print(f'  Intel XPU available')
else:
    print(f'  Running on CPU')
"

    python3 -c "import qwen_tts; print(f'  qwen-tts version: {qwen_tts.__version__}')" 2>/dev/null || \
        warn "qwen-tts import check skipped (may need first-run model download)"

    ok "Installation verified"
}

# ─── Save hardware config ───
save_config() {
    local gpu_type="$1"
    local config_file="$SKILL_DIR/config.json"

    if [ ! -f "$config_file" ]; then
        cat > "$config_file" <<EOF
{
  "server": {
    "host": "127.0.0.1",
    "port": 8880,
    "workers": 1
  },
  "models": {
    "default_model": "custom-voice-1.7b",
    "cache_dir": "$SKILL_DIR/models/",
    "auto_download": true,
    "device": "auto"
  },
  "audio": {
    "sample_rate": 24000,
    "default_format": "wav",
    "output_dir": "$SKILL_DIR/output/"
  },
  "voices": {
    "dir": "$SKILL_DIR/voices/"
  },
  "telegram": {
    "bot_token": "",
    "default_chat_id": ""
  },
  "whatsapp": {
    "phone_number_id": "",
    "access_token": ""
  },
  "hardware": {
    "detected": "$gpu_type",
    "override": null
  }
}
EOF
        ok "Config saved to $config_file"
    else
        info "Config already exists, skipping"
    fi
}

# ═══════════════════════════════════════════════
#  MAIN
# ═══════════════════════════════════════════════
main() {
    echo ""
    echo "╔══════════════════════════════════════════════╗"
    echo "║   🎤 Qwen3-TTS OpenClaw Skill — Setup       ║"
    echo "║   Author: daMustermann · Version: 1.0       ║"
    echo "╚══════════════════════════════════════════════╝"
    echo ""

    # Step 1: Check system deps
    check_system_deps

    # Step 2: Check disk space
    check_disk_space

    # Step 3: Detect GPU
    info "Detecting GPU hardware..."
    GPU_TYPE=$(detect_gpu)
    ok "Detected accelerator: $GPU_TYPE"

    # Step 4: Create venv
    create_venv

    # Step 5: Install PyTorch
    install_pytorch "$GPU_TYPE"

    # Step 6: Install requirements
    install_requirements

    # Step 7: Verify
    verify_install

    # Step 8: Save config
    save_config "$GPU_TYPE"

    echo ""
    echo "╔══════════════════════════════════════════════╗"
    echo "║   ✅ Setup complete!                         ║"
    echo "║   GPU: $GPU_TYPE                             "
    echo "║   Start server: bash scripts/start_server.sh ║"
    echo "╚══════════════════════════════════════════════╝"
    echo ""
}

main "$@"


path: scripts/start_server.sh
content: #!/usr/bin/env bash
# start_server.sh — Launch the Qwen3-TTS FastAPI server
set -euo pipefail

SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
VENV="$SKILL_DIR/.venv"
PIDFILE="$SKILL_DIR/.server.pid"
LOGFILE="$SKILL_DIR/server.log"
PORT="${TTS_PORT:-8880}"
HOST="${TTS_HOST:-127.0.0.1}"

# Check venv exists
if [ ! -d "$VENV" ]; then
    echo "[ERROR] Virtual environment not found. Run setup_env.sh first."
    exit 1
fi

# Check if already running
if [ -f "$PIDFILE" ]; then
    PID=$(cat "$PIDFILE")
    if kill -0 "$PID" 2>/dev/null; then
        echo "[INFO] Server already running (PID $PID) on $HOST:$PORT"
        exit 0
    else
        echo "[WARN] Stale PID file found, removing..."
        rm -f "$PIDFILE"
    fi
fi

# Activate venv and start
source "$VENV/bin/activate"
cd "$SKILL_DIR/server"

echo "[INFO] Starting Qwen3-TTS server on $HOST:$PORT ..."
nohup python -m uvicorn tts_server:app \
    --host "$HOST" \
    --port "$PORT" \
    --log-level info \
    > "$LOGFILE" 2>&1 &

echo $! > "$PIDFILE"
echo "[OK] Server started (PID $!) — logs at $LOGFILE"

# Wait briefly and check it's actually running
sleep 2
if kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
    echo "[OK] Server is running"
else
    echo "[ERROR] Server failed to start. Check $LOGFILE"
    rm -f "$PIDFILE"
    exit 1
fi


path: scripts/stop_server.sh
content: #!/usr/bin/env bash
# stop_server.sh — Gracefully stop the Qwen3-TTS server
set -euo pipefail

SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PIDFILE="$SKILL_DIR/.server.pid"

if [ ! -f "$PIDFILE" ]; then
    echo "[INFO] No server running (no PID file)"
    exit 0
fi

PID=$(cat "$PIDFILE")

if kill -0 "$PID" 2>/dev/null; then
    echo "[INFO] Stopping server (PID $PID)..."
    kill "$PID"

    # Wait up to 10 seconds for graceful shutdown
    for i in $(seq 1 10); do
        if ! kill -0 "$PID" 2>/dev/null; then
            break
        fi
        sleep 1
    done

    # Force kill if still running
    if kill -0 "$PID" 2>/dev/null; then
        echo "[WARN] Server did not stop gracefully, force killing..."
        kill -9 "$PID" 2>/dev/null || true
    fi

    rm -f "$PIDFILE"
    echo "[OK] Server stopped"
else
    echo "[INFO] Server not running (stale PID file)"
    rm -f "$PIDFILE"
fi


path: server/audio_converter.py
content: """
audio_converter.py — Audio format conversion utilities.

Supports: WAV, MP3, OGG/Opus, FLAC
Uses ffmpeg for conversion and pydub for audio manipulation.
"""

import os
import subprocess
import tempfile
from pathlib import Path
from typing import Optional


SUPPORTED_FORMATS = {"wav", "mp3", "ogg", "flac"}

# ffmpeg codec mapping
CODEC_MAP = {
    "wav": {"codec": "pcm_s16le", "extra": []},
    "mp3": {"codec": "libmp3lame", "extra": ["-b:a", "192k"]},
    "ogg": {"codec": "libopus", "extra": ["-b:a", "64k", "-vbr", "on", "-application", "voip"]},
    "flac": {"codec": "flac", "extra": []},
}


def convert_audio(
    input_path: str,
    output_path: str,
    target_format: Optional[str] = None,
    sample_rate: int = 24000,
) -> str:
    """
    Convert an audio file to the specified format.

    Args:
        input_path: Path to the input audio file
        output_path: Path for the output file
        target_format: Target format (wav, mp3, ogg, flac). If None, inferred from output_path.
        sample_rate: Output sample rate (default 24000)

    Returns:
        Path to the output file

    Raises:
        ValueError: If the target format is unsupported
        FileNotFoundError: If the input file doesn't exist
        RuntimeError: If ffmpeg conversion fails
    """
    if not os.path.exists(input_path):
        raise FileNotFoundError(f"Input file not found: {input_path}")

    if target_format is None:
        target_format = Path(output_path).suffix.lstrip(".")

    target_format = target_format.lower()
    if target_format not in SUPPORTED_FORMATS:
        raise ValueError(
            f"Unsupported format: {target_format}. "
            f"Supported: {', '.join(sorted(SUPPORTED_FORMATS))}"
        )

    codec_info = CODEC_MAP[target_format]

    cmd = [
        "ffmpeg", "-y",
        "-i", input_path,
        "-c:a", codec_info["codec"],
        "-ar", str(sample_rate),
        *codec_info["extra"],
        output_path,
    ]

    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=120,
        )
        if result.returncode != 0:
            raise RuntimeError(f"ffmpeg failed: {result.stderr}")
    except FileNotFoundError:
        raise RuntimeError("ffmpeg is not installed or not in PATH")

    if not os.path.exists(output_path):
        raise RuntimeError("ffmpeg did not produce output file")

    return output_path


def convert_to_ogg_opus(
    input_path: str,
    output_path: Optional[str] = None,
    bitrate: str = "64k",
) -> str:
    """
    Convert any audio to OGG/Opus format for Telegram/WhatsApp PTT.

    Args:
        input_path: Path to input audio
        output_path: Path for output OGG. If None, uses input path with .ogg extension
        bitrate: Opus bitrate (default 64k)

    Returns:
        Path to the OGG/Opus file
    """
    if output_path is None:
        output_path = str(Path(input_path).with_suffix(".ogg"))

    cmd = [
        "ffmpeg", "-y",
        "-i", input_path,
        "-c:a", "libopus",
        "-b:a", bitrate,
        "-vbr", "on",
        "-compression_level", "10",
        "-frame_duration", "20",
        "-application", "voip",
        output_path,
    ]

    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=120,
        )
        if result.returncode != 0:
            raise RuntimeError(f"ffmpeg OGG conversion failed: {result.stderr}")
    except FileNotFoundError:
        raise RuntimeError("ffmpeg is not installed or not in PATH")

    return output_path


def get_audio_duration(file_path: str) -> float:
    """Get duration of an audio file in seconds using ffprobe."""
    cmd = [
        "ffprobe",
        "-v", "quiet",
        "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1",
        file_path,
    ]

    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        return float(result.stdout.strip())
    except (subprocess.TimeoutExpired, ValueError, FileNotFoundError):
        return 0.0


def get_audio_info(file_path: str) -> dict:
    """Get audio file information (format, duration, sample rate)."""
    cmd = [
        "ffprobe",
        "-v", "quiet",
        "-show_entries", "format=duration,format_name,bit_rate:stream=sample_rate,channels,codec_name",
        "-of", "json",
        file_path,
    ]

    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        import json
        data = json.loads(result.stdout)
        return {
            "duration": float(data.get("format", {}).get("duration", 0)),
            "format": data.get("format", {}).get("format_name", "unknown"),
            "bit_rate": data.get("format", {}).get("bit_rate", "unknown"),
            "sample_rate": data.get("streams", [{}])[0].get("sample_rate", "unknown"),
            "channels": data.get("streams", [{}])[0].get("channels", 0),
            "codec": data.get("streams", [{}])[0].get("codec_name", "unknown"),
        }
    except (subprocess.TimeoutExpired, ValueError, FileNotFoundError):
        return {"error": "Could not read audio info"}


path: server/messaging/telegram_sender.py
content: """
telegram_sender.py — Send audio as Telegram PTT voice messages.

Uses the Telegram Bot API's sendVoice method to deliver audio
as native inline voice messages (with waveform visualization).

Requirements:
    - Audio must be OGG/Opus format for PTT display
    - Bot token + chat_id required
"""

import os
from pathlib import Path
from typing import Optional

import httpx

from audio_converter import convert_to_ogg_opus


async def send_voice_message(
    audio_path: str,
    bot_token: str,
    chat_id: str,
    caption: Optional[str] = None,
    duration: Optional[int] = None,
) -> dict:
    """
    Send an audio file as a Telegram voice message (PTT).

    The audio is automatically converted to OGG/Opus if not already
    in that format, then sent via the Telegram Bot API sendVoice method.

    Args:
        audio_path: Path to the audio file
        bot_token: Telegram Bot API token
        chat_id: Target chat ID
        caption: Optional caption text
        duration: Optional duration in seconds

    Returns:
        dict with Telegram API response

    Raises:
        FileNotFoundError: If audio file doesn't exist
        RuntimeError: If sending fails
    """
    if not os.path.exists(audio_path):
        raise FileNotFoundError(f"Audio file not found: {audio_path}")

    if not bot_token:
        raise ValueError("Telegram bot_token is required")

    if not chat_id:
        raise ValueError("Telegram chat_id is required")

    # Convert to OGG/Opus if needed
    ogg_path = audio_path
    if not audio_path.lower().endswith(".ogg"):
        ogg_path = str(Path(audio_path).with_suffix(".ogg"))
        convert_to_ogg_opus(audio_path, ogg_path)

    # Send via Telegram Bot API
    url = f"https://api.telegram.org/bot{bot_token}/sendVoice"

    async with httpx.AsyncClient(timeout=60) as client:
        with open(ogg_path, "rb") as f:
            data = {"chat_id": chat_id}
            if caption:
                data["caption"] = caption
            if duration:
                data["duration"] = str(duration)

            files = {"voice": (Path(ogg_path).name, f, "audio/ogg")}

            response = await client.post(url, data=data, files=files)

    # Clean up converted file if we created one
    if ogg_path != audio_path and os.path.exists(ogg_path):
        os.remove(ogg_path)

    if response.status_code != 200:
        error_data = response.json() if response.headers.get("content-type", "").startswith("application/json") else {}
        raise RuntimeError(
            f"Telegram API error (HTTP {response.status_code}): "
            f"{error_data.get('description', response.text)}"
        )

    return response.json()


path: server/messaging/whatsapp_sender.py
content: """
whatsapp_sender.py — Send audio as WhatsApp PTT voice messages.

Uses the WhatsApp Business API (Meta Graph API) to upload audio
and send it as a native voice message.

Requirements:
    - Audio must be OGG/Opus format for voice message display
    - WhatsApp Business API access (phone_number_id + access_token)
"""

import os
from pathlib import Path
from typing import Optional

import httpx

from audio_converter import convert_to_ogg_opus


GRAPH_API_BASE = "https://graph.facebook.com/v21.0"


async def send_voice_message(
    audio_path: str,
    phone_number_id: str,
    recipient: str,
    access_token: str,
) -> dict:
    """
    Send an audio file as a WhatsApp voice message (PTT).

    Steps:
        1. Convert audio to OGG/Opus if needed
        2. Upload media to WhatsApp Business API
        3. Send audio message with the media ID

    Args:
        audio_path: Path to the audio file
        phone_number_id: WhatsApp Business phone number ID
        recipient: Recipient phone number (E.164 format, e.g. "+14155551234")
        access_token: Meta Graph API access token

    Returns:
        dict with WhatsApp API response

    Raises:
        FileNotFoundError: If audio file doesn't exist
        RuntimeError: If upload or sending fails
    """
    if not os.path.exists(audio_path):
        raise FileNotFoundError(f"Audio file not found: {audio_path}")

    if not phone_number_id:
        raise ValueError("WhatsApp phone_number_id is required")
    if not recipient:
        raise ValueError("Recipient phone number is required")
    if not access_token:
        raise ValueError("WhatsApp access_token is required")

    # Normalize recipient (strip spaces, ensure no leading 00)
    recipient = recipient.strip().replace(" ", "")
    if recipient.startswith("00"):
        recipient = "+" + recipient[2:]

    # Convert to OGG/Opus if needed
    ogg_path = audio_path
    if not audio_path.lower().endswith(".ogg"):
        ogg_path = str(Path(audio_path).with_suffix(".ogg"))
        convert_to_ogg_opus(audio_path, ogg_path)

    headers = {"Authorization": f"Bearer {access_token}"}

    async with httpx.AsyncClient(timeout=60) as client:
        # Step 1: Upload media
        upload_url = f"{GRAPH_API_BASE}/{phone_number_id}/media"

        with open(ogg_path, "rb") as f:
            upload_response = await client.post(
                upload_url,
                headers=headers,
                data={"messaging_product": "whatsapp", "type": "audio/ogg; codecs=opus"},
                files={"file": (Path(ogg_path).name, f, "audio/ogg; codecs=opus")},
            )

        if upload_response.status_code != 200:
            raise RuntimeError(
                f"WhatsApp media upload failed (HTTP {upload_response.status_code}): "
                f"{upload_response.text}"
            )

        media_id = upload_response.json().get("id")
        if not media_id:
            raise RuntimeError("WhatsApp media upload did not return a media ID")

        # Step 2: Send audio message
        send_url = f"{GRAPH_API_BASE}/{phone_number_id}/messages"

        message_data = {
            "messaging_product": "whatsapp",
            "to": recipient,
            "type": "audio",
            "audio": {"id": media_id},
        }

        send_response = await client.post(
            send_url,
            headers={**headers, "Content-Type": "application/json"},
            json=message_data,
        )

    # Clean up converted file if we created one
    if ogg_path != audio_path and os.path.exists(ogg_path):
        os.remove(ogg_path)

    if send_response.status_code not in (200, 201):
        raise RuntimeError(
            f"WhatsApp send failed (HTTP {send_response.status_code}): "
            f"{send_response.text}"
        )

    return send_response.json()


path: server/model_loader.py
content: """
model_loader.py — Download and fix Qwen3-TTS models before loading.

Handles the known issue where speech_tokenizer/preprocessor_config.json
is missing from the cached model directory. Pre-downloads all files via
huggingface_hub.snapshot_download, then symlinks the missing configs.
"""

import os
from pathlib import Path
from typing import Optional

import torch


def get_device(override: Optional[str] = None) -> str:
    if override and override != "auto":
        return override
    if torch.cuda.is_available():
        return "cuda:0"
    if hasattr(torch, "xpu") and torch.xpu.is_available():
        return "xpu:0"
    return "cpu"


def get_attn_impl() -> str:
    try:
        import flash_attn
        return "flash_attention_2"
    except ImportError:
        return "sdpa"


def download_and_fix_model(model_id: str, cache_dir: Optional[str] = None) -> str:
    """
    Download a Qwen3-TTS model and fix the speech_tokenizer directory structure.

    Returns the local path to the fixed model directory.
    """
    from huggingface_hub import snapshot_download

    print(f"[INFO] Downloading model {model_id}...")

    kwargs = {}
    if cache_dir:
        kwargs["cache_dir"] = cache_dir

    local_dir = snapshot_download(model_id, **kwargs)
    print(f"[OK]   Model downloaded to {local_dir}")

    # Fix: ensure speech_tokenizer has preprocessor_config.json
    speech_tokenizer_dir = Path(local_dir) / "speech_tokenizer"
    if speech_tokenizer_dir.is_dir():
        _fix_speech_tokenizer(local_dir, speech_tokenizer_dir)

    return local_dir


def _fix_speech_tokenizer(model_dir: str, speech_tokenizer_dir: Path):
    """
    Fix the known issue where preprocessor_config.json and config.json
    are missing from the speech_tokenizer subfolder.
    """
    model_path = Path(model_dir)

    for filename in ["preprocessor_config.json", "config.json"]:
        target = speech_tokenizer_dir / filename
        source = model_path / filename

        if target.exists():
            continue  # Already present, nothing to do

        if source.exists():
            try:
                os.symlink(source, target)
                print(f"[FIX]  Symlinked {filename} → speech_tokenizer/{filename}")
            except OSError:
                # Symlinks may fail on some filesystems, copy instead
                import shutil
                shutil.copy2(source, target)
                print(f"[FIX]  Copied {filename} → speech_tokenizer/{filename}")
        else:
            print(f"[WARN] {filename} not found in model root either — skipping")


def load_model(model_id: str, cache_dir: Optional[str] = None, device: str = "auto"):
    """
    Download (if needed), fix, and load a Qwen3-TTS model.

    Returns a ready-to-use Qwen3TTSModel instance.
    """
    from qwen_tts import Qwen3TTSModel

    # Step 1: Download and fix directory structure
    local_path = download_and_fix_model(model_id, cache_dir)

    # Step 2: Load from local fixed path
    resolved_device = get_device(device)
    attn_impl = get_attn_impl()

    print(f"[INFO] Loading model from {local_path} on {resolved_device} with {attn_impl}")

    model = Qwen3TTSModel.from_pretrained(
        local_path,
        device_map=resolved_device,
        dtype=torch.bfloat16,
        attn_implementation=attn_impl,
    )

    print(f"[OK]   Model loaded successfully")
    return model


path: server/tts_server.py
content: """
tts_server.py — Qwen3-TTS FastAPI Server (OpenAI-compatible).

Provides a comprehensive TTS API with:
  - OpenAI-compatible /v1/audio/speech endpoint
  - Voice design from natural language descriptions
  - Voice cloning from reference audio
  - Persistent named voice management (save/load/list/rename/delete)
  - Audio format conversion
  - Telegram & WhatsApp PTT voice message delivery

Uses the official qwen_tts.Qwen3TTSModel API.

Author: daMustermann · Version: 1.0
"""

import json
import os
import sys
import tempfile
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional, Any

import soundfile as sf
import torch
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field

# Add server directory to path for local imports
sys.path.insert(0, str(Path(__file__).parent))

from audio_converter import convert_audio, convert_to_ogg_opus, get_audio_info
from model_loader import load_model, download_and_fix_model
from voice_cloner import VoiceCloner
from voice_designer import VoiceDesigner
from voice_manager import VoiceManager

# ─── Configuration ───
SKILL_DIR = Path(__file__).parent.parent
CONFIG_PATH = SKILL_DIR / "config.json"


def load_config() -> dict:
    """Load configuration from config.json."""
    if CONFIG_PATH.exists():
        with open(CONFIG_PATH, "r") as f:
            return json.load(f)
    return {
        "models": {
            "default_model": "custom-voice-1.7b",
            "cache_dir": str(SKILL_DIR / "models"),
            "auto_download": True,
            "device": "auto",
        },
        "audio": {
            "sample_rate": 24000,
            "default_format": "wav",
            "output_dir": str(SKILL_DIR / "output"),
        },
        "voices": {
            "dir": str(SKILL_DIR / "voices"),
        },
    }


# ─── Model registry ───
MODELS = {
    "custom-voice-0.6b": "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice",
    "custom-voice-1.7b": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
    "base-0.6b": "Qwen/Qwen3-TTS-12Hz-0.6B-Base",
    "base-1.7b": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
    "voice-design": "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
}

# Models that support generate_custom_voice (built-in speakers)
CUSTOM_VOICE_MODELS = {"custom-voice-0.6b", "custom-voice-1.7b"}

# Auto-correction: if user requests a base/design model for speech, swap to custom-voice
MODEL_AUTO_CORRECT = {
    "base-0.6b": "custom-voice-0.6b",
    "base-1.7b": "custom-voice-1.7b",
    "voice-design": "custom-voice-1.7b",
}


# ─── Language mapping (OpenAI-compatible short codes → Qwen3-TTS full names) ───
LANG_MAP = {
    "en": "English", "zh": "Chinese", "ja": "Japanese", "ko": "Korean",
    "de": "German", "fr": "French", "ru": "Russian", "pt": "Portuguese",
    "es": "Spanish", "it": "Italian", "auto": "Auto",
    # Also accept full names directly
    "english": "English", "chinese": "Chinese", "japanese": "Japanese",
    "korean": "Korean", "german": "German", "french": "French",
    "russian": "Russian", "portuguese": "Portuguese", "spanish": "Spanish",
    "italian": "Italian",
}


def normalize_language(lang: str) -> str:
    """Convert short language codes to Qwen3-TTS format."""
    return LANG_MAP.get(lang.lower(), lang)


# ─── Device detection ───
def get_device(override: Optional[str] = None) -> str:
    if override and override != "auto":
        return override
    if torch.cuda.is_available():
        return "cuda:0"
    if hasattr(torch, "xpu") and torch.xpu.is_available():
        return "xpu:0"
    return "cpu"


def get_attn_impl() -> str:
    try:
        import flash_attn
        return "flash_attention_2"
    except ImportError:
        return "sdpa"


# ─── Globals (initialized at startup) ───
config: dict = {}
voice_manager: Optional[VoiceManager] = None
voice_designer: Optional[VoiceDesigner] = None
voice_cloner: Optional[VoiceCloner] = None
tts_model = None
tts_model_id: Optional[str] = None
output_dir: Path = SKILL_DIR / "output"

# Temporary storage for voice data from recent design/clone operations
# Maps voice_id -> { "ref_audio_path": str, "ref_text": str, "audio_data": ndarray, "sample_rate": int }
_temp_voice_store: dict[str, dict] = {}


@asynccontextmanager
async def lifespan(app: FastAPI):
    """Initialize resources at startup."""
    global config, voice_manager, output_dir

    config = load_config()

    # Initialize voice manager
    voices_dir = config.get("voices", {}).get("dir", str(SKILL_DIR / "voices"))
    voice_manager = VoiceManager(voices_dir)

    # Ensure output directory exists
    output_dir = Path(config.get("audio", {}).get("output_dir", str(SKILL_DIR / "output")))
    output_dir.mkdir(parents=True, exist_ok=True)

    device = get_device(config.get("models", {}).get("device"))
    attn = get_attn_impl()
    print(f"[INFO] Qwen3-TTS server starting on device: {device}, attention: {attn}")
    print(f"[INFO] Voices directory: {voices_dir}")
    print(f"[INFO] Output directory: {output_dir}")
    print(f"[INFO] Loaded {len(voice_manager.list_voices())} saved voices")

    yield

    # Cleanup
    if voice_designer:
        voice_designer.unload()
    if voice_cloner:
        voice_cloner.unload()
    _temp_voice_store.clear()


app = FastAPI(
    title="Qwen3-TTS OpenClaw Skill",
    description="High-quality TTS with voice design, voice cloning, and named voice persistence.",
    version="1.0",
    lifespan=lifespan,
)


# ═══════════════════════════════════════════════
#  Request/Response Models
# ═══════════════════════════════════════════════

class SpeechRequest(BaseModel):
    model: str = "custom-voice-1.7b"
    input: str
    voice: str = "default"
    speaker: str = "Chelsie"
    language: str = "en"
    instruct: str = ""
    response_format: str = "wav"
    speed: float = 1.0


class VoiceDesignRequest(BaseModel):
    model: str = "voice-design"
    input: str
    voice_description: str
    language: str = "en"
    response_format: str = "wav"


class VoiceSaveRequest(BaseModel):
    name: str
    source_voice_id: Optional[str] = None
    description: str = ""
    tags: list[str] = Field(default_factory=list)
    language: str = "en"


class VoiceUpdateRequest(BaseModel):
    name: Optional[str] = None
    description: Optional[str] = None
    tags: Optional[list[str]] = None
    language: Optional[str] = None


class TelegramSendRequest(BaseModel):
    audio_file: str
    chat_id: str
    bot_token: Optional[str] = None
    caption: Optional[str] = None


class WhatsAppSendRequest(BaseModel):
    audio_file: str
    phone_number_id: Optional[str] = None
    recipient: str
    access_token: Optional[str] = None


# ═══════════════════════════════════════════════
#  Helper Functions
# ═══════════════════════════════════════════════

def _get_tts_model(model_name: str = "custom-voice-1.7b"):
    """Lazy-load a TTS model. Pre-downloads and fixes speech_tokenizer if needed."""
    global tts_model, tts_model_id

    if tts_model is not None and tts_model_id == model_name:
        return tts_model

    model_hf_id = MODELS.get(model_name)
    if not model_hf_id:
        raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}. Available: {list(MODELS.keys())}")

    try:
        cache_dir = config.get("models", {}).get("cache_dir")
        device = get_device(config.get("models", {}).get("device"))

        print(f"[INFO] Loading model {model_name} ({model_hf_id})")
        tts_model = load_model(model_hf_id, cache_dir=cache_dir, device=device)
        tts_model_id = model_name
        print(f"[OK] Model {model_name} ready")
        return tts_model

    except ImportError:
        raise HTTPException(
            status_code=500,
            detail="qwen-tts package not installed. Run: pip install qwen-tts",
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Failed to load model: {e}")


def _get_voice_designer():
    """Lazy-load the voice designer."""
    global voice_designer
    if voice_designer is None:
        cache_dir = config.get("models", {}).get("cache_dir")
        device = get_device(config.get("models", {}).get("device"))
        voice_designer = VoiceDesigner(cache_dir=cache_dir, device=device)
    return voice_designer


def _get_voice_cloner():
    """Lazy-load the voice cloner."""
    global voice_cloner
    if voice_cloner is None:
        cache_dir = config.get("models", {}).get("cache_dir")
        device = get_device(config.get("models", {}).get("device"))
        voice_cloner = VoiceCloner(cache_dir=cache_dir, device=device)
    return voice_cloner


def _generate_output_path(prefix: str = "speech", fmt: str = "wav") -> str:
    """Generate a unique output file path."""
    filename = f"{prefix}_{uuid.uuid4().hex[:8]}.{fmt}"
    return str(output_dir / filename)


def _convert_if_needed(audio_path: str, target_format: str) -> str:
    """Convert audio to target format if different."""
    if target_format == "wav":
        return audio_path
    output_path = str(Path(audio_path).with_suffix(f".{target_format}"))
    return convert_audio(audio_path, output_path, target_format)


# ═══════════════════════════════════════════════
#  Health Check
# ═══════════════════════════════════════════════

@app.get("/health")
async def health_check():
    device = get_device(config.get("models", {}).get("device"))
    voices = voice_manager.list_voices() if voice_manager else []
    return {
        "status": "ok",
        "device": device,
        "attn_implementation": get_attn_impl(),
        "cuda_available": torch.cuda.is_available(),
        "saved_voices": len(voices),
        "version": "1.0",
    }


# ═══════════════════════════════════════════════
#  Speech Generation (OpenAI-compatible)
# ═══════════════════════════════════════════════

@app.post("/v1/audio/speech")
async def generate_speech(request: SpeechRequest):
    """
    Generate speech from text. OpenAI-compatible endpoint.

    Uses the CustomVoice model with built-in speakers (Chelsie, Ethan, etc.)
    or a saved voice via the voice cloner.
    """
    language = normalize_language(request.language)
    output_path = _generate_output_path("speech", "wav")

    try:
        # Auto-correct model: base/design models don't support generate_custom_voice
        model_name = request.model
        if model_name in MODEL_AUTO_CORRECT:
            corrected = MODEL_AUTO_CORRECT[model_name]
            print(f"[INFO] Auto-correcting model {model_name} → {corrected} for speech endpoint")
            model_name = corrected

        # Check if voice is a saved profile → use voice cloner
        if request.voice != "default" and voice_manager and voice_manager.voice_exists(request.voice):
            cloner = _get_voice_cloner()

            # Load the saved voice's reference audio for cloning
            profile = voice_manager.get_voice(request.voice)
            if profile is None:
                raise HTTPException(status_code=404, detail=f"Voice '{request.voice}' not found")

            # Load the saved reference audio and create a clone prompt
            sample_path = voice_manager.voices_dir / profile.sample_audio if profile.sample_audio else None
            if sample_path and sample_path.exists():
                result = cloner.clone(
                    text=request.input,
                    reference_audio_path=str(sample_path),
                    reference_text="",
                    language=language,
                    output_path=output_path,
                )
                # Increment usage
                voice_manager.load_embedding(request.voice)
            else:
                raise HTTPException(
                    status_code=400,
                    detail=f"Voice '{request.voice}' has no saved sample audio for cloning.",
                )
        else:
            # Use CustomVoice model with built-in speakers
            model = _get_tts_model(model_name)

            wavs, sr = model.generate_custom_voice(
                text=request.input,
                language=language,
                speaker=request.speaker,
                instruct=request.instruct if request.instruct else None,
            )

            audio = wavs[0]
            sf.write(output_path, audio, sr)

        # Convert to requested format
        final_path = _convert_if_needed(output_path, request.response_format)

        return FileResponse(
            final_path,
            media_type=f"audio/{request.response_format}",
            filename=Path(final_path).name,
        )

    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Speech generation failed: {e}")


# ═══════════════════════════════════════════════
#  Voice Design
# ═══════════════════════════════════════════════

@app.post("/v1/audio/voice-design")
async def design_voice(request: VoiceDesignRequest):
    """Generate speech with a natural-language voice description."""
    designer = _get_voice_designer()
    language = normalize_language(request.language)
    output_path = _generate_output_path("vdesign", "wav")

    try:
        result = designer.generate(
            text=request.input,
            voice_description=request.voice_description,
            language=language,
            output_path=output_path,
        )

        # Store reference data for potential saving
        voice_id = result.get("voice_id", "")
        _temp_voice_store[voice_id] = {
            "audio_path": result["audio_path"],
            "audio_data": result.get("audio_data"),
            "sample_rate": result.get("sample_rate"),
            "ref_text": result.get("ref_text", request.input),
            "source": "voice-design",
            "description": request.voice_description,
        }

        # Convert to requested format
        final_path = _convert_if_needed(output_path, request.response_format)

        return FileResponse(
            final_path,
            media_type=f"audio/{request.response_format}",
            filename=Path(final_path).name,
            headers={"X-Voice-Id": voice_id},
        )

    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Voice design failed: {e}")


# ═══════════════════════════════════════════════
#  Voice Cloning
# ═══════════════════════════════════════════════

@app.post("/v1/audio/voice-clone")
async def clone_voice(
    input: str = Form(...),
    reference_audio: UploadFile = File(...),
    reference_text: str = Form(""),
    language: str = Form("en"),
    response_format: str = Form("wav"),
):
    """Clone a voice from reference audio and generate new speech."""
    cloner = _get_voice_cloner()
    lang = normalize_language(language)
    output_path = _generate_output_path("vclone", "wav")

    # Save uploaded reference audio to temp file
    ref_suffix = Path(reference_audio.filename or "ref.wav").suffix or ".wav"
    fd, ref_path = tempfile.mkstemp(suffix=ref_suffix)
    try:
        with os.fdopen(fd, "wb") as f:
            content = await reference_audio.read()
            f.write(content)

        result = cloner.clone(
            text=input,
            reference_audio_path=ref_path,
            reference_text=reference_text,
            language=lang,
            output_path=output_path,
        )

        # Store reference data for potential saving
        voice_id = result.get("voice_id", "")

        # Copy ref audio to a persistent temp location (don't delete yet)
        import shutil
        persist_ref_path = str(output_dir / f"ref_{voice_id}.wav")
        shutil.copy2(ref_path, persist_ref_path)

        _temp_voice_store[voice_id] = {
            "audio_path": result["audio_path"],
            "ref_audio_path": persist_ref_path,
            "ref_text": reference_text,
            "source": "voice-clone",
            "description": f"Cloned from uploaded audio",
        }

        # Convert to requested format
        final_path = _convert_if_needed(output_path, response_format)

        return FileResponse(
            final_path,
            media_type=f"audio/{response_format}",
            filename=Path(final_path).name,
            headers={"X-Voice-Id": voice_id},
        )

    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Voice cloning failed: {e}")
    finally:
        # Clean up temp reference file (we saved a copy)
        if os.path.exists(ref_path):
            os.remove(ref_path)


# ═══════════════════════════════════════════════
#  Voice Management (CRUD)
# ═══════════════════════════════════════════════

@app.get("/v1/voices")
async def list_voices():
    """List all saved voice profiles, sorted by usage count."""
    if not voice_manager:
        raise HTTPException(status_code=500, detail="Voice manager not initialized")
    return {"voices": voice_manager.list_voices()}


@app.post("/v1/voices")
async def save_voice(request: VoiceSaveRequest):
    """
    Save/persist a voice profile with a user-chosen name.

    The source_voice_id must match a voice_id from a recent voice-design
    or voice-clone operation.
    """
    if not voice_manager:
        raise HTTPException(status_code=500, detail="Voice manager not initialized")

    if voice_manager.voice_exists(request.name):
        raise HTTPException(
            status_code=409,
            detail=f"Voice '{request.name}' already exists. Choose a different name or delete the existing one.",
        )

    # Get stored voice data
    voice_data = None
    if request.source_voice_id:
        voice_data = _temp_voice_store.pop(request.source_voice_id, None)

    if voice_data is None:
        raise HTTPException(
            status_code=400,
            detail="No voice data found. Generate a voice first using voice-design or voice-clone.",
        )

    try:
        # Save the reference audio as the voice's sample
        sample_audio_path = voice_data.get("audio_path") or voice_data.get("ref_audio_path")

        # Create a dummy embedding (the actual voice is stored as sample audio for re-cloning)
        embedding = torch.zeros(1)  # Placeholder — the real data is the sample audio

        profile = voice_manager.save_voice(
            name=request.name,
            embedding=embedding,
            description=request.description or voice_data.get("description", ""),
            source=voice_data.get("source", "unknown"),
            source_description=voice_data.get("description", ""),
            language=request.language,
            tags=request.tags,
            sample_audio_path=sample_audio_path,
        )
        return {"status": "saved", "voice": profile.to_dict()}

    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))


@app.get("/v1/voices/{name}")
async def get_voice(name: str):
    """Get details for a specific voice profile."""
    if not voice_manager:
        raise HTTPException(status_code=500, detail="Voice manager not initialized")

    profile = voice_manager.get_voice(name)
    if profile is None:
        raise HTTPException(status_code=404, detail=f"Voice '{name}' not found")

    return {"voice": profile.to_dict()}


@app.patch("/v1/voices/{name}")
async def update_voice(name: str, request: VoiceUpdateRequest):
    """Rename or update a voice profile."""
    if not voice_manager:
        raise HTTPException(status_code=500, detail="Voice manager not initialized")

    profile = voice_manager.get_voice(name)
    if profile is None:
        raise HTTPException(status_code=404, detail=f"Voice '{name}' not found")

    # Handle rename
    if request.name and request.name != name:
        try:
            profile = voice_manager.rename_voice(name, request.name)
        except ValueError as e:
            raise HTTPException(status_code=409, detail=str(e))

    # Handle metadata updates
    updated = voice_manager.update_voice(
        request.name or name,
        description=request.description,
        tags=request.tags,
        language=request.language,
    )

    if updated:
        return {"status": "updated", "voice": updated.to_dict()}
    return {"status": "no changes"}


@app.delete("/v1/voices/{name}")
async def delete_voice(name: str):
    """Delete a voice profile and all associated files."""
    if not voice_manager:
        raise HTTPException(status_code=500, detail="Voice manager not initialized")

    if not voice_manager.delete_voice(name):
        raise HTTPException(status_code=404, detail=f"Voice '{name}' not found")

    return {"status": "deleted", "name": name}


# ═══════════════════════════════════════════════
#  Audio Conversion
# ═══════════════════════════════════════════════

@app.post("/v1/audio/convert")
async def convert_audio_format(
    audio: UploadFile = File(...),
    target_format: str = Form("ogg"),
):
    """Convert audio between formats (WAV, MP3, OGG/Opus, FLAC)."""
    in_suffix = Path(audio.filename or "input.wav").suffix or ".wav"
    fd, input_path = tempfile.mkstemp(suffix=in_suffix)
    try:
        with os.fdopen(fd, "wb") as f:
            content = await audio.read()
            f.write(content)

        output_path = _generate_output_path("converted", target_format)
        convert_audio(input_path, output_path, target_format)

        return FileResponse(
            output_path,
            media_type=f"audio/{target_format}",
            filename=Path(output_path).name,
        )

    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Conversion failed: {e}")
    finally:
        if os.path.exists(input_path):
            os.remove(input_path)


# ═══════════════════════════════════════════════
#  Telegram Integration
# ═══════════════════════════════════════════════

@app.post("/v1/audio/send/telegram")
async def send_telegram(request: TelegramSendRequest):
    """Send audio as a Telegram PTT voice message."""
    from messaging.telegram_sender import send_voice_message

    bot_token = request.bot_token or config.get("telegram", {}).get("bot_token", "")
    if not bot_token:
        raise HTTPException(
            status_code=400,
            detail="Telegram bot_token required (in request or config.json)",
        )

    audio_path = os.path.expanduser(request.audio_file)
    if not os.path.exists(audio_path):
        raise HTTPException(status_code=404, detail=f"Audio file not found: {audio_path}")

    try:
        result = await send_voice_message(
            audio_path=audio_path,
            bot_token=bot_token,
            chat_id=request.chat_id,
            caption=request.caption,
        )
        return {"status": "sent", "telegram_response": result}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Telegram send failed: {e}")


# ═══════════════════════════════════════════════
#  WhatsApp Integration
# ═══════════════════════════════════════════════

@app.post("/v1/audio/send/whatsapp")
async def send_whatsapp(request: WhatsAppSendRequest):
    """Send audio as a WhatsApp PTT voice message."""
    from messaging.whatsapp_sender import send_voice_message

    phone_number_id = request.phone_number_id or config.get("whatsapp", {}).get("phone_number_id", "")
    access_token = request.access_token or config.get("whatsapp", {}).get("access_token", "")

    if not phone_number_id or not access_token:
        raise HTTPException(
            status_code=400,
            detail="WhatsApp phone_number_id and access_token required (in request or config.json)",
        )

    audio_path = os.path.expanduser(request.audio_file)
    if not os.path.exists(audio_path):
        raise HTTPException(status_code=404, detail=f"Audio file not found: {audio_path}")

    try:
        result = await send_voice_message(
            audio_path=audio_path,
            phone_number_id=phone_number_id,
            recipient=request.recipient,
            access_token=access_token,
        )
        return {"status": "sent", "whatsapp_response": result}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"WhatsApp send failed: {e}")


# ═══════════════════════════════════════════════
#  Server Info
# ═══════════════════════════════════════════════

@app.get("/v1/models")
async def list_models():
    """List available TTS models."""
    return {
        "models": [
            {"id": key, "hf_id": val, "object": "model"}
            for key, val in MODELS.items()
        ]
    }


@app.get("/v1/speakers")
async def list_speakers():
    """List built-in speakers for CustomVoice models."""
    try:
        model = _get_tts_model()
        speakers = model.get_supported_speakers()
        return {"speakers": speakers}
    except Exception:
        # Fallback if model not loaded
        return {
            "speakers": [
                "Chelsie", "Ethan", "Aidan", "Serena", "Ryan",
                "Vivian", "Claire", "Lucas", "Eleanor", "Benjamin",
            ]
        }


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="127.0.0.1", port=8880)


path: server/voice_cloner.py
content: """
voice_cloner.py — Clone voices from reference audio.

Uses Qwen3-TTS-12Hz-1.7B-Base model to clone a voice from a reference
audio clip and generate new speech in that voice.

Supports reusable voice_clone_prompt for efficient multi-generation.
"""

import os
import tempfile
from pathlib import Path
from typing import Optional, Any

import torch
import soundfile as sf


class VoiceCloner:
    """
    Clones a voice from reference audio and generates new speech in that voice.

    Requirements:
        - Minimum 3 seconds of reference audio
        - Recommended 10-30 seconds for best quality
        - Accurate transcription of reference audio improves results
        - Supports cross-language cloning
    """

    def __init__(
        self,
        model_id: str = "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
        cache_dir: Optional[str] = None,
        device: str = "auto",
    ):
        self.model_id = model_id
        self.cache_dir = cache_dir
        self.device = self._resolve_device(device)
        self._model = None
        self._loaded = False

    def _resolve_device(self, device: str) -> str:
        if device != "auto":
            return device
        if torch.cuda.is_available():
            return "cuda:0"
        if hasattr(torch, "xpu") and torch.xpu.is_available():
            return "xpu:0"
        return "cpu"

    def _get_attn_impl(self) -> str:
        try:
            import flash_attn
            return "flash_attention_2"
        except ImportError:
            return "sdpa"

    def _ensure_loaded(self):
        """Lazy-load model on first use."""
        if self._loaded:
            return

        try:
            from model_loader import load_model

            print(f"[INFO] Loading Base/Clone model on {self.device}")
            self._model = load_model(
                self.model_id, cache_dir=self.cache_dir, device=self.device
            )
            self._loaded = True
            print("[OK] Base/Clone model loaded")

        except ImportError:
            raise RuntimeError(
                "qwen-tts package not installed. Run: pip install qwen-tts"
            )
        except Exception as e:
            raise RuntimeError(f"Failed to load voice cloning model: {e}")

    def clone(
        self,
        text: str,
        reference_audio_path: str,
        reference_text: str = "",
        language: str = "English",
        output_path: Optional[str] = None,
    ) -> dict:
        """
        Clone a voice from reference audio and generate new speech.

        Args:
            text: The new text to synthesize in the cloned voice
            reference_audio_path: Path to the reference audio file
            reference_text: Transcription of the reference audio (recommended)
            language: Target language (e.g. "English", "Chinese", "German")
            output_path: Where to save the output. If None, uses a temp file.

        Returns:
            dict with keys:
                - audio_path: Path to the generated audio
                - voice_id: Temporary voice ID for saving
                - voice_clone_prompt: Reusable prompt object for future generations
                - sample_rate: Audio sample rate
        """
        self._ensure_loaded()

        if not os.path.exists(reference_audio_path):
            raise FileNotFoundError(
                f"Reference audio not found: {reference_audio_path}"
            )

        if output_path is None:
            fd, output_path = tempfile.mkstemp(suffix=".wav")
            os.close(fd)

        try:
            # Build a reusable clone prompt
            voice_clone_prompt = self._model.create_voice_clone_prompt(
                ref_audio=reference_audio_path,
                ref_text=reference_text if reference_text else None,
                x_vector_only_mode=not bool(reference_text),
            )

            # Generate cloned speech
            wavs, sr = self._model.generate_voice_clone(
                text=text,
                language=language,
                voice_clone_prompt=voice_clone_prompt,
            )

            audio = wavs[0]
            sf.write(output_path, audio, sr)

            voice_id = f"vc_{hash(reference_audio_path) & 0xFFFFFFFF:08x}"

            return {
                "audio_path": output_path,
                "voice_id": voice_id,
                "voice_clone_prompt": voice_clone_prompt,
                "sample_rate": sr,
            }

        except Exception as e:
            raise RuntimeError(f"Voice cloning failed: {e}")

    def clone_from_prompt(
        self,
        text: str,
        voice_clone_prompt: Any,
        language: str = "English",
        output_path: Optional[str] = None,
    ) -> dict:
        """
        Generate speech using a pre-built voice_clone_prompt.

        This is more efficient for repeated use of the same voice,
        as it skips re-extracting features from reference audio.
        """
        self._ensure_loaded()

        if output_path is None:
            fd, output_path = tempfile.mkstemp(suffix=".wav")
            os.close(fd)

        wavs, sr = self._model.generate_voice_clone(
            text=text,
            language=language,
            voice_clone_prompt=voice_clone_prompt,
        )

        audio = wavs[0]
        sf.write(output_path, audio, sr)

        return {
            "audio_path": output_path,
            "sample_rate": sr,
        }

    def create_prompt_from_audio(
        self,
        ref_audio_path: str,
        ref_text: str = "",
    ) -> Any:
        """
        Create a reusable voice_clone_prompt from reference audio.
        """
        self._ensure_loaded()
        return self._model.create_voice_clone_prompt(
            ref_audio=ref_audio_path,
            ref_text=ref_text if ref_text else None,
            x_vector_only_mode=not bool(ref_text),
        )

    def create_prompt_from_audio_data(
        self,
        audio_data,
        sample_rate: int,
        ref_text: str = "",
    ) -> Any:
        """
        Create a reusable voice_clone_prompt from audio data (numpy array + sample rate).
        Used for the Voice Design → Clone pipeline.
        """
        self._ensure_loaded()
        return self._model.create_voice_clone_prompt(
            ref_audio=(audio_data, sample_rate),
            ref_text=ref_text if ref_text else None,
            x_vector_only_mode=not bool(ref_text),
        )

    def unload(self):
        """Unload models to free memory."""
        if self._model is not None:
            del self._model
        self._model = None
        self._loaded = False
        if torch.cuda.is_available():
            torch.cuda.empty_cache()


path: server/voice_designer.py
content: """
voice_designer.py — Create new voices from natural language descriptions.

Uses Qwen3-TTS-12Hz-1.7B-VoiceDesign model to generate speech with
a voice matching the given description.
"""

import os
import tempfile
from pathlib import Path
from typing import Optional

import torch
import soundfile as sf


class VoiceDesigner:
    """
    Generates speech using a natural language voice description.

    The voice-design model takes a text description (instruct) of the desired voice
    and produces speech in that style.
    """

    def __init__(self, model_id: str = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
                 cache_dir: Optional[str] = None, device: str = "auto"):
        self.model_id = model_id
        self.cache_dir = cache_dir
        self.device = self._resolve_device(device)
        self._model = None
        self._loaded = False

    def _resolve_device(self, device: str) -> str:
        if device != "auto":
            return device
        if torch.cuda.is_available():
            return "cuda:0"
        if hasattr(torch, "xpu") and torch.xpu.is_available():
            return "xpu:0"
        return "cpu"

    def _get_attn_impl(self) -> str:
        """Choose attention implementation based on flash-attn availability."""
        try:
            import flash_attn
            return "flash_attention_2"
        except ImportError:
            return "sdpa"

    def _ensure_loaded(self):
        """Lazy-load the model on first use."""
        if self._loaded:
            return

        try:
            from model_loader import load_model

            print(f"[INFO] Loading VoiceDesign model on {self.device}")
            self._model = load_model(
                self.model_id, cache_dir=self.cache_dir, device=self.device
            )
            self._loaded = True
            print("[OK] VoiceDesign model loaded")

        except ImportError:
            raise RuntimeError(
                "qwen-tts package not installed. Run: pip install qwen-tts"
            )
        except Exception as e:
            raise RuntimeError(f"Failed to load voice design model: {e}")

    def generate(
        self,
        text: str,
        voice_description: str,
        language: str = "English",
        output_path: Optional[str] = None,
    ) -> dict:
        """
        Generate speech with a designed voice.

        Args:
            text: The text to synthesize
            voice_description: Natural language description of the desired voice (instruct)
            language: Target language (e.g. "English", "Chinese", "German")
            output_path: Where to save the audio. If None, uses a temp file.

        Returns:
            dict with keys:
                - audio_path: Path to the generated audio file
                - voice_id: Temporary voice ID for saving
                - sample_rate: Audio sample rate
                - ref_text: The text that was synthesized (for clone prompt reuse)
        """
        self._ensure_loaded()

        if output_path is None:
            fd, output_path = tempfile.mkstemp(suffix=".wav")
            os.close(fd)

        try:
            wavs, sr = self._model.generate_voice_design(
                text=text,
                language=language,
                instruct=voice_description,
            )

            audio = wavs[0]
            sf.write(output_path, audio, sr)

            voice_id = f"vd_{hash(voice_description) & 0xFFFFFFFF:08x}"

            return {
                "audio_path": output_path,
                "audio_data": audio,
                "voice_id": voice_id,
                "sample_rate": sr,
                "ref_text": text,
            }

        except Exception as e:
            raise RuntimeError(f"Voice design generation failed: {e}")

    def unload(self):
        """Unload the model to free memory."""
        if self._model is not None:
            del self._model
            self._model = None
            self._loaded = False
            if torch.cuda.is_available():
                torch.cuda.empty_cache()


path: server/voice_manager.py
content: """
voice_manager.py — Persistent named voice profile management.

Each voice is stored as:
  - <name>.json  — metadata (name, description, source, timestamps, tags)
  - <name>.pt    — PyTorch speaker embedding tensor
  - <name>_sample.wav — optional sample audio

Voices are case-insensitive for lookup but preserve user casing on disk.
"""

import json
import os
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

import torch


class VoiceProfile:
    """Represents a single saved voice profile."""

    def __init__(
        self,
        name: str,
        description: str = "",
        source: str = "unknown",
        source_description: str = "",
        language: str = "en",
        tags: Optional[list[str]] = None,
        embedding_file: str = "",
        sample_audio: str = "",
        usage_count: int = 0,
        created_at: Optional[str] = None,
        updated_at: Optional[str] = None,
    ):
        now = datetime.now(timezone.utc).isoformat()
        self.name = name
        self.description = description
        self.source = source
        self.source_description = source_description
        self.language = language
        self.tags = tags or []
        self.embedding_file = embedding_file or f"{name}.pt"
        self.sample_audio = sample_audio
        self.usage_count = usage_count
        self.created_at = created_at or now
        self.updated_at = updated_at or now

    def to_dict(self) -> dict:
        return {
            "name": self.name,
            "description": self.description,
            "created_at": self.created_at,
            "updated_at": self.updated_at,
            "source": self.source,
            "source_description": self.source_description,
            "language": self.language,
            "tags": self.tags,
            "embedding_file": self.embedding_file,
            "sample_audio": self.sample_audio,
            "usage_count": self.usage_count,
        }

    @classmethod
    def from_dict(cls, data: dict) -> "VoiceProfile":
        return cls(**data)

    def touch(self):
        """Update the updated_at timestamp and increment usage count."""
        self.updated_at = datetime.now(timezone.utc).isoformat()
        self.usage_count += 1


class VoiceManager:
    """
    Manages persistent named voice profiles on disk.

    Voice directory structure:
        voices/
        ├── Angie.json
        ├── Angie.pt
        ├── Angie_sample.wav  (optional)
        ├── CaptainHook.json
        ├── CaptainHook.pt
        └── ...
    """

    def __init__(self, voices_dir: str):
        self.voices_dir = Path(voices_dir)
        self.voices_dir.mkdir(parents=True, exist_ok=True)
        self._cache: dict[str, VoiceProfile] = {}
        self._load_all()

    def _load_all(self):
        """Load all voice profiles from disk into cache."""
        self._cache.clear()
        for json_file in self.voices_dir.glob("*.json"):
            try:
                with open(json_file, "r", encoding="utf-8") as f:
                    data = json.load(f)
                profile = VoiceProfile.from_dict(data)
                self._cache[profile.name.lower()] = profile
            except (json.JSONDecodeError, KeyError, TypeError) as e:
                print(f"[WARN] Failed to load voice profile {json_file}: {e}")

    def _sanitize_filename(self, name: str) -> str:
        """Sanitize a voice name for use as a filename."""
        # Replace problematic chars but keep the name readable
        safe = "".join(c if c.isalnum() or c in "-_ " else "_" for c in name)
        return safe.strip().replace(" ", "_")

    def list_voices(self) -> list[dict]:
        """List all saved voice profiles."""
        return [profile.to_dict() for profile in sorted(
            self._cache.values(), key=lambda p: p.usage_count, reverse=True
        )]

    def get_voice(self, name: str) -> Optional[VoiceProfile]:
        """Get a voice profile by name (case-insensitive)."""
        return self._cache.get(name.lower())

    def voice_exists(self, name: str) -> bool:
        """Check if a voice name already exists."""
        return name.lower() in self._cache

    def save_voice(
        self,
        name: str,
        embedding: torch.Tensor,
        description: str = "",
        source: str = "unknown",
        source_description: str = "",
        language: str = "en",
        tags: Optional[list[str]] = None,
        sample_audio_path: Optional[str] = None,
    ) -> VoiceProfile:
        """
        Save a new voice profile with the given name.

        Args:
            name: User-chosen name for the voice
            embedding: Speaker embedding tensor
            description: Human-readable description
            source: "voice-design" or "voice-clone"
            source_description: The original voice description or reference info
            language: Primary language
            tags: Optional tags for categorization
            sample_audio_path: Optional path to a sample audio file

        Returns:
            The saved VoiceProfile

        Raises:
            ValueError: If name is empty or contains invalid characters
        """
        if not name or not name.strip():
            raise ValueError("Voice name cannot be empty")

        safe_name = self._sanitize_filename(name)
        embedding_filename = f"{safe_name}.pt"
        sample_filename = ""

        # Save embedding tensor
        embedding_path = self.voices_dir / embedding_filename
        torch.save(embedding, embedding_path)

        # Copy sample audio if provided
        if sample_audio_path and os.path.exists(sample_audio_path):
            sample_filename = f"{safe_name}_sample.wav"
            shutil.copy2(sample_audio_path, self.voices_dir / sample_filename)

        # Create profile
        profile = VoiceProfile(
            name=name,
            description=description,
            source=source,
            source_description=source_description,
            language=language,
            tags=tags,
            embedding_file=embedding_filename,
            sample_audio=sample_filename,
        )

        # Save metadata JSON
        json_path = self.voices_dir / f"{safe_name}.json"
        with open(json_path, "w", encoding="utf-8") as f:
            json.dump(profile.to_dict(), f, indent=2, ensure_ascii=False)

        # Update cache
        self._cache[name.lower()] = profile
        return profile

    def load_embedding(self, name: str) -> Optional[torch.Tensor]:
        """
        Load the speaker embedding tensor for a named voice.

        Also increments usage_count and updates timestamps.
        """
        profile = self.get_voice(name)
        if profile is None:
            return None

        embedding_path = self.voices_dir / profile.embedding_file
        if not embedding_path.exists():
            return None

        # Increment usage
        profile.touch()
        self._save_profile(profile)

        return torch.load(embedding_path, weights_only=True)

    def rename_voice(self, old_name: str, new_name: str) -> Optional[VoiceProfile]:
        """
        Rename a voice profile.

        Returns the updated profile, or None if the old name doesn't exist.
        Raises ValueError if the new name already exists.
        """
        profile = self.get_voice(old_name)
        if profile is None:
            return None

        if self.voice_exists(new_name) and new_name.lower() != old_name.lower():
            raise ValueError(f"Voice name '{new_name}' already exists")

        old_safe = self._sanitize_filename(profile.name)
        new_safe = self._sanitize_filename(new_name)

        # Rename files on disk
        for ext in [".json", ".pt", "_sample.wav"]:
            old_path = self.voices_dir / f"{old_safe}{ext}"
            new_path = self.voices_dir / f"{new_safe}{ext}"
            if old_path.exists():
                old_path.rename(new_path)

        # Update profile
        del self._cache[old_name.lower()]
        profile.name = new_name
        profile.embedding_file = f"{new_safe}.pt"
        if profile.sample_audio:
            profile.sample_audio = f"{new_safe}_sample.wav"
        profile.updated_at = datetime.now(timezone.utc).isoformat()

        # Save updated metadata
        self._cache[new_name.lower()] = profile
        self._save_profile(profile)

        return profile

    def delete_voice(self, name: str) -> bool:
        """
        Delete a voice profile and all associated files.

        Returns True if deleted, False if not found.
        """
        profile = self.get_voice(name)
        if profile is None:
            return False

        safe_name = self._sanitize_filename(profile.name)

        # Remove files
        for ext in [".json", ".pt", "_sample.wav"]:
            path = self.voices_dir / f"{safe_name}{ext}"
            if path.exists():
                path.unlink()

        # Remove from cache
        del self._cache[name.lower()]
        return True

    def update_voice(
        self,
        name: str,
        description: Optional[str] = None,
        tags: Optional[list[str]] = None,
        language: Optional[str] = None,
    ) -> Optional[VoiceProfile]:
        """Update metadata fields of an existing voice profile."""
        profile = self.get_voice(name)
        if profile is None:
            return None

        if description is not None:
            profile.description = description
        if tags is not None:
            profile.tags = tags
        if language is not None:
            profile.language = language

        profile.updated_at = datetime.now(timezone.utc).isoformat()
        self._save_profile(profile)
        return profile

    def _save_profile(self, profile: VoiceProfile):
        """Save a profile's metadata to disk."""
        safe_name = self._sanitize_filename(profile.name)
        json_path = self.voices_dir / f"{safe_name}.json"
        with open(json_path, "w", encoding="utf-8") as f:
            json.dump(profile.to_dict(), f, indent=2, ensure_ascii=False)


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