diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 64a985585..25d9a77a7 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -279,6 +279,18 @@ "authentication": "ON_INSTALL" }, "category": "MCP" + }, + { + "name": "eapil-voice", + "source": { + "source": "local", + "path": "./plugins/codex/plugins/eapil-voice" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "多媒体与生成" } ] } diff --git a/plugins/codex/.agents/plugins/marketplace.json b/plugins/codex/.agents/plugins/marketplace.json index a2b48ff4f..055b97eea 100644 --- a/plugins/codex/.agents/plugins/marketplace.json +++ b/plugins/codex/.agents/plugins/marketplace.json @@ -279,6 +279,18 @@ "authentication": "ON_INSTALL" }, "category": "MCP" + }, + { + "name": "eapil-voice", + "source": { + "source": "local", + "path": "./plugins/eapil-voice" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "多媒体与生成" } ] } diff --git a/plugins/codex/plugins/eapil-voice/.codex-plugin/plugin.json b/plugins/codex/plugins/eapil-voice/.codex-plugin/plugin.json new file mode 100644 index 000000000..48e720f8b --- /dev/null +++ b/plugins/codex/plugins/eapil-voice/.codex-plugin/plugin.json @@ -0,0 +1,37 @@ +{ + "name": "eapil-voice", + "version": "0.1.0", + "description": "当任务需要合成语音、根据指令设计语音、从参考音频克隆语音、使用 VoxCPM2 或 Qwen 提供商的服务、对音频进行增强或去噪、通过该服务转录音频、批量进行 ASR/警报音克隆、将生成的语音导出为 WAV/MP3 格式、检查模型状态,或生成用于 Eapil-Voice API 的 curl/Python 调用时,应使用此服务。", + "author": { + "name": "EAPIL", + "url": "https://git.playones.com/arechen/EapilSkillMarket" + }, + "homepage": "https://git.playones.com/arechen/EapilSkillMarket", + "repository": "https://git.playones.com/arechen/EapilSkillMarket", + "license": "Proprietary", + "keywords": [ + "eapil", + "codex-skill", + "eapil-voice" + ], + "skills": "./skills/", + "interface": { + "displayName": "易瞳语音转录|设计|克隆|降噪", + "shortDescription": "当任务需要合成语音、根据指令设计语音、从参考音频克隆语音、使用 VoxCPM2 或 Qwen 提供商的服务、对音频进行增强或去噪、通过该服务转录音频、批量进行 ASR/警报音克隆、将生成的语音导出为 WAV/MP3 格式、检查模型状态,或生成用于 Eapil-Voice API 的 curl/Python 调用时,应使用此服务。", + "longDescription": "当任务需要合成语音、根据指令设计语音、从参考音频克隆语音、使用 VoxCPM2 或 Qwen 提供商的服务、对音频进行增强或去噪、通过该服务转录音频、批量进行 ASR/警报音克隆、将生成的语音导出为 WAV/MP3 格式、检查模型状态,或生成用于 Eapil-Voice API 的 curl/Python 调用时,应使用此服务。", + "developerName": "EAPIL", + "category": "多媒体与生成", + "capabilities": [ + "Read", + "Write" + ], + "defaultPrompt": [ + "使用 易瞳语音转录|设计|克隆|降噪 帮我完成这个任务。" + ], + "websiteURL": "https://git.playones.com/arechen/EapilSkillMarket", + "privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket", + "termsOfServiceURL": "https://git.playones.com/arechen/EapilSkillMarket", + "brandColor": "#2563EB", + "screenshots": [] + } +} diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/SKILL.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/SKILL.md new file mode 100644 index 000000000..5df56c741 --- /dev/null +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/SKILL.md @@ -0,0 +1,108 @@ +--- +name: eapil-voice +description: Guide agents to call the hosted Eapil-Voice/Qwen3 Voice Design FastAPI service by domain for audio generation workflows. Use when a task asks to synthesize speech, design a voice from an instruction, clone a voice from reference audio, use VoxCPM2 or Qwen providers, enhance or denoise audio, transcribe audio through this service, batch-ASR/clone alert sounds, export generated speech as WAV/MP3, check model health, or produce curl/Python calls for the Eapil-Voice API. +--- + +# Eapil-Voice + +## Purpose + +Use this skill to call the hosted Eapil-Voice API by domain. Prefer the existing API surface over inventing client contracts. + +Default base URL is `https://ades.playones.com`. Override it only when the user gives another domain or `QWEN3_VOICE_API_BASE_URL`. + +Use `OPENAI_API_KEY` as the API key environment variable. If it is not set and the user did not provide a key directly, tell the user to get their key from `https://kinfo.playones.com`. Direct key use is allowed with `--token` or an `Authorization: Bearer ` header. + +## Call Workflow + +1. Resolve the base URL first. Use `https://ades.playones.com` unless the user gives another domain. +2. Check `GET /health` before expensive generation. +3. Add `Authorization: Bearer ` from an explicit user-provided key or `OPENAI_API_KEY`. +4. Choose the endpoint by desired output: + - Voice from style instruction: `POST /v1/voice/design`. + - Base TTS by speaker name: `POST /v1/voice/synthesize`. + - Voice clone from reference audio: `POST /v1/voice/clone`. + - Speech enhancement or denoise: `POST /v1/audio/enhance` or `POST /v1/audio/denoise`. + - Transcription: `POST /v1/asr/transcribe` returns JSON, not audio. +5. Save audio-output responses as `.wav`; these endpoints return `audio/wav`. +6. Read `/openapi.json` from the active base URL when exact current form fields matter. +7. Read `references/api.md` before writing detailed client code, exposing all services, troubleshooting errors, or selecting Qwen vs VoxCPM2 parameters. +8. Read `references/batch-audio.md` before batch ASR/clone/export work, especially game filter alert sounds. + +## Provider Rules + +Use `provider=qwen` by default. Only `/v1/voice/design` and `/v1/voice/clone` accept `provider=voxcpm2`. + +For VoxCPM2: +- Pass `provider=voxcpm2`. +- Use `cfg_value`, `inference_timesteps`, `normalize`, `denoise`, and `retry_badcase` for generation controls. +- Set `denoise=true` only when the server is configured with `VOXCPM2_LOAD_DENOISER=true`. +- For clone with `ref_text`, expect Hi-Fi transcript-guided cloning. +- For clone without `ref_text`, use `instruct` for controllable cloning. + +For Qwen clone, always provide both `text` and `ref_text`. + +## Batch Audio Rules + +- For many short alert sounds, ask only if the output mode is genuinely ambiguous: generated-only files, full replacement files, or overlay/mix. +- Do not assume ASR segment timestamps are source-separation masks. ASR can locate/transcribe speech; it cannot remove an old voice from mixed music/SFX by itself. +- If the user wants clean replacement assets for another tool, export generated speech only, named after the source files. Use `scripts/batch_clone_generated_audio.py`. +- If the user wants to preserve music/SFX while replacing embedded speech, state that source separation or stems are required for clean removal. Do not mix the original voice under the generated voice unless the user explicitly asks for overlay. +- Normalize generated text before clone: add expressive punctuation when useful; if the final character is not `。!?.!?~~`, append `。` for Chinese alert text. +- For final MP3 delivery, convert the generated WAVs with `ffmpeg` and explicit gain such as `volume=4dB` only when requested. + +## Quick Examples + +Use JSON for voice design and synthesis: + +```bash +curl -X POST "$QWEN3_VOICE_API_BASE_URL/v1/voice/design" \ + -H 'Content-Type: application/json' \ + -d '{"text":"Hello world","instruct":"Warm, calm, natural narration","language":"auto"}' \ + --output voice_design.wav +``` + +Use multipart form data for clone, enhancement, denoise, and ASR: + +```bash +curl -X POST "$QWEN3_VOICE_API_BASE_URL/v1/voice/clone" \ + -F 'file=@reference.wav' \ + -F 'text=This is the cloned sentence.' \ + -F 'ref_text=Transcript of the reference audio.' \ + -F 'language=auto' \ + --output voice_clone.wav +``` + +Use the bundled dependency-free client when convenient: + +```bash +python ~/.codex/skills/eapil-voice/scripts/call_voice_api.py design \ + --text "Hello world" \ + --instruct "Warm, calm, natural narration" \ + --out voice_design.wav +``` + +Batch clone generated speech only, preserving source names: + +```bash +python ~/.codex/skills/eapil-voice/scripts/batch_clone_generated_audio.py \ + --base-url https://ades.playones.com \ + --token "$OPENAI_API_KEY" \ + --input-dir "/path/to/filter-sounds" \ + --reference-audio reference.wav \ + --output-dir "/path/to/generated-mp3" \ + --format mp3 \ + --gain-db 4 +``` + +On Windows, prefer direct command execution or a single quoted `Start-Process -ArgumentList` string for paths containing spaces or CJK characters. + +## Source Anchors + +When a local source checkout is available and the task depends on implementation details, use it only as source reference. Do not prefer local service startup over the hosted domain: + +- `/Users/eapil/git/QWEN3-Voice-Design/app/api/routes/voice.py` +- `/Users/eapil/git/QWEN3-Voice-Design/app/api/routes/audio.py` +- `/Users/eapil/git/QWEN3-Voice-Design/app/api/routes/asr.py` +- `/Users/eapil/git/QWEN3-Voice-Design/app/schemas/voice.py` +- `/Users/eapil/git/QWEN3-Voice-Design/README.md` diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/agents/openai.yaml b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/agents/openai.yaml new file mode 100644 index 000000000..73e1eafa5 --- /dev/null +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Eapil-Voice" + short_description: "Call hosted Eapil voice APIs" + default_prompt: "Use $eapil-voice to transcribe, clone, or batch-export speech through the hosted Eapil-Voice API." diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/api.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/api.md new file mode 100644 index 000000000..d85b4aa5d --- /dev/null +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/api.md @@ -0,0 +1,297 @@ +# Eapil-Voice API Reference + +## Defaults + +- Base URL: `https://ades.playones.com` +- Override env: `QWEN3_VOICE_API_BASE_URL` +- API key env: `OPENAI_API_KEY` +- Auth header: `Authorization: Bearer ` +- If no key is available, get one from `https://kinfo.playones.com`. +- Audio-output media type: `audio/wav` +- Local source checkouts are source references only unless the user explicitly asks to run a local service. Historical local start command: + +```bash +uv run python -m uvicorn fastapi_voice_design_api:app --host 0.0.0.0 --port 8197 +``` + +## Endpoint Summary + +| Task | Method | Path | Input | Output | +| --- | --- | --- | --- | --- | +| Voice design TTS | `POST` | `/v1/voice/design` | JSON | WAV | +| Base TTS synthesis | `POST` | `/v1/voice/synthesize` | JSON | WAV | +| Voice clone | `POST` | `/v1/voice/clone` | multipart form or raw audio body plus query fields | WAV | +| Speech enhancement | `POST` | `/v1/audio/enhance` | multipart form or raw audio body | WAV | +| Denoise alias | `POST` | `/v1/audio/denoise` | multipart form or raw audio body | WAV | +| ASR transcription | `POST` | `/v1/asr/transcribe` | multipart form or raw audio body | JSON | +| Health | `GET` | `/health` | none | JSON | +| Models | `GET` | `/models` | none | JSON | + +## Voice Design + +`POST /v1/voice/design` + +Use this for text-to-speech driven by a style instruction. Body is JSON. + +Required: +- `text`: text to speak +- `instruct`: style, emotion, voice, or delivery instruction + +Common optional fields: +- `provider`: `qwen` default, or `voxcpm2` +- `language`: `auto` default, used by Qwen +- `temperature`: `0.9` default, used by Qwen +- `max_tokens`: `4096` default, used by Qwen +- `top_k`: `50` default, used by Qwen +- `top_p`: `1.0` default, used by Qwen +- `repetition_penalty`: `1.05` default, used by Qwen +- `cfg_value`: `2.0` default, used by VoxCPM2 +- `inference_timesteps`: `10` default, used by VoxCPM2 +- `normalize`: `false` default, used by VoxCPM2 +- `denoise`: `false` default, used by VoxCPM2 +- `retry_badcase`: `true` default, used by VoxCPM2 + +Qwen example: + +```bash +curl -X POST https://ades.playones.com/v1/voice/design \ + -H 'Content-Type: application/json' \ + -d '{ + "text": "Hello world", + "instruct": "Warm, calm, natural narration", + "language": "auto" + }' \ + --output voice_design.wav +``` + +VoxCPM2 example: + +```bash +curl -X POST https://ades.playones.com/v1/voice/design \ + -H 'Content-Type: application/json' \ + -d '{ + "text": "Hello world", + "instruct": "Warm, calm, natural narration", + "provider": "voxcpm2", + "cfg_value": 2.0, + "inference_timesteps": 10, + "normalize": false, + "denoise": false, + "retry_badcase": true + }' \ + --output voxcpm2_voice_design.wav +``` + +## Base TTS Synthesis + +`POST /v1/voice/synthesize` + +Use this for base Qwen TTS with an optional named speaker. Body is JSON. This endpoint does not accept `provider`. + +Required: +- `text` + +Optional: +- `speaker`: e.g. `Chelsie`; omit for model default +- `language`: `auto` default +- `temperature`: `0.9` default +- `max_tokens`: `4096` default +- `top_k`: `50` default +- `top_p`: `1.0` default +- `repetition_penalty`: `1.05` default + +Example: + +```bash +curl -X POST https://ades.playones.com/v1/voice/synthesize \ + -H 'Content-Type: application/json' \ + -d '{ + "text": "Hello world", + "speaker": "Chelsie", + "language": "auto" + }' \ + --output synthesize.wav +``` + +## Voice Clone + +`POST /v1/voice/clone` + +Use this for cloning from reference audio. Prefer multipart form upload with `file=@reference.wav`. + +Audio input: +- `file`: reference audio upload +- Alternative: raw audio request body with non-JSON `Content-Type`, with text fields in query params + +Shared fields: +- `text`: required output text +- `provider`: `qwen` default, or `voxcpm2` +- `language`: `auto` default, used by Qwen + +Qwen required fields: +- `text` +- `ref_text`: transcript of the reference audio +- `file` + +Qwen optional fields: +- `temperature`: default `0.9` +- `max_tokens`: default `4096` +- `top_k`: default `50` +- `top_p`: default `1.0` +- `repetition_penalty`: default `1.05` + +VoxCPM2 required fields: +- `text` +- `file` +- `provider=voxcpm2` + +VoxCPM2 optional fields: +- `ref_text`: when present, enables transcript-guided Hi-Fi clone +- `instruct`: used only when `ref_text` is absent +- `cfg_value`: default `2.0` +- `inference_timesteps`: default `10` +- `normalize`: default `false` +- `denoise`: default `false` +- `retry_badcase`: default `true` + +Qwen clone example: + +```bash +curl -X POST https://ades.playones.com/v1/voice/clone \ + -F 'file=@reference.wav' \ + -F 'text=This is the cloned sentence.' \ + -F 'ref_text=This is the transcript of the reference audio.' \ + -F 'language=auto' \ + --output voice_clone.wav +``` + +VoxCPM2 controllable clone without transcript: + +```bash +curl -X POST https://ades.playones.com/v1/voice/clone \ + -F 'file=@reference.wav' \ + -F 'text=This is the cloned sentence.' \ + -F 'instruct=Keep the same speaker identity with a calm delivery.' \ + -F 'provider=voxcpm2' \ + --output voxcpm2_controllable_clone.wav +``` + +VoxCPM2 Hi-Fi clone with transcript: + +```bash +curl -X POST https://ades.playones.com/v1/voice/clone \ + -F 'file=@reference.wav' \ + -F 'text=This is the cloned sentence.' \ + -F 'ref_text=This is the transcript of the reference audio.' \ + -F 'provider=voxcpm2' \ + --output voxcpm2_hifi_clone.wav +``` + +## Speech Enhancement And Denoise + +`POST /v1/audio/enhance` + +`POST /v1/audio/denoise` + +These two paths call the same service and return `enhanced.wav`. Use either multipart `file=@noisy.wav` or raw audio body. + +Optional: +- `chunked`: form or query bool. Accepted truthy values include `1`, `true`, `yes`, `on`; falsy values include `0`, `false`, `no`, `off`. + +Example: + +```bash +curl -X POST https://ades.playones.com/v1/audio/enhance \ + -F 'file=@noisy.wav' \ + --output enhanced.wav +``` + +## ASR Transcription + +`POST /v1/asr/transcribe` + +This is not audio generation. It returns JSON: + +```json +{ + "text": "...", + "segments": [], + "language": "English", + "prompt_tokens": 0, + "generation_tokens": 0, + "total_tokens": 0, + "prompt_tps": 0, + "generation_tps": 0, + "total_time": 0 +} +``` + +Input is multipart `file=@speech.wav` or raw audio body. + +Optional fields can be form or query params: +- `language`: normalized aliases include `zh`, `zh-cn`, `en`, `ja`, `ko`; default is `English` +- `temperature`: default `0.0` +- `max_tokens`: default `8192` +- `top_k`: default `0` +- `top_p`: default `1.0` +- `repetition_penalty`: default omitted + +Example: + +```bash +curl -X POST https://ades.playones.com/v1/asr/transcribe \ + -F 'file=@speech.wav' +``` + +## Health And Models + +```bash +curl https://ades.playones.com/health +curl https://ades.playones.com/models +``` + +Model status keys: +- `voice_design_tts` +- `base_tts` +- `asr` +- `speech_enhancement` +- `voxcpm2` + +Models are lazy-loaded on first request, so `loaded: false` can be normal before use. + +## Errors And Troubleshooting + +- `401 unauthorized`: the request lacks a valid `Authorization: Bearer `. Use `OPENAI_API_KEY`, pass `--token`, or get a key from `https://kinfo.playones.com`. +- `400 provider must be one of: qwen, voxcpm2`: invalid or empty provider. +- `400 audio file is required`: missing audio input for enhancement or ASR. +- `400 reference audio file is required`: missing clone reference audio. +- `400 text and ref_text are required`: Qwen clone requires both. +- `400 text is required`: VoxCPM2 clone requires `text`. +- `400 denoise=true requires VOXCPM2_LOAD_DENOISER=true`: retry with `denoise=false` or reconfigure server. +- `501 VOXCPM2_BACKEND=vllm_omni is not implemented in this build`: local ARM Mac path supports `official`. +- `503 failed to load ... model`: model package, model id, local weights, or optional `voxcpm` dependency is unavailable. + +## Environment Variables + +Server-side: + +```env +OPENAI_API_KEY=your_key_here +TTS_MODEL_ID=mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-bf16 +BASE_TTS_MODEL_ID=mlx-community/Qwen3-TTS-12Hz-0.6B-Base-8bit +ASR_MODEL_ID=mlx-community/Qwen3-ASR-1.7B-8bit +SE_MODEL_ID=starkdmi/MossFormer2_SE_48K_MLX +SE_MODEL_PRECISION=int8 +VOXCPM2_MODEL_ID=openbmb/VoxCPM2 +VOXCPM2_BACKEND=official +VOXCPM2_LOAD_DENOISER=false +VOXCPM2_DEVICE=auto +VOXCPM2_OPTIMIZE=true +``` + +Client-side convenience: + +```env +QWEN3_VOICE_API_BASE_URL=https://ades.playones.com +OPENAI_API_KEY=your_key_here +``` diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/batch-audio.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/batch-audio.md new file mode 100644 index 000000000..55dce9604 --- /dev/null +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/batch-audio.md @@ -0,0 +1,60 @@ +# Batch Audio Workflows + +Use this reference for directory-scale ASR, clone, and export jobs such as game filter alert sounds. + +## Pick The Output Mode First + +Clarify or infer the output mode before generating many files: + +- **Generated-only assets**: create cloned speech files named after the source audio. This is the safest default when the user wants files they can manually replace elsewhere. +- **Overlay/mix**: mix generated speech with the source audio only when the user explicitly asks for overlay. +- **Speech replacement inside mixed audio**: do not promise clean removal from mixed music/SFX using ASR timestamps alone. Clean replacement requires source separation, stems, or a dedicated vocal-removal model. + +If the user says the old and new voices are both audible, stop overlay work and export generated-only assets instead unless they ask for source separation. + +## Generated-Only Batch Export + +Prefer the bundled script: + +```bash +python ~/.codex/skills/eapil-voice/scripts/batch_clone_generated_audio.py \ + --base-url https://ades.playones.com \ + --token "$OPENAI_API_KEY" \ + --input-dir "/path/to/source-audio" \ + --reference-audio reference.wav \ + --output-dir "/path/to/generated-output" \ + --format mp3 \ + --gain-db 4 \ + --overwrite +``` + +The script: + +- ASRs each source file. +- Normalizes alert text punctuation. +- Uses the reference audio stem as `ref_text`. +- Calls `/v1/voice/clone` with `provider=qwen` by default. +- Saves `_raw_wav/` originals plus final WAV/MP3 named after the source files. +- Writes `_manifests/` and `conversion_summary.json`. + +If `OPENAI_API_KEY` is not set, ask the user to get their key from `https://kinfo.playones.com` or pass the key directly with `--token`. + +## Text And Punctuation + +For Chinese alert text, normalize whitespace and punctuation before clone. Add expressive punctuation when it matches the phrase. If the final character is not one of `。!?.!?~~`, append `。`. + +## Gain And Format + +Service clone responses are WAV. For MP3 delivery, use ffmpeg after generation: + +```bash +ffmpeg -y -i generated.wav -filter:a "volume=4dB" -codec:a libmp3lame -q:a 2 generated.mp3 +``` + +Use the user's requested gain literally. Do not normalize or limit unless requested. + +## Windows Notes + +Paths with spaces or CJK characters are common. Prefer direct shell invocation. If launching a background job with PowerShell `Start-Process`, pass a single fully quoted `-ArgumentList` string; arrays can be split incorrectly. + +Use `subprocess.run(..., encoding="utf-8", errors="replace")` in Python when capturing ffmpeg output on Windows to avoid GBK decode crashes. diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/batch_clone_generated_audio.py b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/batch_clone_generated_audio.py new file mode 100644 index 000000000..7e2cb5d21 --- /dev/null +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/batch_clone_generated_audio.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Batch ASR + Eapil-Voice clone and export generated speech by source name.""" + +from __future__ import annotations + +import argparse +import json +import mimetypes +import os +import re +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + +import requests + + +DEFAULT_BASE_URL = "https://ades.playones.com" +KEY_HELP = "missing API key: set OPENAI_API_KEY, pass --token directly, or get a key from https://kinfo.playones.com" +AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".wma"} +END_PUNCTUATION = "。!?.!?~~" +SAFE_NAME_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]') + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", default=os.getenv("QWEN3_VOICE_API_BASE_URL", DEFAULT_BASE_URL)) + parser.add_argument("--token", default=os.getenv("OPENAI_API_KEY"), required=False) + parser.add_argument("--input-dir", required=True, help="Directory of source alert audio files") + parser.add_argument("--reference-audio", required=True, help="Voice clone reference audio") + parser.add_argument("--output-dir", required=True, help="Directory for generated files") + parser.add_argument("--format", choices=["wav", "mp3"], default="wav") + parser.add_argument("--gain-db", type=float, default=0.0, help="Output gain applied during export") + parser.add_argument("--provider", choices=["qwen", "voxcpm2"], default="qwen") + parser.add_argument("--asr-language", default="zh") + parser.add_argument("--clone-language", default="auto") + parser.add_argument("--timeout", type=int, default=900) + parser.add_argument("--retries", type=int, default=3) + parser.add_argument("--overwrite", action="store_true") + return parser.parse_args() + + +def log(message: str) -> None: + print(message, flush=True) + + +def request_headers(token: str | None) -> dict[str, str]: + if not token: + raise SystemExit(KEY_HELP) + return {"Authorization": f"Bearer {token}"} + + +def audio_files(input_dir: Path) -> list[Path]: + return sorted( + [path for path in input_dir.iterdir() if path.is_file() and path.suffix.lower() in AUDIO_EXTENSIONS], + key=lambda path: path.name.casefold(), + ) + + +def safe_stem(text: str) -> str: + cleaned = SAFE_NAME_RE.sub("_", text).strip().strip(".") + return cleaned or "untitled" + + +def normalize_text(raw: str, fallback: str) -> str: + text = " ".join((raw or "").replace("\u3000", " ").split()).strip() or fallback.strip() + text = text.translate(str.maketrans({",": ",", ":": ":", ";": ";", "(": "(", ")": ")"})) + if text.startswith("哇") and len(text) > 1 and text[1] not in ",,。!?!?": + text = "哇," + text[1:] + if text.startswith("拜托") and "," not in text[:4] and len(text) > 2: + text = "拜托," + text[2:] + if text and text[-1] not in END_PUNCTUATION: + text += "。" + return text + + +def request_with_retries( + method: str, + url: str, + *, + retries: int, + timeout: int, + **kwargs: Any, +) -> requests.Response: + last_error: Exception | None = None + for attempt in range(1, retries + 1): + try: + response = requests.request(method, url, timeout=timeout, **kwargs) + if response.status_code < 500: + return response + last_error = RuntimeError(f"HTTP {response.status_code}: {response.text[:500]}") + except Exception as exc: # noqa: BLE001 + last_error = exc + wait = min(30, 2**attempt) + log(f"retry attempt={attempt}/{retries} wait={wait}s error={last_error}") + time.sleep(wait) + if last_error: + raise last_error + raise RuntimeError("request failed") + + +def transcribe(args: argparse.Namespace, source: Path, headers: dict[str, str]) -> dict[str, Any]: + mime = mimetypes.guess_type(source.name)[0] or "application/octet-stream" + with source.open("rb") as handle: + response = request_with_retries( + "POST", + f"{args.base_url.rstrip('/')}/v1/asr/transcribe", + retries=args.retries, + timeout=args.timeout, + headers=headers, + files={"file": (source.name, handle, mime)}, + data={"language": args.asr_language, "temperature": "0"}, + ) + if response.status_code != 200: + raise RuntimeError(f"ASR HTTP {response.status_code}: {response.text[:1000]}") + return response.json() + + +def clone(args: argparse.Namespace, reference_audio: Path, ref_text: str, text: str, headers: dict[str, str]) -> bytes: + mime = mimetypes.guess_type(reference_audio.name)[0] or "audio/wav" + with reference_audio.open("rb") as handle: + response = request_with_retries( + "POST", + f"{args.base_url.rstrip('/')}/v1/voice/clone", + retries=args.retries, + timeout=args.timeout, + headers=headers, + files={"file": (reference_audio.name, handle, mime)}, + data={ + "text": text, + "ref_text": ref_text, + "language": args.clone_language, + "provider": args.provider, + }, + ) + if response.status_code != 200: + raise RuntimeError(f"CLONE HTTP {response.status_code}: {response.text[:1000]}") + return response.content + + +def export_audio(raw_wav: Path, output: Path, gain_db: float, output_format: str) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + if output_format == "wav": + command = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + str(raw_wav), + "-filter:a", + f"volume={gain_db:.6f}dB", + str(output), + ] + else: + command = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + str(raw_wav), + "-filter:a", + f"volume={gain_db:.6f}dB", + "-codec:a", + "libmp3lame", + "-q:a", + "2", + str(output), + ] + result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace") + if result.returncode != 0: + raise RuntimeError(result.stderr[-2000:] or result.stdout[-2000:] or "ffmpeg failed") + + +def main() -> int: + args = parse_args() + if not args.token: + log(KEY_HELP) + return 2 + + input_dir = Path(args.input_dir) + reference_audio = Path(args.reference_audio) + output_dir = Path(args.output_dir) + raw_dir = output_dir / "_raw_wav" + manifest_dir = output_dir / "_manifests" + output_dir.mkdir(parents=True, exist_ok=True) + raw_dir.mkdir(parents=True, exist_ok=True) + manifest_dir.mkdir(parents=True, exist_ok=True) + + headers = request_headers(args.token) + files = audio_files(input_dir) + ref_text = reference_audio.stem + summary: list[dict[str, Any]] = [] + + log(f"base_url={args.base_url.rstrip('/')}") + log(f"input_files={len(files)} output_dir={output_dir} format={args.format} gain_db={args.gain_db}") + + for index, source in enumerate(files, 1): + output = output_dir / f"{safe_stem(source.stem)}.{args.format}" + raw_output = raw_dir / f"{safe_stem(source.stem)}.wav" + try: + if output.exists() and not args.overwrite: + record = {"source": str(source), "output": str(output), "status": "skipped", "reason": "exists"} + else: + asr = transcribe(args, source, headers) + text = normalize_text(str(asr.get("text") or ""), source.stem) + raw_output.write_bytes(clone(args, reference_audio, ref_text, text, headers)) + export_audio(raw_output, output, args.gain_db, args.format) + record = { + "source": str(source), + "output": str(output), + "raw_wav": str(raw_output), + "status": "ok", + "text": text, + "asr": asr, + } + (manifest_dir / f"{safe_stem(source.stem)}.json").write_text( + json.dumps(record, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + log(f"{index}/{len(files)} {record['status'].upper()} {source.name}") + except Exception as exc: # noqa: BLE001 + record = {"source": str(source), "status": "error", "error": str(exc)} + log(f"{index}/{len(files)} ERROR {source.name}: {exc}") + summary.append(record) + + summary_path = output_dir / "conversion_summary.json" + summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + errors = sum(1 for record in summary if record.get("status") == "error") + log(f"done files={len(files)} errors={errors} summary={summary_path}") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py new file mode 100644 index 000000000..ecb891ac0 --- /dev/null +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +"""Dependency-free client for the hosted Eapil-Voice API.""" + +from __future__ import annotations + +import argparse +import json +import mimetypes +import os +import sys +import uuid +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin +from urllib.request import Request, urlopen + + +DEFAULT_BASE_URL = "https://ades.playones.com" +KEY_HELP = "missing API key: set OPENAI_API_KEY, pass --token directly, or get a key from https://kinfo.playones.com" + + +def env_base_url() -> str: + return os.getenv("QWEN3_VOICE_API_BASE_URL", DEFAULT_BASE_URL).rstrip("/") + + +def env_token() -> str | None: + return os.getenv("OPENAI_API_KEY") + + +def add_common_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--base-url", default=env_base_url(), help="API base URL") + parser.add_argument("--token", default=env_token(), help="Bearer API key; defaults to OPENAI_API_KEY") + + +def clean_payload(values: dict[str, object | None]) -> dict[str, object]: + return {key: value for key, value in values.items() if value is not None} + + +def bool_value(raw: str | bool | None) -> bool | None: + if raw is None or isinstance(raw, bool): + return raw + value = raw.strip().lower() + if value in {"1", "true", "yes", "on"}: + return True + if value in {"0", "false", "no", "off"}: + return False + raise argparse.ArgumentTypeError(f"invalid boolean value: {raw}") + + +def request_headers(token: str | None, extra: dict[str, str] | None = None) -> dict[str, str]: + if not token: + raise SystemExit(KEY_HELP) + headers = dict(extra or {}) + headers["Authorization"] = f"Bearer {token}" + return headers + + +def build_url(base_url: str, path: str) -> str: + return urljoin(base_url.rstrip("/") + "/", path.lstrip("/")) + + +def http_request( + *, + method: str, + base_url: str, + path: str, + token: str | None, + body: bytes | None = None, + headers: dict[str, str] | None = None, +) -> bytes: + request = Request( + build_url(base_url, path), + data=body, + method=method, + headers=request_headers(token, headers), + ) + try: + with urlopen(request) as response: + return response.read() + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise SystemExit(f"HTTP {exc.code} {exc.reason}: {detail}") from exc + except URLError as exc: + raise SystemExit(f"request failed: {exc.reason}") from exc + + +def post_json(args: argparse.Namespace, path: str, payload: dict[str, object]) -> bytes: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + return http_request( + method="POST", + base_url=args.base_url, + path=path, + token=args.token, + body=body, + headers={"Content-Type": "application/json"}, + ) + + +def multipart_body( + *, + fields: dict[str, object | None], + file_field: str | None = None, + file_path: str | None = None, +) -> tuple[bytes, str]: + boundary = f"----eapil-voice-{uuid.uuid4().hex}" + chunks: list[bytes] = [] + + def add_line(value: str) -> None: + chunks.append(value.encode("utf-8")) + + for name, value in clean_payload(fields).items(): + add_line(f"--{boundary}\r\n") + add_line(f'Content-Disposition: form-data; name="{name}"\r\n\r\n') + add_line(f"{value}\r\n") + + if file_field and file_path: + path = Path(file_path) + content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + add_line(f"--{boundary}\r\n") + add_line( + f'Content-Disposition: form-data; name="{file_field}"; filename="{path.name}"\r\n' + ) + add_line(f"Content-Type: {content_type}\r\n\r\n") + chunks.append(path.read_bytes()) + add_line("\r\n") + + add_line(f"--{boundary}--\r\n") + return b"".join(chunks), f"multipart/form-data; boundary={boundary}" + + +def post_multipart( + args: argparse.Namespace, + path: str, + fields: dict[str, object | None], + file_path: str | None, +) -> bytes: + body, content_type = multipart_body(fields=fields, file_field="file", file_path=file_path) + return http_request( + method="POST", + base_url=args.base_url, + path=path, + token=args.token, + body=body, + headers={"Content-Type": content_type}, + ) + + +def write_output(path: str, data: bytes) -> None: + Path(path).write_bytes(data) + print(path) + + +def cmd_health(args: argparse.Namespace) -> None: + data = http_request( + method="GET", + base_url=args.base_url, + path="/health", + token=args.token, + ) + print_json(data) + + +def cmd_models(args: argparse.Namespace) -> None: + data = http_request( + method="GET", + base_url=args.base_url, + path="/models", + token=args.token, + ) + print_json(data) + + +def print_json(data: bytes) -> None: + try: + parsed = json.loads(data.decode("utf-8")) + except json.JSONDecodeError: + sys.stdout.buffer.write(data) + return + print(json.dumps(parsed, ensure_ascii=False, indent=2)) + + +def cmd_design(args: argparse.Namespace) -> None: + payload = clean_payload( + { + "text": args.text, + "instruct": args.instruct, + "provider": args.provider, + "language": args.language, + "temperature": args.temperature, + "max_tokens": args.max_tokens, + "top_k": args.top_k, + "top_p": args.top_p, + "repetition_penalty": args.repetition_penalty, + "cfg_value": args.cfg_value, + "inference_timesteps": args.inference_timesteps, + "normalize": args.normalize, + "denoise": args.denoise, + "retry_badcase": args.retry_badcase, + } + ) + write_output(args.out, post_json(args, "/v1/voice/design", payload)) + + +def cmd_synthesize(args: argparse.Namespace) -> None: + payload = clean_payload( + { + "text": args.text, + "speaker": args.speaker, + "language": args.language, + "temperature": args.temperature, + "max_tokens": args.max_tokens, + "top_k": args.top_k, + "top_p": args.top_p, + "repetition_penalty": args.repetition_penalty, + } + ) + write_output(args.out, post_json(args, "/v1/voice/synthesize", payload)) + + +def cmd_clone(args: argparse.Namespace) -> None: + fields = clean_payload( + { + "text": args.text, + "ref_text": args.ref_text, + "language": args.language, + "temperature": args.temperature, + "max_tokens": args.max_tokens, + "top_k": args.top_k, + "top_p": args.top_p, + "repetition_penalty": args.repetition_penalty, + "provider": args.provider, + "instruct": args.instruct, + "cfg_value": args.cfg_value, + "inference_timesteps": args.inference_timesteps, + "normalize": args.normalize, + "denoise": args.denoise, + "retry_badcase": args.retry_badcase, + } + ) + write_output(args.out, post_multipart(args, "/v1/voice/clone", fields, args.file)) + + +def cmd_enhance(args: argparse.Namespace) -> None: + fields = {"chunked": args.chunked} + write_output(args.out, post_multipart(args, "/v1/audio/enhance", fields, args.file)) + + +def cmd_denoise(args: argparse.Namespace) -> None: + fields = {"chunked": args.chunked} + write_output(args.out, post_multipart(args, "/v1/audio/denoise", fields, args.file)) + + +def cmd_asr(args: argparse.Namespace) -> None: + fields = clean_payload( + { + "language": args.language, + "temperature": args.temperature, + "max_tokens": args.max_tokens, + "top_k": args.top_k, + "top_p": args.top_p, + "repetition_penalty": args.repetition_penalty, + } + ) + print_json(post_multipart(args, "/v1/asr/transcribe", fields, args.file)) + + +def add_generation_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--language", default=None) + parser.add_argument("--temperature", type=float, default=None) + parser.add_argument("--max-tokens", type=int, default=None) + parser.add_argument("--top-k", type=int, default=None) + parser.add_argument("--top-p", type=float, default=None) + parser.add_argument("--repetition-penalty", type=float, default=None) + + +def add_voxcpm2_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--cfg-value", type=float, default=None) + parser.add_argument("--inference-timesteps", type=int, default=None) + parser.add_argument("--normalize", type=bool_value, default=None) + parser.add_argument("--denoise", type=bool_value, default=None) + parser.add_argument("--retry-badcase", type=bool_value, default=None) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + health = subparsers.add_parser("health", help="Check service health") + add_common_args(health) + health.set_defaults(func=cmd_health) + + models = subparsers.add_parser("models", help="Show configured model status") + add_common_args(models) + models.set_defaults(func=cmd_models) + + design = subparsers.add_parser("design", help="Generate voice from text and instruction") + add_common_args(design) + design.add_argument("--text", required=True) + design.add_argument("--instruct", required=True) + design.add_argument("--provider", choices=["qwen", "voxcpm2"], default=None) + design.add_argument("--out", default="voice_design.wav") + add_generation_args(design) + add_voxcpm2_args(design) + design.set_defaults(func=cmd_design) + + synthesize = subparsers.add_parser("synthesize", help="Base TTS synthesis") + add_common_args(synthesize) + synthesize.add_argument("--text", required=True) + synthesize.add_argument("--speaker", default=None) + synthesize.add_argument("--out", default="synthesize.wav") + add_generation_args(synthesize) + synthesize.set_defaults(func=cmd_synthesize) + + clone = subparsers.add_parser("clone", help="Clone a voice from reference audio") + add_common_args(clone) + clone.add_argument("--file", required=True, help="Reference audio path") + clone.add_argument("--text", required=True) + clone.add_argument("--ref-text", default=None) + clone.add_argument("--provider", choices=["qwen", "voxcpm2"], default=None) + clone.add_argument("--instruct", default=None) + clone.add_argument("--out", default="voice_clone.wav") + add_generation_args(clone) + add_voxcpm2_args(clone) + clone.set_defaults(func=cmd_clone) + + enhance = subparsers.add_parser("enhance", help="Enhance speech audio") + add_common_args(enhance) + enhance.add_argument("--file", required=True) + enhance.add_argument("--chunked", type=bool_value, default=None) + enhance.add_argument("--out", default="enhanced.wav") + enhance.set_defaults(func=cmd_enhance) + + denoise = subparsers.add_parser("denoise", help="Denoise speech audio") + add_common_args(denoise) + denoise.add_argument("--file", required=True) + denoise.add_argument("--chunked", type=bool_value, default=None) + denoise.add_argument("--out", default="enhanced.wav") + denoise.set_defaults(func=cmd_denoise) + + asr = subparsers.add_parser("asr", help="Transcribe audio to JSON") + add_common_args(asr) + asr.add_argument("--file", required=True) + add_generation_args(asr) + asr.set_defaults(func=cmd_asr) + + return parser + + +def main() -> None: + args = build_parser().parse_args() + args.func(args) + + +if __name__ == "__main__": + main()