Skill: liberalchang/liber-speechapi (version 1.1.0)

--- name: liber-speechapi description: Use Liber SpeechAPI for three speech workflows: (1) Telegram/openclaw voice-message handling with ASR, concise reply summarization, and Telegram-compatible OGG/Opus TTS, (2) direct text-to-speech generation when the user explicitly asks to synthesize text into audio, and (3) direct speech-to-text transcription when the user explicitly asks to convert audio into text or structured JSON. Support environment-based configuration, optional voice cloning from a reference audio file, and fallback between shared python-env skill and the local Python environment. --- # Liber SpeechAPI Use this skill for three related tasks: - handle Telegram/openclaw voice-message workflows end to end - convert user-provided text to speech on demand - convert user-provided audio to text on demand ## Follow this workflow 1. Read `references/config.md` to resolve configuration from `.env` and `config.json`. 2. Read `references/workflow.md` for Telegram/openclaw voice-message handling. 3. Read `references/api.md` when you need endpoint and payload details. 4. Read `references/parameters.md` for detailed ASR/TTS parameter meanings and defaults. 5. Use `scripts/summarize_for_voice.py` only when a reply must be shortened for voice playback. 6. Use `scripts/liber_speech_client.py` for deterministic ASR/TTS calls instead of rewriting HTTP request logic. ## Environment selection Prefer a shared `python-env` skill if it is available in the current environment. If `python-env` is not available, use the local Python environment for this skill. When running local Python commands: - use Python 3.11 if available - allow Python 3.10 when 3.11 is unavailable - install only the minimal dependencies required by the bundled scripts - do not hardcode secrets; read them from `.env` ## Configuration model ### `.env` Load core service settings from `.env` in priority order: 1. environment variables (`LIBER_API_BASE_URL` and `LIBER_API_KEY`) 2. `~/.openclaw/.env` file (for global configuration) 3. the skill directory's `.env` file 4. the current working directory's `.env` file Environment variables take the highest priority, followed by the global config file `~/.openclaw/.env`, then local skill directory, and finally the current working directory. Required settings: - `LIBER_API_BASE_URL` - `LIBER_API_KEY` ### `config.json` Load detailed defaults from `speechapi_config.json` in `~/.openclaw/workspace/config/` to prevent overwrites during skill updates. Fallback to local `config.json` if the external config doesn't exist. Key behavior: - values of `"default"` or `null` are omitted from API requests - Telegram-specific voice replies use `global.telegram_tts_format` - direct text-to-speech uses `tts.format` as its default output format - direct speech-to-text uses `global.asr_output` as its default output mode ## Direct text-to-speech When the user explicitly asks to convert text to speech: 1. use `scripts/liber_speech_client.py tts` 2. default to `wav` unless the caller explicitly requests another format 3. include `audio_prompt` only when clone audio is enabled and the file exists 4. return the TTS result URL or saved output path to the caller ## Direct speech-to-text When the user explicitly asks to convert audio to text: 1. use `scripts/liber_speech_client.py asr` 2. default to structured `json` output 3. return plain text only when the caller explicitly wants transcript text only ## Telegram/openclaw workflow For incoming Telegram voice/audio: 1. download or access the local audio file 2. send it to ASR and extract the recognized `text` 3. send the transcript to openclaw 4. if the final reply is too long for voice, shorten it to within the configured summary limit 5. synthesize the final spoken reply with Telegram-compatible `ogg_opus` 6. return the resulting audio URL or saved output path to the caller ## Telegram-specific guidance For Telegram voice replies: - force `ogg_opus` output - keep spoken output concise and natural - if the original answer is verbose, preserve intent and key facts but compress aggressively - avoid reading markdown, code blocks, tables, or long lists verbatim ## Safety and robustness - never print or log API keys - validate input file existence before ASR - validate text is non-empty before TTS - use request timeouts - handle HTTP failures with clear error messages - if TTS clone audio is configured but missing, continue without cloning instead of failing - if summarization fails, fall back to conservative truncation rather than blocking the reply - default direct ASR output to JSON and default direct TTS output to WAV unless the caller requests otherwise ## Expected outputs Depending on the task, return one of: - structured ASR JSON - plain transcript text - concise voice-ready text - TTS result URL - saved audio file path - a structured JSON object containing transcript, summary, and synthesis result

path: .env
content: 
LIBER_API_BASE_URL=http://local-test:5555/api/v1
LIBER_API_KEY=local_test_key


path: README.md
content: # liber-speechapi 技能使用说明

## 功能和目的

`liber-speechapi` 是一个集成了ASR（自动语音识别）和TTS（文本转语音）功能的语音处理工具。该技能提供了：

- **ASR（语音转文字）**：基于 [insanely-fast-whisper](https://github.com/Vaibhavs10/insanely-fast-whisper)（Whisper），支持多语言、时间戳、说话人分离（diarization）
- **TTS（文字转语音）**：基于 [chatterbox-tts](https://github.com/resemble-ai/chatterbox)，支持 Turbo/Standard/Multilingual 模型、音色克隆
- **多输出格式**：
  - ASR：JSON、TXT、SRT、VTT
  - TTS：WAV、MP4、OGG/Opus（按 Telegram Voice Note 最佳实践）
- **部署灵活**：本地 CLI 命令 + 远程 FastAPI 服务
- **API 鉴权**：支持 API Key（Bearer Token）
- **设备自适应**：自动检测 CUDA，支持 CPU/GPU 切换

## 前置条件

### 部署后端要求

使用此技能前，必须先部署后端服务，具体要求如下：

- **Python 3.10+**
- **[uv](https://docs.astral.sh/uv/)**（Python 包管理器）
- **FFmpeg**（用于音频转码，TTS 多格式输出需要）
- **可选**：CUDA（GPU 加速）

### 配置优先级

环境配置按以下优先级顺序加载：

1. **环境变量**：`LIBER_API_BASE_URL` 和 `LIBER_API_KEY`（最高优先级）
2. **全局配置文件**：`~/.openclaw/.env`（用于跨项目共享配置）
3. **项目内配置**：技能目录下的 `.env` 文件
4. **当前工作目录**：当前工作目录下的 `.env` 文件（最低优先级）

这样可以灵活地在不同层级进行配置，满足全局、项目和个人需求。

### 配置文件存储

为了防止在更新技能时丢失用户配置，详细的配置参数存储在以下位置：

1. **首选位置**：`~/.openclaw/workspace/config/speechapi_config.json`（防止更新时被覆盖）
2. **备用位置**：本地的 `config.json`（用于兼容性）

### 部署步骤

1. 克隆项目：
   ```
   git clone https://github.com/liberalchang/Liber_SpeechAPI.git
   ```

2. 交互式安装：
   - Windows:
     ```powershell
     .\setup.ps1
     ```
   
   - Linux/macOS:
     ```bash
     ./setup.sh
     ```

3. 安装脚本将：
   - 交互式选择组件（ASR/TTS/API）、设备、模型、鉴权方式
   - 生成 `.env` 配置文件
   - 自动安装依赖（`uv sync [--extra api]`）
   - 验证 CLI 入口

4. 验证安装：
   ```bash
   # 查看 CLI 帮助
   uv run liber-speech --help
   
   # 诊断环境
   uv run liber-speech doctor
   ```

5. 配置 API 凭据：
   根据上述优先级规则，在合适的位置设置 `LIBER_API_BASE_URL` 和 `LIBER_API_KEY`，例如：
   - 在 `~/.openclaw/.env` 中设置全局凭据
   - 或在环境变量中设置特定会话的凭据

6. 配置详细参数：
   如需自定义ASR/TTS参数，创建 `~/.openclaw/workspace/config/speechapi_config.json` 文件以避免在技能更新时丢失配置

### Web 服务部署选项

如果需要额外的 Web 服务，可以部署 FastAPI 服务：

```bash
# 启动服务（默认 5555 端口）
uv run liber-speech serve

# 指定端口
uv run liber-speech serve --host 0.0.0.0 --port 8080
```

访问 API 文档：`http://localhost:5555/docs`

## 项目地址

- **主项目地址**：[https://github.com/liberalchang/Liber_SpeechAPI](https://github.com/liberalchang/Liber_SpeechAPI)

## 使用示例

### CLI 模式

#### ASR 转录
```bash
# 使用默认配置转录（输出 JSON）
uv run liber-speech asr transcribe audio.wav

# 指定模型与语言，输出 SRT 字幕
uv run liber-speech asr transcribe audio.wav \
  --model openai/whisper-large-v3 \
  --language zh \
  --out subs.srt
```

#### TTS 合成
```bash
# 使用默认配置合成（输出 WAV）
uv run liber-speech tts synthesize "你好世界"

# 多语模型 + OGG/Opus 输出（适合 Telegram）
uv run liber-speech tts synthesize "你好世界" \
  --language zh \
  --format ogg_opus \
  --out hello.ogg
```

### API 模式

通过 HTTP 请求调用，支持认证和多种输出格式，详细 API 文档请参见项目文档。

## Windows 用户特别注意

如果在 Windows 上遇到编码或模块加载问题，请使用修复版启动器：

```bash
# 使用修复版启动器（推荐）
python -S scripts/run_cli.py [命令]

# 或使用批处理文件
.\run_cli.bat [命令]
```

修复版启动器解决了以下问题：
- Windows GBK 编码问题
- Python site 模块加载冲突
- 缺失依赖（setuptools）
- 张量维度不匹配

path: requirements.txt
content: requests>=2.31.0


path: speechapi_config.json
content: {
    "asr": {
        "model": "openai/whisper-large-v3",
        "language": "zh",
        "task": "transcribe",
        "timestamps": "chunk",
        "batch_size": "default",
        "flash": "default",
        "hf_token": "default",
        "diarization_model": "default",
        "num_speakers": "default",
        "min_speakers": "default",
        "max_speakers": "default",
        "transcript_path": "default"
    },
    "tts": {
        "model": "multilingual",
        "language": "zh",
        "format": "wav",
        "repetition_penalty": "default",
        "temperature": "default",
        "top_p": "default",
        "top_k": "default",
        "norm_loudness": "default",
        "exaggeration": "default",
        "cfg_weight": "default",
        "audio_prompt_path": "tmp/clone.wav"
    },
    "global": {
        "voice_summary_limit": 100,
        "default_clone_audio": "tmp/clone.wav",
        "telegram_tts_format": "ogg_opus",
        "asr_output": "json"
    }
}


path: scripts/liber_speech_client.py
content: from __future__ import annotations

import argparse
import copy
import json
import os
import sys
from pathlib import Path
from typing import Any

import requests


DEFAULT_TIMEOUT = 120

# Create a session that bypasses proxy for local API calls
_session = requests.Session()
_session.trust_env = False  # Ignore http_proxy/https_proxy env vars


def _request(method: str, url: str, **kwargs) -> requests.Response:
    """Make a request using the no-proxy session."""
    return _session.request(method, url, **kwargs)
VALID_TTS_FORMATS = {"wav", "mp4", "ogg_opus"}
VALID_ASR_OUTPUTS = {"json", "text"}


class ConfigError(RuntimeError):
    """Raised when configuration is invalid."""


class ApiError(RuntimeError):
    """Raised when API request fails."""


def load_dotenv(dotenv_path: Path) -> None:
    if not dotenv_path.exists():
        return

    for raw_line in dotenv_path.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        key = key.strip()
        value = value.strip().strip('"').strip("'")
        os.environ.setdefault(key, value)


def str_to_bool(value: str | bool | None) -> bool | None:
    if value is None or isinstance(value, bool):
        return value
    normalized = value.strip().lower()
    if normalized in {"1", "true", "yes", "y", "on"}:
        return True
    if normalized in {"0", "false", "no", "n", "off"}:
        return False
    raise argparse.ArgumentTypeError(f"Invalid boolean value: {value}")


def load_config_json(config_path: Path) -> dict[str, Any]:
    default_config = {
        "asr": {
            "model": "openai/whisper-large-v3",
            "language": "zh",
            "task": "transcribe",
            "timestamps": "chunk",
            "batch_size": "default",
            "flash": "default",
            "hf_token": "default",
            "diarization_model": "default",
            "num_speakers": "default",
            "min_speakers": "default",
            "max_speakers": "default",
            "transcript_path": "default"
        },
        "tts": {
            "model": "multilingual",
            "language": "zh",
            "format": "wav",
            "repetition_penalty": "default",
            "temperature": "default",
            "top_p": "default",
            "top_k": "default",
            "norm_loudness": "default",
            "exaggeration": "default",
            "cfg_weight": "default",
            "audio_prompt_path": "default"
        },
        "global": {
            "voice_summary_limit": 100,
            "default_clone_audio": "tmp/clone.wav",
            "telegram_tts_format": "ogg_opus",
            "asr_output": "json"
        }
    }

    if not config_path.exists():
        return default_config

    try:
        user_config = json.loads(config_path.read_text(encoding="utf-8"))
    except Exception as exc:
        raise ConfigError(f"Failed to load config.json: {exc}") from exc

    merged_config = copy.deepcopy(default_config)
    for section in ("asr", "tts", "global"):
        if isinstance(user_config.get(section), dict):
            for key, value in user_config[section].items():
                if key == "timestamp":
                    key = "timestamps"
                if key in merged_config[section]:
                    merged_config[section][key] = value

    return merged_config


def load_env() -> dict[str, Any]:
    script_dir = Path(__file__).resolve().parent
    skill_dir = script_dir.parent

    # First, try loading from environment variables
    base_url = os.getenv("LIBER_API_BASE_URL", "").strip().rstrip("/")
    [REDACTED_SECRET]"LIBER_API_KEY", "").strip()

    # If not found in environment variables, try loading from ~/.openclaw/.env
    if not base_url or not [REDACTED_SECRET] = Path.home() / ".openclaw" / ".env"
        load_dotenv(openclaw_env_path)
        base_url = os.getenv("LIBER_API_BASE_URL", "").strip().rstrip("/")
        [REDACTED_SECRET]"LIBER_API_KEY", "").strip()

    # Finally, try loading from local .env files (skill directory or current working directory)
    if not base_url or not api_key:
        for candidate in (skill_dir / ".env", Path.cwd() / ".env"):
            load_dotenv(candidate)
            base_url = os.getenv("LIBER_API_BASE_URL", "").strip().rstrip("/")
            [REDACTED_SECRET]"LIBER_API_KEY", "").strip()
            if base_url and api_key:
                break

    if not base_url:
        raise ConfigError("Missing LIBER_API_BASE_URL. Please set it in environment variables, ~/.openclaw/.env, or local .env file")
    if not api_key:
        raise ConfigError("Missing LIBER_API_KEY. Please set it in environment variables, ~/.openclaw/.env, or local .env file")

    # Load config from external location to prevent overwrites during skill updates
    config_path = Path.home() / ".openclaw" / "workspace" / "config" / "speechapi_config.json"
    if config_path.exists():
        config_json = load_config_json(config_path)
    else:
        # Fallback to local config if external config doesn't exist
        config_json = load_config_json(skill_dir / "config.json")

    return {
        "base_url": base_url,
        "api_key": api_key,
        "config_json": config_json,
        "skill_dir": skill_dir,
    }


def build_headers(api_key: str) -> dict[str, str]:
    return {"Authorization": f"Bearer {api_key}"}


def process_parameter(value: Any) -> Any:
    if value == "default" or value is None:
        return None
    return value


def resolve_clone_audio(config: dict[str, Any], override: str | None = None) -> Path | None:
    tts_audio_prompt = config["config_json"]["tts"].get("audio_prompt_path")
    clone_audio_config = config["config_json"]["global"].get("default_clone_audio")
    raw_value = override if override is not None else tts_audio_prompt

    if raw_value in (None, "default"):
        raw_value = clone_audio_config
    if raw_value is None:
        return None

    value = str(raw_value).strip()
    if not value or value.lower() == "none" or value == "default":
        return None

    path = Path(value)
    if not path.is_absolute():
        path = config["skill_dir"] / value

    return path if path.exists() and path.is_file() else None


def build_asr_params(config: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
    params: dict[str, Any] = {}
    asr_config = config["config_json"]["asr"]

    for key in asr_config:
        value = overrides.get(key, asr_config[key])
        processed_value = process_parameter(value)
        if processed_value is not None:
            params[key] = processed_value

    return params


def build_tts_params(config: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
    params: dict[str, Any] = {}
    tts_config = config["config_json"]["tts"]

    # Add text parameter first (required field)
    text_value = overrides.get("text")
    if text_value is not None:
        params["text"] = text_value

    for key in tts_config:
        if key == "audio_prompt_path":
            continue
        value = overrides.get(key, tts_config[key])
        processed_value = process_parameter(value)
        if processed_value is not None:
            params[key] = processed_value

    return params


def check_health(config: dict[str, Any]) -> dict[str, Any]:
    url = f'{config["base_url"]}/health'
    response = _request("GET", url, headers=build_headers(config["api_key"]), timeout=DEFAULT_TIMEOUT)
    _raise_for_status(response, "health check failed")
    return response.json()


def download_audio_file(
    config: dict[str, Any],
    audio_url: str,
    output_path: str | None = None,
    fmt: str = "wav",
) -> Path | None:
    """Download audio file from API and save to local path.
    
    According to Liber_SpeechAPI source code, the TTS endpoint is synchronous
    and returns only after the file is fully generated and saved. The audio_url
    in the response points to an existing file that can be downloaded immediately.
    
    Args:
        config: Configuration dict with base_url and api_key
        audio_url: URL path from API response (e.g., /api/v1/results/xxx.wav)
        output_path: Custom output path, or None to use results/ directory
        fmt: Audio format extension
    
    Returns:
        Path to saved file, or None if download failed
    """
    from datetime import datetime
    
    try:
        # Build full URL
        if audio_url.startswith("http"):
            full_url = audio_url
        else:
            # API returns path like /api/v1/results/xxx.wav
            # base_url is like http://host:port/api/v1
            from urllib.parse import urlparse
            parsed = urlparse(config["base_url"])
            full_url = f"{parsed.scheme}://{parsed.netloc}{audio_url}"
        
        # Determine output directory
        if output_path:
            target_path = Path(output_path)
        else:
            # Default: results/ directory under skill root
            results_dir = config["skill_dir"] / "results"
            results_dir.mkdir(parents=True, exist_ok=True)
            
            # Generate filename with timestamp
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            target_path = results_dir / f"tts_{timestamp}.{fmt}"
        
        # Download the file (single attempt, no retry needed for synchronous API)
        response = _request("GET", full_url, headers=build_headers(config["api_key"]), timeout=DEFAULT_TIMEOUT)
        response.raise_for_status()
        
        # Verify content is audio, not error JSON
        content_type = response.headers.get("Content-Type", "")
        if "json" in content_type.lower():
            # Might be an error response
            try:
                error_data = response.json()
                raise ApiError(f"Server returned error: {error_data}")
            except:
                pass  # Not JSON, proceed with save
        
        # Save file
        target_path.write_bytes(response.content)
        
        # Verify file was written and has content
        if target_path.stat().st_size == 0:
            raise ApiError("Downloaded file is empty")
        
        return target_path
        
    except Exception as e:
        raise ApiError(f"Failed to download audio file: {e}") from e


def transcribe_file(
    audio_path: str,
    language: str | None = None,
    task: str | None = None,
    timestamps: str | None = None,
    model: str | None = None,
    batch_size: int | None = None,
    flash: bool | None = None,
    hf_token: str | None = None,
    diarization_model: str | None = None,
    num_speakers: int | None = None,
    min_speakers: int | None = None,
    max_speakers: int | None = None,
    output: str | None = None,
) -> dict[str, Any] | str:
    config = load_env()
    file_path = Path(audio_path)
    if not file_path.exists() or not file_path.is_file():
        raise FileNotFoundError(f"Audio file not found: {file_path}")

    default_output = str(config["config_json"]["global"].get("asr_output", "json")).strip().lower() or "json"
    output_mode = (output or default_output).strip().lower()
    if output_mode not in VALID_ASR_OUTPUTS:
        raise ValueError(f"Invalid ASR output mode: {output_mode}")

    overrides = {
        "language": language,
        "task": task,
        "timestamps": timestamps,
        "model": model,
        "batch_size": batch_size,
        "flash": flash,
        "hf_token": hf_token,
        "diarization_model": diarization_model,
        "num_speakers": num_speakers,
        "min_speakers": min_speakers,
        "max_speakers": max_speakers,
    }
    data = build_asr_params(config, overrides)
    url = f'{config["base_url"]}/asr/transcribe'

    with file_path.open("rb") as fh:
        files = {"file": (file_path.name, fh)}
        response = _request(
            "POST",
            url,
            headers=build_headers(config["api_key"]),
            data=data,
            files=files,
            timeout=DEFAULT_TIMEOUT,
        )

    _raise_for_status(response, "ASR transcription failed")
    payload = response.json()
    text = payload.get("text", "")
    if not isinstance(text, str) or not text.strip():
        raise ApiError("ASR succeeded but returned empty text")

    if output_mode == "text":
        return text
    return payload


def synthesize_text(
    text: str,
    language: str | None = None,
    model: str | None = None,
    fmt: str | None = None,
    clone_audio: str | None = None,
    repetition_penalty: float | None = None,
    temperature: float | None = None,
    top_p: float | None = None,
    top_k: int | None = None,
    norm_loudness: bool | None = None,
    exaggeration: float | None = None,
    cfg_weight: float | None = None,
    telegram: bool = False,
    output_path: str | None = None,
) -> dict[str, Any]:
    config = load_env()
    cleaned_text = (text or "").strip()
    if not cleaned_text:
        raise ValueError("TTS text must not be empty")

    default_format = str(config["config_json"]["tts"].get("format", "wav")).strip() or "wav"
    telegram_format = str(config["config_json"]["global"].get("telegram_tts_format", "ogg_opus")).strip() or "ogg_opus"
    effective_format = telegram_format if telegram else (fmt or default_format)
    if effective_format not in VALID_TTS_FORMATS:
        raise ValueError(f"Invalid TTS format: {effective_format}")

    overrides = {
        "text": cleaned_text,
        "language": language,
        "model": model,
        "format": effective_format,
        "repetition_penalty": repetition_penalty,
        "temperature": temperature,
        "top_p": top_p,
        "top_k": top_k,
        "norm_loudness": norm_loudness,
        "exaggeration": exaggeration,
        "cfg_weight": cfg_weight,
    }
    data = build_tts_params(config, overrides)
    url = f'{config["base_url"]}/tts/synthesize'
    clone_path = resolve_clone_audio(config, clone_audio)

    if clone_path is not None:
        with clone_path.open("rb") as clone_fh:
            files = {"audio_prompt": (clone_path.name, clone_fh)}
            response = _request(
                "POST",
                url,
                headers=build_headers(config["api_key"]),
                data=data,
                files=files,
                timeout=DEFAULT_TIMEOUT,
            )
    else:
        response = _request(
            "POST",
            url,
            headers=build_headers(config["api_key"]),
            data=data,
            timeout=DEFAULT_TIMEOUT,
        )

    _raise_for_status(response, "TTS synthesis failed")
    payload = response.json()
    audio_url = payload.get("audio_url", "")
    if not isinstance(audio_url, str) or not audio_url.strip():
        raise ApiError("TTS succeeded but returned empty audio_url")
    
    # Download audio file to local path
    local_path = download_audio_file(config, audio_url, output_path, effective_format)
    if local_path:
        payload["local_path"] = str(local_path)
    
    return payload


def _raise_for_status(response: requests.Response, message: str) -> None:
    try:
        response.raise_for_status()
    except requests.HTTPError as exc:
        detail = response.text[:1000]
        raise ApiError(f"{message}: HTTP {response.status_code}: {detail}") from exc


def main() -> None:
    parser = argparse.ArgumentParser(description="Liber SpeechAPI client")
    subparsers = parser.add_subparsers(dest="command", required=True)

    health_parser = subparsers.add_parser("health", help="Run health check")
    health_parser.set_defaults(command="health")

    asr_parser = subparsers.add_parser("asr", help="Transcribe audio file")
    asr_parser.add_argument("audio_path", help="Path to local audio file")
    asr_parser.add_argument("--language", default=None)
    asr_parser.add_argument("--task", default=None)
    asr_parser.add_argument("--timestamps", default=None)
    asr_parser.add_argument("--timestamp", dest="timestamps", default=None)
    asr_parser.add_argument("--model", default=None)
    asr_parser.add_argument("--batch-size", type=int, default=None)
    asr_parser.add_argument("--flash", type=str_to_bool, default=None)
    asr_parser.add_argument("--hf-token", default=None)
    asr_parser.add_argument("--diarization-model", default=None)
    asr_parser.add_argument("--num-speakers", type=int, default=None)
    asr_parser.add_argument("--min-speakers", type=int, default=None)
    asr_parser.add_argument("--max-speakers", type=int, default=None)
    asr_parser.add_argument("--output", choices=sorted(VALID_ASR_OUTPUTS), default=None)

    tts_parser = subparsers.add_parser("tts", help="Synthesize text")
    tts_parser.add_argument("text", help="Text to synthesize")
    tts_parser.add_argument("--language", default=None)
    tts_parser.add_argument("--model", default=None)
    tts_parser.add_argument("--format", choices=sorted(VALID_TTS_FORMATS), default=None)
    tts_parser.add_argument("--clone-audio", default=None)
    tts_parser.add_argument("--repetition-penalty", type=float, default=None)
    tts_parser.add_argument("--temperature", type=float, default=None)
    tts_parser.add_argument("--top-p", type=float, default=None)
    tts_parser.add_argument("--top-k", type=int, default=None)
    tts_parser.add_argument("--norm-loudness", type=str_to_bool, default=None)
    tts_parser.add_argument("--exaggeration", type=float, default=None)
    tts_parser.add_argument("--cfg-weight", type=float, default=None)
    tts_parser.add_argument("--telegram", action="store_true", help="Force Telegram ogg_opus output")
    tts_parser.add_argument("--output", "-o", default=None, help="Output file path (default: results/tts_YYYYMMDD_HHMMSS.wav)")

    args = parser.parse_args()

    try:
        if args.command == "health":
            result: Any = check_health(load_env())
        elif args.command == "asr":
            result = transcribe_file(
                audio_path=args.audio_path,
                language=args.language,
                task=args.task,
                timestamps=args.timestamps,
                model=args.model,
                batch_size=args.batch_size,
                flash=args.flash,
                hf_token=args.hf_token,
                diarization_model=args.diarization_model,
                num_speakers=args.num_speakers,
                min_speakers=args.min_speakers,
                max_speakers=args.max_speakers,
                output=args.output,
            )
        elif args.command == "tts":
            result = synthesize_text(
                text=args.text,
                language=args.language,
                model=args.model,
                fmt=args.format,
                clone_audio=args.clone_audio,
                repetition_penalty=args.repetition_penalty,
                temperature=args.temperature,
                top_p=args.top_p,
                top_k=args.top_k,
                norm_loudness=args.norm_loudness,
                exaggeration=args.exaggeration,
                cfg_weight=args.cfg_weight,
                telegram=args.telegram,
                output_path=args.output,
            )
        else:
            raise RuntimeError(f"Unsupported command: {args.command}")

        if isinstance(result, str):
            print(result)
        else:
            print(json.dumps(result, ensure_ascii=False, indent=2))
    except Exception as exc:
        print(f"Error: {exc}", file=sys.stderr)
        raise SystemExit(1)


if __name__ == "__main__":
    main()


path: scripts/summarize_for_voice.py
content: from __future__ import annotations

import argparse
import re


def strip_markdown(text: str) -> str:
    text = re.sub(r"```.*?```", "", text, flags=re.S)
    text = re.sub(r"`([^`]+)`", r"\1", text)
    text = re.sub(r"!\[[^\]]*]\([^)]+\)", "", text)
    text = re.sub(r"\[([^\]]+)]\([^)]+\)", r"\1", text)
    text = re.sub(r"^[>#\-*]+\s*", "", text, flags=re.M)
    text = re.sub(r"\|", " ", text)
    text = re.sub(r"\s+", " ", text)
    return text.strip()


def split_sentences(text: str) -> list[str]:
    parts = re.split(r"(?<=[。！？!?；;])", text)
    return [p.strip() for p in parts if p.strip()]


def summarize_text(text: str, limit: int = 100) -> str:
    cleaned = strip_markdown(text)
    if len(cleaned) <= limit:
        return cleaned

    sentences = split_sentences(cleaned)
    if not sentences:
        return cleaned[:limit]

    summary = ""
    for sentence in sentences:
        if len(summary + sentence) <= limit:
            summary += sentence
        else:
            break

    if not summary:
        summary = cleaned[:limit]

    return summary[:limit].rstrip("，,、；; ") + "。"


def main() -> None:
    parser = argparse.ArgumentParser(description="Summarize text for voice reply")
    parser.add_argument("text", help="Input text")
    parser.add_argument("--limit", type=int, default=100, help="Max character limit")
    args = parser.parse_args()

    print(summarize_text(args.text, args.limit))


if __name__ == "__main__":
    main()


path: references/api.md
content: # Liber SpeechAPI Reference

## Base URL

Default example:

```text
http://localhost:5555/api/v1
```

Set the actual base URL through `.env`:

- `LIBER_API_BASE_URL`

## Authentication

Use bearer token authentication:

```http
[REDACTED_SECRET]
```

Read the token from:

- `LIBER_API_KEY`

Do not hardcode tokens in scripts.

---

## Health check

```http
GET /health
```

Use this to verify service availability before ASR/TTS when needed.

---

## ASR

### Endpoint

```http
POST /asr/transcribe
```

### Form fields

- `file`: local audio file upload
- `url`: remote audio URL, optional alternative to file
- `language`: e.g. `zh`, `en`
- `task`: `transcribe` or `translate`
- `timestamps`: `chunk` or `word`
- other optional backend parameters from `config.json`

### Expected response fields

- `text`: recognized text
- `chunks`: optional segment information
- `meta`: metadata

### Output modes in this skill

- direct ASR default: return full JSON
- explicit text-only mode: return `text` only
- Telegram/openclaw mode: extract `text` and pass it into the chatbot workflow

### Notes

- Prefer file upload for Telegram voice processing.
- Validate that the input file exists and is readable.
- Fail clearly if no `text` is returned.

---

## TTS

### Endpoint

```http
POST /tts/synthesize
```

### Form fields

- `text`: text to synthesize
- `model`: `multilingual`, `turbo`, or `standard`
- `language`: e.g. `zh`, `en`
- `format`: `wav`, `mp4`, or `ogg_opus`
- `audio_prompt`: optional reference audio file for voice cloning
- other optional backend parameters from `config.json`

### Expected response fields

- `audio_url`: generated audio file URL
- `meta.format`
- `meta.sample_rate`

### Output format rules in this skill

- direct TTS default: `wav`
- Telegram voice reply: force `ogg_opus`
- explicit caller override: honor the requested format when it is valid

### Notes

- For Telegram voice notes, use `format=ogg_opus`.
- Only send `audio_prompt` when clone audio is enabled and the file exists.
- If `audio_prompt` is disabled or missing, send a normal TTS request.

---

## Telegram compatibility

The upstream project explicitly recommends `OGG/Opus` for Telegram voice-note delivery.

Use:
- `format=ogg_opus`

---

## Error handling guidance

Handle these cases explicitly:
- network timeout
- 401/403 authentication failure
- 404 wrong base URL or endpoint
- 422 invalid form fields
- empty ASR text
- empty TTS audio_url

Return actionable messages so the calling workflow can decide whether to retry, downgrade, or fall back to plain text.


path: references/config.md
content: # Configuration

## 配置文件结构

Liber SpeechAPI skill 使用两种配置方式：

1. **`.env` 文件** - 存放核心认证和服务器配置（必填）
2. **`config.json` 文件** - 存放详细的 ASR、TTS 和全局默认配置（可选，支持 `default` 值）

---

## 1. .env 文件（核心配置）

### 必填变量

| 变量名 | 描述 | 示例 |
|--------|------|------|
| `LIBER_API_BASE_URL` | Liber SpeechAPI 服务器地址 | `http://127.0.0.1:5555/api/v1` |
| `LIBER_API_KEY` | API 访问密钥（Bearer Token） | `your_api_key_here` |

### 示例 .env 文件

```env
LIBER_API_BASE_URL=http://127.0.0.1:5555/api/v1
LIBER_API_KEY=your_api_key_here
```

说明：
- `.env` 只负责服务地址和鉴权信息
- ASR/TTS 默认参数主要来自 `config.json`
- 如果 skill 目录和当前工作目录都存在 `.env`，优先使用 skill 目录内的 `.env`

---

## 2. config.json 文件（详细参数）

### 完整配置示例

```json
{
    "asr": {
        "model": "openai/whisper-large-v3",
        "language": "zh",
        "task": "transcribe",
        "timestamps": "chunk",
        "batch_size": "default",
        "flash": "default",
        "hf_token": "default",
        "diarization_model": "default",
        "num_speakers": "default",
        "min_speakers": "default",
        "max_speakers": "default",
        "transcript_path": "default"
    },
    "tts": {
        "model": "multilingual",
        "language": "zh",
        "format": "wav",
        "repetition_penalty": "default",
        "temperature": "default",
        "top_p": "default",
        "top_k": "default",
        "norm_loudness": "default",
        "exaggeration": "default",
        "cfg_weight": "default",
        "audio_prompt_path": "default"
    },
    "global": {
        "voice_summary_limit": 100,
        "default_clone_audio": "tmp/clone.wav",
        "telegram_tts_format": "ogg_opus",
        "asr_output": "json"
    }
}
```

### default 值处理

- 参数值为 `"default"` 表示使用后端默认值，请求时不传递该字段
- 参数值为 `null` 或未定义也会使用后端默认值
- 所有参数都有合理默认值，用户只需覆盖需要自定义的参数

---

## 3. 默认行为

### 通用文字转语音

- 默认输出格式：`wav`
- 默认参数来源：`tts` 配置段

### Telegram/openclaw 语音回复

- 强制输出格式：`ogg_opus`
- 默认参数来源：`tts` 配置段 + `global.telegram_tts_format`

### 通用语音转文字

- 默认输出模式：`json`
- 若显式要求纯文本，再返回 `text`
- 默认输出模式来源：`global.asr_output`

---

## 4. ASR 参数（Insanely Fast Whisper）

| 参数名 | 类型 | 默认值 | 描述 |
|--------|------|--------|------|
| `model` | string | `"openai/whisper-large-v3"` | ASR 模型 |
| `language` | string | `"zh"` | 输入音频语言 |
| `task` | string | `"transcribe"` | 任务类型：`transcribe` 或 `translate` |
| `timestamps` | string | `"chunk"` | 时间戳粒度：`chunk` 或 `word` |
| `batch_size` | number | `"default"` | 批处理大小 |
| `flash` | boolean | `"default"` | 是否启用 Flash Attention |
| `hf_token` | string | `"default"` | Hugging Face Token |
| `diarization_model` | string | `"default"` | 说话人分割模型 |
| `num_speakers` | number | `"default"` | 说话人数 |
| `min_speakers` | number | `"default"` | 最小说话人数 |
| `max_speakers` | number | `"default"` | 最大说话人数 |
| `transcript_path` | string | `"default"` | 转录保存路径 |

---

## 5. TTS 参数（Chatterbox）

| 参数名 | 类型 | 默认值 | 描述 |
|--------|------|--------|------|
| `model` | string | `"multilingual"` | TTS 模型 |
| `language` | string | `"zh"` | 输出语言 |
| `format` | string | `"wav"` | 通用 TTS 默认输出格式 |
| `repetition_penalty` | number | `"default"` | 重复惩罚系数 |
| `temperature` | number | `"default"` | 采样温度 |
| `top_p` | number | `"default"` | 核采样概率 |
| `top_k` | number | `"default"` | Top-K 采样 |
| `norm_loudness` | boolean | `"default"` | 是否标准化音量 |
| `exaggeration` | number | `"default"` | 夸张度系数 |
| `cfg_weight` | number | `"default"` | 克隆相似度权重 |
| `audio_prompt_path` | string | `"default"` | 参考音频路径配置 |

---

## 6. 全局参数

| 参数名 | 类型 | 默认值 | 描述 |
|--------|------|--------|------|
| `voice_summary_limit` | number | 100 | 语音回复文本长度限制 |
| `default_clone_audio` | string | `"tmp/clone.wav"` | 默认声音克隆参考音频路径 |
| `telegram_tts_format` | string | `"ogg_opus"` | Telegram 语音回复格式 |
| `asr_output` | string | `"json"` | 通用 ASR 默认输出模式 |

---

## 7. 配置优先级

1. 命令行参数
2. 函数参数
3. `config.json`
4. 后端默认值

说明：
- `.env` 不参与 ASR/TTS 参数优先级比较，只负责服务地址和 API Key
- `default` 值会被忽略，最终由后端补默认值

---

## 8. 快速开始

### 复制示例配置文件

```bash
copy .env.example .env
copy config.json.example config.json
```

### 运行健康检查

```bash
python scripts/liber_speech_client.py health
```

### 通用文字转语音（默认 wav）

```bash
python scripts/liber_speech_client.py tts "你好，欢迎使用 Liber SpeechAPI"
```

### 通用语音转文字（默认 json）

```bash
python scripts/liber_speech_client.py asr demo.wav
```

### 仅返回文本

```bash
python scripts/liber_speech_client.py asr demo.wav --output text
```

### Telegram 语音格式

```bash
python scripts/liber_speech_client.py tts "这是一条 Telegram 语音回复" --telegram
```


path: references/parameters.md
content: # Liber SpeechAPI 参数说明文档

## 概述

Liber SpeechAPI skill 提供三类能力：

1. **Telegram/openclaw 语音工作流**：语音输入 → ASR → 回复压缩 → Telegram OGG/Opus TTS
2. **通用文字转语音**：显式把文本合成为音频，默认输出 `wav`
3. **通用语音转文字**：显式把音频转为结构化识别结果，默认输出 `json`

配置支持 `config.json` 中的 `default` 值，表示使用后端默认参数，不在请求中传递该字段。

---

## 配置文件结构

### config.json 格式

```json
{
    "asr": {
        "model": "openai/whisper-large-v3",
        "language": "zh",
        "task": "transcribe",
        "timestamps": "chunk"
    },
    "tts": {
        "model": "multilingual",
        "language": "zh",
        "format": "wav"
    },
    "global": {
        "voice_summary_limit": 100,
        "telegram_tts_format": "ogg_opus",
        "asr_output": "json"
    }
}
```

### 默认值处理规则

- 如果参数值为 `default` 或 `null`，请求时不传递该字段
- 使用后端服务器默认值
- `tts.format` 用于通用 TTS 默认输出格式
- `global.telegram_tts_format` 用于 Telegram 场景语音格式
- `global.asr_output` 用于通用 ASR 默认输出模式

---

## ASR 参数详解 (Insanely Fast Whisper)

### 核心参数

| 参数名 | 类型 | 默认值 | 描述 |
|--------|------|--------|------|
| `model` | string | `openai/whisper-large-v3` | 预训练模型名称 |
| `language` | string | `zh` | 输入音频语言 |
| `task` | string | `transcribe` | 任务类型：`transcribe` 或 `translate` |
| `timestamps` | string | `chunk` | 时间戳粒度：`chunk` 或 `word` |

### 性能参数

| 参数名 | 类型 | 默认值 | 描述 |
|--------|------|--------|------|
| `batch_size` | integer | 后端默认 | 并行计算批次大小 |
| `flash` | boolean | 后端默认 | 是否使用 Flash Attention 2 加速 |

### 高级功能参数

| 参数名 | 类型 | 默认值 | 描述 |
|--------|------|--------|------|
| `hf_token` | string | `null` | Hugging Face 访问令牌 |
| `diarization_model` | string | 后端默认 | 说话人分割模型 |
| `num_speakers` | integer | `null` | 确切说话人数量 |
| `min_speakers` | integer | `null` | 最小说话人数量 |
| `max_speakers` | integer | `null` | 最大说话人数量 |
| `transcript_path` | string | 后端默认 | 转录结果保存路径 |

### 输出模式

| 模式 | 默认值 | 描述 |
|------|--------|------|
| `json` | 是 | 返回完整 ASR JSON |
| `text` | 否 | 仅返回识别文本 |

---

## TTS 参数详解 (Chatterbox)

### 模型配置

| 参数名 | 类型 | 默认值 | 描述 |
|--------|------|--------|------|
| `model` | string | `multilingual` | 模型类型：`multilingual`、`turbo`、`standard` |
| `language` | string | `zh` | 输出语言 |
| `format` | string | `wav` | 通用 TTS 默认输出格式 |

### 生成参数

| 参数名 | 类型 | 默认值 | 描述 |
|--------|------|--------|------|
| `repetition_penalty` | float | 后端默认 | 重复惩罚系数 |
| `temperature` | float | 后端默认 | 采样温度 |
| `top_p` | float | 后端默认 | 核采样概率阈值 |
| `top_k` | integer | 后端默认 | Top-K 采样 |
| `norm_loudness` | boolean | 后端默认 | 是否标准化音频响度 |
| `exaggeration` | float | 后端默认 | 夸张度系数 |
| `cfg_weight` | float | 后端默认 | 无分类器引导权重 |

### 输出格式规则

| 场景 | 默认输出 |
|------|----------|
| 直接文字转语音 | `wav` |
| Telegram 语音回复 | `ogg_opus` |
| 显式指定格式 | 使用用户指定格式 |

---

## 全局参数

| 参数名 | 类型 | 默认值 | 描述 |
|--------|------|--------|------|
| `voice_summary_limit` | integer | 100 | 语音回复文本长度限制 |
| `default_clone_audio` | string | `tmp/clone.wav` | 默认声音克隆参考音频路径 |
| `telegram_tts_format` | string | `ogg_opus` | Telegram 语音输出格式 |
| `asr_output` | string | `json` | 通用 ASR 默认输出模式 |

---

## 常见配置场景

### 场景 1: 通用文字转语音

```json
{
    "tts": {
        "model": "multilingual",
        "language": "zh",
        "format": "wav"
    }
}
```

### 场景 2: Telegram/openclaw 语音回复

```json
{
    "tts": {
        "model": "multilingual",
        "language": "zh",
        "format": "wav"
    },
    "global": {
        "telegram_tts_format": "ogg_opus",
        "voice_summary_limit": 100
    }
}
```

### 场景 3: 通用语音转文字

```json
{
    "asr": {
        "language": "zh",
        "task": "transcribe",
        "timestamps": "word"
    },
    "global": {
        "asr_output": "json"
    }
}
```

### 场景 4: 语音克隆

```json
{
    "tts": {
        "model": "multilingual",
        "language": "zh",
        "cfg_weight": 0.5
    },
    "global": {
        "default_clone_audio": "tmp/clone.wav"
    }
}
```

---

## 参数优化建议

### 性能优化

- GPU 设备建议使用 `flash=true`
- 内存不足时减小 `batch_size`
- 快速转录可使用更轻模型

### 质量优化

- 使用 `openai/whisper-large-v3` 获得更好的识别质量
- TTS 使用 `standard` 模型获得更高音质
- 调整 `cfg_weight` 控制克隆相似度


path: references/workflow.md
content: # Telegram / openclaw Workflow

## Goal

Convert Telegram voice input into text for openclaw, then convert the final concise reply back into Telegram-compatible voice.

## Supported modes

This skill supports three modes:

1. **Telegram/openclaw voice workflow**
2. **Direct text-to-speech**
3. **Direct speech-to-text**

---

## Mode 1: Telegram/openclaw voice workflow

### 1. Receive Telegram voice message

Input is usually one of:
- Telegram voice file path
- downloaded local OGG file
- downloaded local audio file in another format

### 2. Run ASR

Call Liber SpeechAPI ASR with:
- `file=<local audio file>`
- `language=<configured language>`
- `task=transcribe`
- `timestamps=chunk`

Extract:
- `text`

### 3. Send transcript to openclaw

Use the ASR transcript as the user message for the chatbot workflow.

### 4. Produce final textual reply

Let openclaw generate the full answer first.

### 5. Compress for voice

If the final answer is too long for a natural voice reply:
- summarize it to within 100 Chinese characters
- keep the direct answer, essential facts, and next action
- remove markdown, long examples, code, and repeated explanation

### 6. Run TTS

Call Liber SpeechAPI TTS with:
- `text=<voice-ready text>`
- `model=<configured model>`
- `language=<configured language>`
- `format=ogg_opus`

If clone audio is enabled:
- include `audio_prompt=<configured file>`

### 7. Return audio to Telegram

Use the returned `audio_url` or downloaded file to send a Telegram voice message.

---

## Mode 2: Direct text-to-speech

Use this mode when the user explicitly asks to convert text into audio.

Default behavior:
- default output format: `wav`
- do not force summarization unless the caller requests a short spoken version
- use clone audio only when enabled and the file exists

Examples:
- “把这段文字转成语音”
- “把这段回复合成为 wav”
- “朗读这段文字并输出音频”

---

## Mode 3: Direct speech-to-text

Use this mode when the user explicitly asks to convert audio into text.

Default behavior:
- default output mode: `json`
- return plain `text` only when explicitly requested

Examples:
- “把这段录音转文字”
- “识别这段音频并返回 JSON”
- “帮我做语音转文字”

---

## Compression guidance

Good spoken reply:
- short
- direct
- natural
- no markdown formatting
- no code syntax
- no excessive detail

Example:

Original:
- “这个问题通常是因为 API 地址配置错误、鉴权失败，或者 OGG/Opus 文件没有正确生成。建议先检查 health 接口，再核对 API key，最后确认 TTS format 是否为 ogg_opus。”

Compressed:
- “可能是地址、鉴权或音频格式有误。先检查 health、API key，并确认 TTS 输出为 ogg_opus。”

---

## Failure strategy

### ASR failure
- return plain error text
- optionally ask the user to resend clearer audio

### Summarization failure
- conservatively trim text to the configured limit

### TTS failure
- return plain text reply instead of blocking the whole chatbot response

### Clone-audio missing
- continue without cloning


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