feat: add unified 1.0.0 skill collection

This commit is contained in:
chenmingxuan committed 2026-10-06 15:01:05 +08:00
commit cc4c380583
87 files changed
+6963

No files matched your search

@@ -0,0 +1,39 @@
{
"name": "eapil-voice",
"version": "1.0.0",
"description": "易瞳音频服务调用指南:语音设计、朗读、克隆、ASR、MossFormer2/RE-USE 降噪、Stable Audio 音乐与音效、Yue2 歌曲及批量导出。包含请求校验、错误处理和实测记录。",
"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": "语音、ASR、降噪、音乐、音效与歌曲的调用及验证指南。",
"longDescription": "按需选择 Qwen/VoxCPM2 语音、转录、两类降噪、Stable Audio 和 Yue2 接口。配套单请求客户端与顺序批处理,校验 WAV/JSON 响应、使用准确参考转写,并明确超时不可盲目重发及部署限制。保留 eapil-voice 插件 ID。",
"developerName": "EAPIL",
"category": "Creativity",
"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": [],
"composerIcon": "./assets/market-icon.svg",
"logo": "./assets/market-icon.svg"
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="360" viewBox="0 0 48 48"><title>易瞳语音转录|设计|克隆|降噪</title><g fill="none"><path fill="url(#SVGdintbeRm)" d="M25 37.715c7.265-.513 13-6.57 13-13.965a1.25 1.25 0 1 0-2.5 0c0 6.351-5.149 11.5-11.5 11.5s-11.5-5.149-11.5-11.5a1.25 1.25 0 1 0-2.5 0c0 7.225 5.473 13.172 12.5 13.92v5.08a1.25 1.25 0 1 0 2.5 0z"/><path fill="url(#SVGNVICieOE)" d="M16 12a8 8 0 1 1 16 0v12a8 8 0 1 1-16 0z"/><defs><linearGradient id="SVGdintbeRm" x1="10" x2="23.45" y1="13.269" y2="46.06" gradientUnits="userSpaceOnUse"><stop stop-color="#cad2d9"/><stop offset="1" stop-color="#70777d"/></linearGradient><linearGradient id="SVGNVICieOE" x1="12" x2="26.691" y1="-2.364" y2="47.657" gradientUnits="userSpaceOnUse"><stop stop-color="#0fafff"/><stop offset="1" stop-color="#cc23d1"/></linearGradient></defs></g></svg>

After

Width:  |  Height:  |  Size: 868 B

@@ -0,0 +1,47 @@
---
name: eapil-voice
description: Build, test or use EAPIL audio API calls for speech design and cloning, transcription, denoising, music, sound effects and lyric-conditioned songs; includes sequential batch speech export.
---
# EAPIL audio service
Use the hosted service at `https://ades.playones.com`, or the deployment chosen by the user. Keep the historical skill/plugin ID for compatibility; this guide covers the whole audio service.
| Need | Route | Read when relevant |
|---|---|---|
| Describe a voice and speak text | `/v1/voice/design` | [Speech](references/speech.md) |
| Ordinary Qwen text reading | `/v1/voice/synthesize` | [Speech](references/speech.md) |
| Clone from reference speech | `/v1/voice/clone` | [Speech](references/speech.md) |
| Transcribe speech | `/v1/asr/transcribe` | [ASR and enhancement](references/recognition-enhancement.md) |
| MossFormer2 enhancement / alias | `/v1/audio/enhance`, `/v1/audio/denoise` | [ASR and enhancement](references/recognition-enhancement.md) |
| RE-USE denoising | `/v1/audio/reuse` | [ASR and enhancement](references/recognition-enhancement.md) |
| Instrumental music or sound effects | `/v1/audio/music`, `/v1/audio/sfx` | [Stable Audio](references/music.md) |
| Songs from lyrics or ABC arrangement | `/v1/audio/music/yue2` | [Yue2](references/yue2.md) |
| Directory-scale ASR/clone/export | Sequential calls | [Batch audio](references/batch-audio.md) |
Read [transport and errors](references/api.md) for client integration or troubleshooting. Consult `/openapi.json` and `/models` when deployment facts matter; a registered route or healthy service does not prove a model can infer. Dated evidence is in [verification](references/verification.md). Read only the mode needed.
## Call the actual contract
Use `AUDIO_API_BASE` and `AUDIO_API_TOKEN`; old `QWEN3_VOICE_API_BASE_URL` and `OPENAI_API_KEY` remain fallback aliases. Explicit flags take precedence. An available legacy key is not proof of authorization on this deployment. Inference requires Bearer authentication; health/models are public. Obtain missing credentials from the provider without putting them in chat or tracked files.
Speech design/synthesis, music/SFX and Yue2 take JSON. Clone, ASR and enhancement use multipart with file field `file`; let the client create the boundary. Audio success is complete WAV bytes, not JSON/Base64/a URL or inference streaming. ASR returns JSON. Preserve explicit false/0; keep provider and endpoint controls separate.
[call_voice_api.py](scripts/call_voice_api.py) makes one validated request. Existing command names remain; new commands are reuse, music, sfx, yue2. Dry run prints the payload without a key/network call, so share only synthetic inputs. Resolve the actual skill directory:
~~~powershell
python '<skill-dir>/scripts/call_voice_api.py' design --text '你好。' --instruct '温暖自然、清晰的女声。' --language Chinese --max-tokens 512 --out voice.wav
python '<skill-dir>/scripts/call_voice_api.py' asr --file speech.wav --language zh
~~~
Prefix external commands with `rtk proxy` in this repository.
## Verify and deliver
Check status/media type and complete WAV structure, then verify what matters: spoken content, accurate reference transcript, intelligibility/noise or musical content/duration. A valid WAV alone does not prove voice similarity or listening quality. Read actual rate/channels from the header; MP3 requires local transcoding.
Do not invent transcripts from filenames, manufacture speech after empty ASR, silently rewrite requested text or treat ASR timestamps as source separation. Punctuation normalization, gain and overlays apply only when requested.
Requests wait for the final result. There is no job ID, polling/cancel endpoint or idempotency key. Timeout/504 may leave inference running: do not automatically resubmit. Keep calls sequential initially; only an explicit busy rejection is eligible for bounded backoff.
Return usable local audio, endpoint/provider and limitations. In Codex, preview delivered audio with an absolute path. Record actual coverage; keep credentials, probe outputs and private transcripts outside the repository.
@@ -0,0 +1,4 @@
interface:
display_name: "EAPIL Audio"
short_description: "EAPIL speech, ASR, denoising, music and song APIs"
default_prompt: "Use $eapil-voice to choose and call the EAPIL audio endpoint for speech, transcription, denoising, music or songs."
@@ -0,0 +1,37 @@
# Transport and deployed contract
Source: maintainer's API usage guide dated 2026-10-06 and live [OpenAPI](https://ades.playones.com/openapi.json). Schema limits, deployment caps and model recommendations differ; deployment settings can change.
## Requests and credentials
Base URL is the service root without /v1. Client precedence: explicit flags; AUDIO_API_BASE/AUDIO_API_TOKEN; legacy QWEN3_VOICE_API_BASE_URL/OPENAI_API_KEY; default hosted URL. --api-key-env NAME selects exactly that variable. --token remains for old scripts; environment credentials avoid process-argument exposure. GET health/models require no token.
Inference uses Bearer auth. Serialize UTF-8 JSON, including multiline lyrics. File workflows prefer multipart with field file, automatically generated boundary and form-valued numbers/booleans. Raw binary with query fields is also supported by the API; the bundled client uses multipart to keep long text out of URLs.
Size limit: 26,214,400 bytes (25 MiB). Qwen clone, ASR, MossFormer2 and RE-USE enforce <=60 decoded seconds. VoxCPM2 shares the size limit but not that decoded-duration check; a clear 5–15 second reference is usually sufficient. A .wav extension is not conversion. WAV inputs can be preflight checked; other decoders depend on the server.
## Results
Audio success is complete binary audio/wav. The helper checks media type and complete RIFF format/data chunks before saving. It handles PCM/IEEE float WAV; RF64/compressed WAV needs another validated decoder. ASR must have string text; an empty transcription can represent silence, but cannot drive a batch clone.
Read actual rate/channels. Music/SFX currently use 44.1 kHz stereo PCM16; Yue2 48 kHz stereo PCM16. Provider speech rates vary. RE-USE encodes the input rate; enhancement/RE-USE mix channels to mono and are not stereo mastering tools.
Server-side JavaScript reads response.arrayBuffer() for audio and response.json() for ASR, once per response body. Current deployment lacks general browser CORS; use a backend proxy or configured CORS. Keep service tokens on that backend.
## Timeouts and concurrency
Speech/ASR/enhancement timeout starts at 600 seconds; music/SFX/Yue2 at 1800. Client timeouts cannot extend a gateway timeout. HTTP chunked transport is not progressive generation.
There is no async job, task query, cancellation endpoint or idempotency key. Timeout/504 does not establish that inference stopped; investigate before repeating. Do not invent a status endpoint.
Qwen design can reject busy with 503 and Retry-After. Stable Audio music/SFX share a queue. Qwen synthesis/clone share serialized execution; ASR, VoxCPM2 and Yue2 are separately serialized; MossFormer2/RE-USE share execution. Loading/connection failures can also be 503: status alone is insufficient.
The single-call helper never retries. Batch --retries defaults to 1 total attempt, capped at 5. Additional attempts only follow explicit busy/running 503 detail without load/error/timeout indicators. It honors numeric Retry-After up to 60 seconds; longer backoff is left to the application, and fallback delays are bounded. Upload bodies are rebuilt in full. Ambiguous errors are failures, not retry signals.
## Errors and state
Failure detail can be a string/array or gateway HTML. Check status/media before parsing. 400/422 usually require request correction; 401 credential correction; 413 smaller/shorter audio. 404 includes removed MiniMax. 500/501/502/503 need detail to distinguish input errors, unsupported backend, loading and busy. Yue2 upstream 4xx may appear as 502.
Library callers can inspect APIError.status/detail/busy. CLI logs omit raw provider detail because it can echo private inputs; inspect locally and redact before sharing.
Health ok means API responsiveness. Models keys include voice_design_tts, base_tts, asr, speech_enhancement, reuse, stable_audio_3, yue2, voxcpm2. Loaded=false or Yue2 stopped can be normal lazy loading. Hosted API use does not require local model startup.
@@ -0,0 +1,25 @@
# Sequential batch speech export
ASR each source, then clone speech under the source stem. Output is generated speech only, without overlay or embedded music/SFX preservation. Clean voice replacement inside mixed audio requires stems/source separation; ASR timestamps are not removal masks.
~~~powershell
python '<skill-dir>/scripts/batch_clone_generated_audio.py' --input-dir source-audio --reference-audio reference.wav --reference-text-file reference.txt --output-dir generated --provider qwen --asr-language zh --clone-language Chinese --format wav
~~~
## Transcripts and content
Supply reference-text/reference-text-file containing the recording's actual words. Otherwise transcribe the reference once with reference-language (default zh). Review ASR for important work; automatic recognition does not establish accuracy.
Each source uses asr-language (default zh). Empty/invalid ASR fails that file, never substitutes its name. Recognized text is preserved apart from surrounding whitespace. Optional normalize-text collapses whitespace/adds terminal punctuation without invented interjections or rewriting semantics.
Both batch providers use transcript-guided clone. Use the single-call client for VoxCPM2 instruct-only clone; Qwen clone-language is not sent to VoxCPM2.
## Files and retries
Requests are sequential. Retries defaults to **1 total attempt**, only confirmed busy 503 permits extra attempts (max 5). Timeout, 500/502/504 and ambiguous load failures are not replayed. [Transport](api.md) explains classification. Multipart is rebuilt from the entire file.
Preflight checks file presence/size, WAV limits where possible, output-name collisions, overlap with input/reference paths and required ffmpeg. Compressed decoding is server-dependent. Rename colliding stems before generation. Existing outputs skip unless overwrite is requested.
Raw WAVs go to _raw_wav; final outputs keep source stems. Per-file _manifests and conversion_summary.json track progress, saved after every file. Failed files do not become successes. Rerun skips completed files; review unknown outcomes before resubmitting failed files. This is local progress tracking, not server task resume. Keep private transcripts/manifests outside the repository.
WAV/no gain is copied; MP3 or requested gain uses ffmpeg. Export writes a temporary file and replaces the final output only after success; failure clears the temporary file and preserves any old completed output. Defaults are WAV and 0 dB. Set format mp3 or gain-db 4 only when wanted. No automatic normalization, limiter or overlay; subprocess capture uses UTF-8 for Windows CJK paths. For redirected Python output on Windows, use python -X utf8 so CJK diagnostics are encoded as UTF-8 too.
@@ -0,0 +1,24 @@
# Stable Audio music and sound effects
JSON music uses Medium + SAME-L; sfx uses Small-SFX + SAME-S. Text-to-audio only: no dedicated lyrics, reference audio, inpainting or continuation. Use Yue2 for explicit lyrics. MiniMax was removed.
| Field | Default | Schema / effective meaning |
|---|---|---|
| prompt | required | Nonblank 1–2000 characters; model uses <=256 tokenizer tokens |
| negative_prompt | null | <=2000 characters; active only when cfg != 1 |
| seconds | music 30, SFX 5 | >0 and <=380; current music cap 380 |
| steps | 8 | Schema 1–64; current deployment cap **16** |
| seed | null/random | 0–2147483647 |
| cfg | 1 | 0–10; 1 is single-branch lower latency |
| apg | 1 | 0–1; active only when cfg != 1 |
Effective limits are min(380, configured seconds) and min(64, configured steps). SFX shares the 380-second schema but its recommended window remains <=120 seconds. Schema acceptance is not a model recommendation.
Use one or two concrete sentences: source/genre, instruments, BPM, mood, development, space. Defaults or 4-step short previews are starting points; more steps/CFG do not guarantee quality. Fix seed to compare. Instrumental/no vocals is guidance, not a guarantee. Activate negative_prompt with cfg !=1 (e.g. 2.5), increasing compute. APG does nothing at cfg=1.
~~~powershell
python '<skill-dir>/scripts/call_voice_api.py' music --prompt 'Instrumental piano and warm strings, 90 BPM, no vocals' --seconds 5 --steps 4 --seed 7 --out music.wav
python '<skill-dir>/scripts/call_voice_api.py' sfx --prompt 'Three footsteps on wet gravel, realistic transients, no music' --seconds 5 --steps 8 --seed 7 --out sfx.wav
~~~
Current output is 44.1 kHz stereo PCM16. Check duration, clipping, content and unwanted vocals by listening. Longer output increases wait/memory; music/SFX share a queue, so do not concurrently benchmark them merely to verify integration.
@@ -0,0 +1,24 @@
# ASR and speech enhancement
## ASR
Multipart /v1/asr/transcribe: file plus optional form fields. Default language is **English**, not automatic detection. Chinese recordings should explicitly use zh. Aliases: zh/zh-cn/zh-tw → Chinese, en → English, ja/jp → Japanese, ko/kr → Korean. Other values go to the model directly; auto is not an auto-detection guarantee.
Controls: temperature=0 (0–2), max_tokens=8192 (1–8192), top_k=0 (0–1000), top_p=1 (0–1), optional repetition_penalty (.1–2). Do not inherit TTS sampling settings without reason.
Text is the primary result; segments may be empty and does not guarantee word/sentence timestamps or speaker separation. Token/TPS and total_time are model statistics, not audio duration or total HTTP latency. Review uncertain transcription before cloning.
~~~powershell
python '<skill-dir>/scripts/call_voice_api.py' asr --file speech.wav --language zh
~~~
## Enhancement
MossFormer2 enhance/denoise are aliases. Optional chunked controls model chunking, not HTTP streaming; use form true/false. RE-USE requires only file and has no public strength/chunk/provider control.
~~~powershell
python '<skill-dir>/scripts/call_voice_api.py' enhance --file noisy.wav --chunked true --out enhanced.wav
python '<skill-dir>/scripts/call_voice_api.py' reuse --file noisy.wav --out reuse.wav
~~~
Both return complete WAV and mix channels to mono; input is limited to 25 MiB/60 decoded seconds. Evaluate intelligibility, residual noise and speech damage with known speech/noise. Headers are not a quality score. These endpoints are not source separation, clean old-voice replacement inside mixed music, or stereo spatial preservation.
@@ -0,0 +1,37 @@
# Speech design, reading and cloning
Design takes JSON text (spoken content) and instruct (voice/delivery), provider qwen by default or voxcpm2. Reading takes JSON text, Qwen only; optional speaker is meaningful only with an actual supported list. Omit it otherwise; do not invent speaker names or use instruct/provider here.
Clone takes multipart file (reference speech), text (new utterance) and ref_text (accurate reference transcript), with provider rules below. Text/ref_text limit 4096 characters, instruct 1024. Reject blank speech; split long content at complete sentences and verify continuity rather than assuming identical delivery.
## Qwen
TTS language names such as Chinese/English or auto go to the model; ASR alias normalization differs.
| Control | Default | Request range |
|---|---|---|
| temperature | .9 | 0–2 |
| max_tokens | 4096 | 1–8192 |
| top_k | 50 | 0–1000 |
| top_p | 1 | 0–1 |
| repetition_penalty | 1.05 | .1–2 |
Effective current caps: **design 512**, reading/clone **4096** tokens. Budgets are not characters/seconds; a higher accepted request cannot bypass the cap. Small budgets can truncate. Defaults are a starting point, not a quality guarantee.
Qwen clone requires nonblank accurate ref_text; instruct is ignored. Use the recording's actual text, a UTF-8 transcript file, or reviewed ASR, never a filename/stem.
~~~powershell
python '<skill-dir>/scripts/call_voice_api.py' clone --provider qwen --file reference.wav --text '这是新的朗读内容。' --ref-text-file reference.txt --language Chinese --out clone.wav
~~~
## VoxCPM2
Only design/clone support it. Controls: cfg_value=2 (0–10), inference_timesteps=10 (1–200), normalize=false, denoise=false, retry_badcase=true. More steps/CFG can increase time without better sound.
Current deployment has no built-in denoiser; denoise=true returns 400. Qwen language/sampling/token controls do not reach this provider. The helper rejects wrong-provider controls. Retry_badcase is a model option, separate from HTTP retries.
Clone with ref_text is transcript-guided Hi-Fi; instruct does not apply. Without ref_text, instruct guides delivery and audio supplies the reference. Design instruct is combined with text by this provider; verify the actual spoken utterance.
~~~powershell
python '<skill-dir>/scripts/call_voice_api.py' clone --provider voxcpm2 --file reference.wav --text '今天的故事从这里开始。' --instruct '保持音色,轻松平静地朗读。' --denoise false --out clone.wav
~~~
@@ -0,0 +1,50 @@
# Verification record
Date: **2026-10-06**. Target: `https://ades.playones.com`. Basis: maintainer's API usage document dated the same day, public health/models/OpenAPI inspection, actual synthetic requests and offline behavior tests. No service source or deployment configuration was modified.
## Live coverage
Existing environment credentials authenticated successfully via the legacy OPENAI_API_KEY alias. Inputs were a short Chinese utterance, its generated reference WAV, deterministic low-amplitude added noise, short music/SFX prompts and synthetic lyrics. Requests ran sequentially, without retries. All generated media and detailed diagnostics remain outside the repository.
| Operation | Probe and observed result |
|---|---|
| Qwen ordinary reading | Chinese text, 256-token budget; valid mono PCM16 24 kHz WAV, 2.64 s. This became the reference. |
| Chinese ASR | Reference with language=zh; nonblank JSON text, known words matched. |
| Qwen design | Short text/style, Chinese, 256-token budget; mono 24 kHz, 2.08 s. |
| VoxCPM2 design | Short text/style, denoise=false; mono 48 kHz, 3.36 s. Generated utterance checked by ASR. |
| Qwen clone | Accurate reference text and new utterance; mono 24 kHz, 1.60 s. Utterance checked by ASR. |
| VoxCPM2 Hi-Fi clone | Same reference with ref_text; mono 48 kHz, 2.08 s. Utterance checked by ASR. |
| VoxCPM2 controlled clone | No ref_text, delivery instruct, denoise=false; mono 48 kHz, 1.92 s. Utterance checked by ASR. |
| MossFormer2 enhance | Noisy synthetic reference, chunked=true; mono 48 kHz, 2.64 s. |
| RE-USE | Same noisy reference; mono 24 kHz, 2.64 s. |
| Stable Audio music | 5 s, 4 steps, seed 7, cfg=1; stereo 44.1 kHz PCM16, exactly 5 s. |
| Stable Audio SFX | Same timing/settings, footsteps prompt; stereo 44.1 kHz PCM16, exactly 5 s. |
| Yue2 | Synthetic tagged Chinese lyrics, direct, 4 steps, seed 7, min 8/max 12 s; stereo 48 kHz PCM16, 11.999 s. |
| Real single-file batch | Explicit accurate reference text; source ASR followed by Qwen clone succeeded, CJK output names/raw WAV/manifest created. Rerun skipped the completed output. |
| Local MP3 | Generated batch WAV converted with explicit +4 dB, produced a usable nonempty MP3. Export was rechecked locally after the atomic-write fix. |
There were 18 small inference calls including four ASR content rechecks and the two batch calls. After punctuation removal, those four transcripts matched the intended utterances. All initial generated WAVs had nonzero PCM samples and no samples at PCM16 clipping extremes. These checks establish playable-format/signal/content evidence; they do **not** establish perceptual quality, speaker similarity, genre correctness, lyric completeness or denoising improvement.
Public GETs confirmed service responsiveness and current route/schema availability. Models not loaded or Yue2 stopped before inference were treated as lazy loading, not a failed model test. Stable Audio's OpenAPI step cap was 64 while the supplied deployment guide states effective cap 16; schema presence alone does not override runtime constraints.
## Local and independent validation
Run from the repository root:
```powershell
rtk proxy python -X utf8 -m unittest discover -s plugins/eapil-voice/skills/eapil-voice/tests -v
```
**22 regression tests passed**, with network mocked. Coverage includes public GET without credentials, environment precedence, Unicode/multiline/zero/false wire values, real multipart boundary/file content, provider controls, Yue2 mode/ABC constraints, WAV media/header/truncation, decoded duration, ASR shape/empty text, string/array/HTML errors, explicit busy-only retries/Retry-After, whole-file reupload, name collisions, overlap, per-file continuation, existing-output skip and failed conversion cleanup/preservation/manual rerun.
An independent offline use pass constructed VoxCPM2 clone, 5-second music and UTF-8 lyrics/ABC melody requests through the actual CLI. Mock HTTP drove actual multipart and batch processing; local ffmpeg exported two MP3s, WAVs and manifests. Empty ASR did not clone, a timeout was not replayed, and mixed successes/failures produced the correct summary/exit status. It discovered the partial-MP3 skip problem; atomic export and targeted failure/rerun tests fixed it. This pass did not call live models.
Skill frontmatter/UI metadata, local documentation links, root AGENTS.md and all five plugin manifests/assets/version were checked. IDs remain eapil-voice and version remains 1.0.0. Changed scripts also passed Ruff checks.
## Limits of this run
- No blind listening test, human voice-similarity evaluation, objective denoising score or long-audio benchmark was performed.
- Yue2 direct was live tested; melody/full/external ABC were checked offline only. No claim of live coverage for those modes.
- Denoise alias was checked through the client/offline routing; live enhancement used the primary enhance endpoint.
- Deployment caps, maximum-duration behavior, denied denoise=true, invalid-token behavior, real busy/503 handling, raw-binary uploads and browser CORS were not load-tested. Their guidance comes from the supplied contract/public schema; retry/error behavior was simulated offline.
- No broad concurrency tests, timeout resubmission, API/service reconfiguration, commit, push, tag or market publication occurred.
@@ -0,0 +1,26 @@
# Yue2 songs and ABC arrangements
JSON /v1/audio/music/yue2, current Yue2 3B Q8. Required nonblank style (1–4000 characters) and lyrics (1–30000). Unknown fields are rejected: Stable Audio prompt/seconds/cfg and legacy duration_seconds do not belong here.
| Field | Default | Range / meaning |
|---|---|---|
| mode | full | direct, melody, full |
| abc | null | Nonblank if supplied, <=100000 characters; melody/full only |
| steps | 8 | 1–64 acoustic denoising steps |
| seed | 831001 | 0–9223372036854775807 |
| cfg_scale | 1 | 0–20 |
| min_duration_seconds | 8 | Integer 8–360 |
| max_duration_seconds | 120 | Integer 8–360, >=minimum |
Direct skips ABC planning. Melody plans melody or uses supplied ABC; full plans melody/harmony or uses supplied ABC. No WAV/MP3 reference is accepted; score arrangement is not recording-to-recording singing conversion.
Duration bounds map to 25 semantic tokens/second; they guide stopping, not exact length or natural endings. Large minimum can force repetition; short maximum can cut lyrics. Start at minimum 8, maximum 60–120 for real songs. Short probes do not establish full-song quality; fixed-length products may need requested trimming/fades.
Use [Verse]/[Chorus]/[Bridge] and UTF-8 lyric/ABC files. JS Number cannot represent all allowed 64-bit seeds; keep interoperable seeds <=2147483647 or handle exact integers.
~~~powershell
python '<skill-dir>/scripts/call_voice_api.py' yue2 --style 'Mandopop ballad, female vocal, piano and strings' --lyrics-file lyrics.txt --mode full --steps 8 --seed 7 --min-duration-seconds 8 --max-duration-seconds 60 --out song.wav
python '<skill-dir>/scripts/call_voice_api.py' yue2 --style 'Acoustic folk, intimate vocal' --lyrics-file lyrics.txt --mode melody --abc-file melody.abc --max-duration-seconds 30 --out arrangement.wav
~~~
Lyrics must not be blank/absent. [Instrumental] is experimental, not a guarantee; prefer Stable Audio for pure instrumentals. Current output is 48 kHz stereo PCM16. Lazy startup is handled by the service. Verify lyrics/music by listening; one direct probe does not establish melody/full/ABC.
@@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""Sequential ASR/clone export. Generated speech only; no timeout retries."""
from __future__ import annotations
import argparse
import json
import math
import re
import shutil
import subprocess
import sys
import time
import tempfile
from pathlib import Path
from types import SimpleNamespace
import call_voice_api as api
AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".wma"}
SAFE_NAME_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
def parse_args(argv=None):
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--base-url", default=api.env_base_url())
p.add_argument("--token", help="legacy direct token; prefer environment")
p.add_argument("--api-key-env")
p.add_argument("--input-dir", required=True)
p.add_argument("--reference-audio", required=True)
ref = p.add_mutually_exclusive_group()
ref.add_argument("--reference-text")
ref.add_argument("--reference-text-file")
p.add_argument("--reference-language", default="zh")
p.add_argument("--output-dir", required=True)
p.add_argument("--format", choices=["wav", "mp3"], default="wav")
p.add_argument("--gain-db", type=float, default=0)
p.add_argument("--provider", choices=["qwen", "voxcpm2"], default="qwen")
p.add_argument("--asr-language", default="zh")
p.add_argument("--clone-language", default="auto")
p.add_argument("--timeout", type=float, default=600)
p.add_argument(
"--retries",
type=int,
default=1,
help="maximum attempts for explicit busy 503 only; default 1",
)
p.add_argument(
"--normalize-text",
action="store_true",
help="opt in to whitespace normalization and terminal punctuation",
)
p.add_argument("--overwrite", action="store_true")
return p.parse_args(argv)
def audio_files(input_dir):
return sorted(
(
p
for p in input_dir.iterdir()
if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS
),
key=lambda p: p.name.casefold(),
)
def safe_stem(text):
name = SAFE_NAME_RE.sub("_", text).strip().strip(".")
if not name:
name = "untitled"
if re.fullmatch(r"(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])", name, re.I):
name = "_" + name
return name
def normalize_text(raw):
if not isinstance(raw, str) or not raw.strip():
raise ValueError(
"ASR returned no usable transcript; file names are not transcripts"
)
text = " ".join(raw.split())
if text[-1] not in "。!?.!?~~":
text += "。"
return text
def call_with_busy_retry(operation, attempts):
for index in range(attempts):
try:
return operation()
except api.APIError as exc:
if not exc.busy or index + 1 >= attempts:
raise
delay = (
exc.retry_after
if exc.retry_after is not None
else min(30, 2 ** (index + 1))
)
if delay > 60:
raise # Leave unusually long backoff to the calling application.
time.sleep(delay)
raise ValueError("attempt count must be at least 1")
def transcribe(args, source):
# Each optional busy retry rebuilds multipart from the complete file.
data = call_with_busy_retry(
lambda: api.post_multipart(
args,
api.PATHS["asr"],
{"language": args.asr_language, "temperature": 0},
str(source),
),
args.retries,
)
result = json.loads(data)
if not isinstance(result.get("text"), str) or not result["text"].strip():
raise ValueError("ASR returned an empty transcript")
return result
def reference_transcript(args):
if args.reference_text_file:
text = Path(args.reference_text_file).read_text(encoding="utf-8-sig")
elif args.reference_text is not None:
text = args.reference_text
else:
ref_args = SimpleNamespace(**vars(args))
ref_args.asr_language = args.reference_language
text = transcribe(ref_args, Path(args.reference_audio))["text"]
if not isinstance(text, str) or not text.strip() or len(text) > 4096:
raise ValueError(
"reference transcript must be nonblank and at most 4096 characters"
)
return text.strip()
def clone(args, ref_text, text):
fields = {"text": text, "ref_text": ref_text, "provider": args.provider}
if args.provider == "qwen":
fields["language"] = args.clone_language
api.validate_payload("clone", fields)
return call_with_busy_retry(
lambda: api.post_multipart(
args, api.PATHS["clone"], fields, args.reference_audio
),
args.retries,
)
def export_audio(raw_wav, output, gain_db, output_format):
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
dir=output.parent, prefix=".audio-export-", suffix=output.suffix, delete=False
) as handle:
temporary = Path(handle.name)
try:
if output_format == "wav" and gain_db == 0:
api.wav_info(raw_wav.read_bytes())
temporary.write_bytes(raw_wav.read_bytes())
else:
command = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
str(raw_wav),
]
if gain_db != 0:
command += ["-filter:a", f"volume={gain_db:.6f}dB"]
if output_format == "mp3":
command += ["-codec:a", "libmp3lame", "-q:a", "2"]
command.append(str(temporary))
if shutil.which("rtk"):
command = ["rtk", "proxy", *command]
result = subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode or not temporary.stat().st_size:
raise RuntimeError("ffmpeg export failed; inspect conversion locally")
if output_format == "wav":
api.wav_info(temporary.read_bytes())
temporary.replace(output)
finally:
temporary.unlink(missing_ok=True)
def prepare(args):
if (
not math.isfinite(args.gain_db)
or not math.isfinite(args.timeout)
or args.timeout <= 0
or args.retries < 1
or args.retries > 5
):
raise ValueError("invalid gain/timeout; retries must be 1–5")
api.build_url(args.base_url, api.PATHS["clone"])
source = Path(args.input_dir)
if not source.is_dir():
raise ValueError("input directory does not exist")
api.preflight_audio(
args.reference_audio,
args.provider == "qwen"
or not (args.reference_text or args.reference_text_file),
)
if (args.format == "mp3" or args.gain_db != 0) and not shutil.which("ffmpeg"):
raise ValueError("ffmpeg is required for MP3 or gain conversion")
files = audio_files(source)
names = [safe_stem(p.stem).casefold() for p in files]
if len(names) != len(set(names)):
raise ValueError(
"source names collide after output normalization; rename before generating"
)
output = Path(args.output_dir)
inputs = {p.resolve() for p in files} | {Path(args.reference_audio).resolve()}
for p in files:
for target in (
output / f"{safe_stem(p.stem)}.{args.format}",
output / "_raw_wav" / f"{safe_stem(p.stem)}.wav",
):
if target.resolve() in inputs:
raise ValueError("output must not overwrite input/reference audio")
api.preflight_audio(str(p))
return files
def main(argv=None):
args = parse_args(argv)
try:
files = prepare(args)
import os
args.token = args.token or (
os.getenv(args.api_key_env) if args.api_key_env else api.env_token()
)
api.bearer_token(args.token)
output_dir = Path(args.output_dir)
raw_dir, manifest_dir = output_dir / "_raw_wav", output_dir / "_manifests"
for path in (output_dir, raw_dir, manifest_dir):
path.mkdir(parents=True, exist_ok=True)
pending = [
p
for p in files
if args.overwrite
or not (output_dir / f"{safe_stem(p.stem)}.{args.format}").exists()
]
ref_text = reference_transcript(args) if pending else ""
summary = []
for source in files:
output = output_dir / f"{safe_stem(source.stem)}.{args.format}"
raw = raw_dir / f"{safe_stem(source.stem)}.wav"
if output.exists() and not args.overwrite:
record = {
"source": str(source),
"output": str(output),
"status": "skipped",
"reason": "exists",
}
else:
try:
asr = transcribe(args, source)
text = (
normalize_text(asr["text"])
if args.normalize_text
else asr["text"].strip()
)
audio = clone(args, ref_text, text)
api.write_output(str(raw), audio)
export_audio(raw, output, args.gain_db, args.format)
record = {
"source": str(source),
"output": str(output),
"raw_wav": str(raw),
"status": "ok",
"text": text,
"reference_text": ref_text,
"asr": asr,
"audio": api.wav_info(audio),
}
(manifest_dir / f"{safe_stem(source.stem)}.json").write_text(
json.dumps(record, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except (api.APIError, OSError, ValueError, RuntimeError) as exc:
record = {
"source": str(source),
"status": "error",
"error_type": type(exc).__name__,
}
if isinstance(exc, api.APIError):
record["http_status"] = exc.status
summary.append(record)
# Persist progress after each file; existing successful outputs are skipped on rerun.
(output_dir / "conversion_summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(
json.dumps(
{"file": source.name, "status": record["status"]},
ensure_ascii=False,
),
flush=True,
)
if not files:
(output_dir / "conversion_summary.json").write_text("[]", encoding="utf-8")
return int(any(r["status"] == "error" for r in summary))
except (api.APIError, OSError, ValueError, RuntimeError) as exc:
report = {"ok": False, "error_type": type(exc).__name__}
if isinstance(exc, api.APIError):
report["http_status"] = exc.status
else:
message = str(exc)
if args.token:
message = message.replace(args.token, "[redacted]")
report["message"] = message[:240]
print(json.dumps(report), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,566 @@
#!/usr/bin/env python3
"""Dependency-free EAPIL audio client. One request, no inference retries."""
from __future__ import annotations
import argparse
import json
import math
import mimetypes
import os
import struct
import sys
import uuid
from http.client import HTTPException
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
DEFAULT_BASE_URL = "https://ades.playones.com"
MAX_UPLOAD_BYTES = 26_214_400
PATHS = {
"health": "/health",
"models": "/models",
"design": "/v1/voice/design",
"synthesize": "/v1/voice/synthesize",
"clone": "/v1/voice/clone",
"asr": "/v1/asr/transcribe",
"enhance": "/v1/audio/enhance",
"denoise": "/v1/audio/denoise",
"reuse": "/v1/audio/reuse",
"music": "/v1/audio/music",
"sfx": "/v1/audio/sfx",
"yue2": "/v1/audio/music/yue2",
}
QWEN = ("language", "temperature", "max_tokens", "top_k", "top_p", "repetition_penalty")
VOX = ("cfg_value", "inference_timesteps", "normalize", "denoise", "retry_badcase")
class APIError(RuntimeError):
def __init__(self, status, detail, retry_after=None):
self.status, self.detail = status, detail
try:
delay = float(retry_after)
self.retry_after = delay if math.isfinite(delay) and delay >= 0 else None
except (TypeError, ValueError):
self.retry_after = None
text = detail.lower() if isinstance(detail, str) else ""
self.busy = (
status == 503
and ("busy" in text or "already running" in text)
and not any(
word in text
for word in ("failed", "error", "load", "connect", "timeout")
)
)
super().__init__(f"HTTP {status}; provider detail omitted, no automatic retry")
def env_base_url():
return (
os.getenv("AUDIO_API_BASE")
or os.getenv("QWEN3_VOICE_API_BASE_URL")
or DEFAULT_BASE_URL
)
def env_token():
return os.getenv("AUDIO_API_TOKEN") or os.getenv("OPENAI_API_KEY")
def bearer_token(token):
value = (token or "").strip()
if value.lower().startswith("bearer "):
value = value[7:].strip()
if not value or "\r" in value or "\n" in value:
raise ValueError(
"set AUDIO_API_TOKEN (legacy OPENAI_API_KEY supported), or --api-key-env"
)
return value
def build_url(base_url, path):
parts = urlsplit(base_url)
if (
parts.scheme not in {"http", "https"}
or not parts.netloc
or parts.username
or parts.password
or parts.query
or parts.fragment
):
raise ValueError(
"base URL must be an HTTP(S) service root without credentials/query/fragment"
)
if parts.path.rstrip("/").endswith("/v1"):
raise ValueError("audio base URL is the service root, without /v1")
return base_url.rstrip("/") + path
def clean_payload(values):
return {k: v for k, v in values.items() if v is not None}
def bool_value(raw):
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("boolean must be true or false")
def upload_bytes(file_path):
path = Path(file_path)
if not path.is_file() or not 0 < path.stat().st_size <= MAX_UPLOAD_BYTES:
raise ValueError("upload must be a nonempty file of at most 25 MiB")
return path.read_bytes()
def preflight_audio(file_path, limit_seconds=True):
data = upload_bytes(file_path)
if Path(file_path).suffix.lower() == ".wav":
info = wav_info(data)
if limit_seconds and info["frames"] / info["sample_rate"] > 60:
raise ValueError("decoded WAV exceeds the current 60-second input limit")
def multipart_body(*, fields, file_path, file_field="file"):
audio = upload_bytes(file_path)
boundary = "----eapil-audio-" + uuid.uuid4().hex
parts = []
for name, value in clean_payload(fields).items():
if any(c in name for c in '\r\n"'):
raise ValueError("invalid multipart field name")
value = str(value).lower() if isinstance(value, bool) else str(value)
parts.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode(
"utf-8"
)
)
filename = (
Path(file_path).name.replace('"', "_").replace("\r", "_").replace("\n", "_")
)
mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
parts.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; filename="{filename}"\r\nContent-Type: {mime}\r\n\r\n'.encode(
"utf-8"
)
)
parts.extend((audio, f"\r\n--{boundary}--\r\n".encode("ascii")))
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
def wav_info(data):
"""Check complete little-endian PCM/float WAV, including extensible headers."""
if len(data) < 12 or data[:4] != b"RIFF" or data[8:12] != b"WAVE":
raise ValueError("response is not RIFF/WAVE")
end = struct.unpack_from("<I", data, 4)[0] + 8
if end != len(data):
raise ValueError("WAV size mismatch or truncated response")
fmt, audio, pos = None, None, 12
while pos + 8 <= end:
kind, size = data[pos : pos + 4], struct.unpack_from("<I", data, pos + 4)[0]
start, stop = pos + 8, pos + 8 + size
if stop > end:
raise ValueError("truncated WAV chunk")
if kind == b"fmt ":
if size < 16:
raise ValueError("invalid WAV format")
fmt = struct.unpack_from("<HHIIHH", data, start)
if fmt[0] == 0xFFFE:
if size < 40 or struct.unpack_from("<H", data, start + 16)[0] < 22:
raise ValueError("invalid extensible WAV")
fmt = (struct.unpack_from("<H", data, start + 24)[0], *fmt[1:])
elif kind == b"data":
audio = size
pos = stop + size % 2
if pos != end or fmt is None or not audio:
raise ValueError("WAV lacks complete format/audio chunks")
code, channels, rate, byte_rate, align, bits = fmt
if (
code not in {1, 3}
or not channels
or not rate
or bits not in {8, 16, 24, 32, 64}
or align != channels * bits // 8
or byte_rate != rate * align
or audio % align
):
raise ValueError("invalid or unsupported WAV sample format")
return {
"sample_rate": rate,
"channels": channels,
"bits": bits,
"duration_s": round(audio / byte_rate, 3),
"frames": audio // align,
"bytes": len(data),
}
def http_request(
*,
method,
base_url,
path,
token=None,
body=None,
headers=None,
timeout=600,
expected="json",
):
if not math.isfinite(timeout) or timeout <= 0:
raise ValueError("timeout must be positive and finite")
hdr = dict(headers or {})
if path.startswith("/v1/"):
hdr["Authorization"] = "Bearer " + bearer_token(token)
hdr["Accept"] = "audio/wav" if expected == "wav" else "application/json"
req = Request(build_url(base_url, path), data=body, method=method, headers=hdr)
try:
with urlopen(req, timeout=timeout) as response:
media = (
response.headers.get("Content-Type", "")
.split(";", 1)[0]
.lower()
.strip()
)
data = response.read()
except HTTPError as exc:
raw = exc.read().decode("utf-8", "replace")
try:
parsed = json.loads(raw)
detail = (
parsed.get("detail", parsed) if isinstance(parsed, dict) else parsed
)
except ValueError:
detail = "non-JSON error response"
raise APIError(
exc.code, detail, exc.headers.get("Retry-After") if exc.headers else None
) from exc
except HTTPException as exc:
raise RuntimeError(
"incomplete HTTP response; outcome unknown, no automatic retry"
) from exc
if expected == "wav":
if media not in {"audio/wav", "audio/x-wav", "audio/wave"}:
raise ValueError("successful response is not WAV media")
wav_info(data)
else:
if media != "application/json":
raise ValueError("successful response is not JSON media")
value = json.loads(data)
if not isinstance(value, dict):
raise ValueError("expected a JSON object")
if path == PATHS["asr"] and not isinstance(value.get("text"), str):
raise ValueError("ASR response must contain string text")
return data
def post_json(args, path, payload):
return http_request(
method="POST",
base_url=args.base_url,
path=path,
token=args.token,
body=json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8"),
headers={"Content-Type": "application/json"},
timeout=args.timeout,
expected="wav",
)
def post_multipart(args, path, fields, file_path):
body, media = multipart_body(fields=fields, file_path=file_path)
return http_request(
method="POST",
base_url=args.base_url,
path=path,
token=args.token,
body=body,
headers={"Content-Type": media},
timeout=args.timeout,
expected="json" if path == PATHS["asr"] else "wav",
)
def write_output(path, data):
wav_info(data)
target = Path(path)
if target.suffix.lower() != ".wav":
raise ValueError("API output is WAV; transcode separately for MP3")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
def validate_payload(command, payload):
required = {
"design": ("text", "instruct"),
"synthesize": ("text",),
"clone": ("text",),
"music": ("prompt",),
"sfx": ("prompt",),
"yue2": ("style", "lyrics"),
}.get(command, ())
limits = {
"text": 4096,
"ref_text": 4096,
"instruct": 1024,
"prompt": 2000,
"negative_prompt": 2000,
"style": 4000,
"lyrics": 30000,
"abc": 100000,
}
for key in required:
if not isinstance(payload.get(key), str) or not payload[key].strip():
raise ValueError(f"{key} must be nonblank text")
for key, limit in limits.items():
if key in payload and (
not isinstance(payload[key], str)
or len(payload[key]) > limit
or key in {"ref_text", "abc"}
and not payload[key].strip()
):
raise ValueError(f"invalid {key} or exceeds {limit} characters")
ranges = {
"temperature": (0, 2),
"max_tokens": (1, 8192),
"top_k": (0, 1000),
"top_p": (0, 1),
"repetition_penalty": (0.1, 2),
"cfg_value": (0, 10),
"inference_timesteps": (1, 200),
"cfg": (0, 10),
"apg": (0, 1),
"steps": (1, 64),
"cfg_scale": (0, 20),
"min_duration_seconds": (8, 360),
"max_duration_seconds": (8, 360),
"seed": (0, 9223372036854775807 if command == "yue2" else 2147483647),
}
for key, (low, high) in ranges.items():
if key in payload and (
isinstance(payload[key], bool)
or not isinstance(payload[key], (int, float))
or not math.isfinite(payload[key])
or not low <= payload[key] <= high
):
raise ValueError(f"{key} must be between {low} and {high}")
if "seconds" in payload and not (
math.isfinite(payload["seconds"]) and 0 < payload["seconds"] <= 380
):
raise ValueError("seconds must be greater than 0 and at most 380")
if command in {"design", "clone"}:
provider = payload.get("provider", "qwen")
if provider not in {"qwen", "voxcpm2"}:
raise ValueError("provider must be qwen or voxcpm2")
ignored = VOX if provider == "qwen" else QWEN
if any(k in payload for k in ignored):
raise ValueError("control fields must match the selected provider")
if command == "clone":
if provider == "qwen" and not payload.get("ref_text", "").strip():
raise ValueError(
"Qwen clone requires the accurate reference transcript"
)
if "instruct" in payload and (provider == "qwen" or "ref_text" in payload):
raise ValueError(
"clone instruct only applies to VoxCPM2 without ref_text"
)
if command == "yue2":
if payload.get("mode", "full") == "direct" and "abc" in payload:
raise ValueError("ABC is allowed only in melody/full mode")
if payload.get("max_duration_seconds", 120) < payload.get(
"min_duration_seconds", 8
):
raise ValueError("maximum duration must not be less than minimum")
def build_parser():
parser = argparse.ArgumentParser(description=__doc__)
subs = parser.add_subparsers(dest="command", required=True)
for command in PATHS:
sub = subs.add_parser(command)
sub.add_argument("--base-url", default=env_base_url())
sub.add_argument("--token", help="legacy direct token; prefer environment")
sub.add_argument("--api-key-env", help="explicit token environment variable")
sub.add_argument(
"--timeout",
type=float,
default=1800 if command in {"music", "sfx", "yue2"} else 600,
)
sub.add_argument("--dry-run", action="store_true")
if command in {"health", "models"}:
continue
if command != "asr":
sub.add_argument(
"--out",
default={
"design": "voice_design.wav",
"clone": "voice_clone.wav",
"enhance": "enhanced.wav",
"denoise": "enhanced.wav",
}.get(command, f"{command}.wav"),
)
if command in {"clone", "asr", "enhance", "denoise", "reuse"}:
sub.add_argument("--file", required=True)
if command in {"design", "synthesize", "clone"}:
sub.add_argument("--text", required=True)
if command in {"design", "clone"}:
sub.add_argument("--instruct", required=command == "design")
sub.add_argument("--provider", choices=["qwen", "voxcpm2"], default="qwen")
for key in VOX:
kind = (
bool_value
if key in {"normalize", "denoise", "retry_badcase"}
else int
if key == "inference_timesteps"
else float
)
sub.add_argument("--" + key.replace("_", "-"), type=kind)
if command == "clone":
sub.add_argument("--ref-text")
sub.add_argument("--ref-text-file")
if command == "synthesize":
sub.add_argument("--speaker")
if command in {"design", "synthesize", "clone", "asr"}:
for key in QWEN:
kind = (
str
if key == "language"
else int
if key in {"max_tokens", "top_k"}
else float
)
sub.add_argument("--" + key.replace("_", "-"), type=kind)
if command in {"enhance", "denoise"}:
sub.add_argument("--chunked", type=bool_value)
if command in {"music", "sfx"}:
sub.add_argument("--prompt", required=True)
sub.add_argument("--negative-prompt")
sub.add_argument(
"--seconds", type=float, default=30 if command == "music" else 5
)
sub.add_argument("--steps", type=int, default=8)
sub.add_argument("--seed", type=int)
sub.add_argument("--cfg", type=float, default=1.0)
sub.add_argument("--apg", type=float, default=1.0)
if command == "yue2":
sub.add_argument("--style", required=True)
lyrics = sub.add_mutually_exclusive_group(required=True)
lyrics.add_argument("--lyrics")
lyrics.add_argument("--lyrics-file")
sub.add_argument(
"--mode", choices=["direct", "melody", "full"], default="full"
)
sub.add_argument("--abc-file")
sub.add_argument("--steps", type=int, default=8)
sub.add_argument("--seed", type=int, default=831001)
sub.add_argument("--cfg-scale", type=float, default=1.0)
sub.add_argument("--min-duration-seconds", type=int, default=8)
sub.add_argument("--max-duration-seconds", type=int, default=120)
return parser
def payload_from_args(args):
excluded = {
"command",
"base_url",
"token",
"api_key_env",
"timeout",
"dry_run",
"out",
"file",
"ref_text_file",
"lyrics_file",
"abc_file",
}
payload = clean_payload({k: v for k, v in vars(args).items() if k not in excluded})
for key in ("ref_text", "lyrics", "abc"):
path = getattr(args, key + "_file", None)
if path:
if key in payload:
raise ValueError(f"supply {key} text or file, not both")
payload[key] = Path(path).read_text(encoding="utf-8-sig")
validate_payload(args.command, payload)
return payload
def main(argv=None):
args = build_parser().parse_args(argv)
try:
url = build_url(args.base_url, PATHS[args.command])
if not math.isfinite(args.timeout) or args.timeout <= 0:
raise ValueError("timeout must be positive and finite")
payload = payload_from_args(args)
file = getattr(args, "file", None)
if file:
preflight_audio(
file, not (args.command == "clone" and args.provider == "voxcpm2")
)
if hasattr(args, "out") and Path(args.out).suffix.lower() != ".wav":
raise ValueError("use a .wav output path")
if args.dry_run:
print(
json.dumps(
{
"url": url,
"payload": payload,
"file": file,
"timeout": args.timeout,
},
ensure_ascii=False,
indent=2,
)
)
return 0
args.token = args.token or (
os.getenv(args.api_key_env) if args.api_key_env else env_token()
)
if args.command in {"health", "models"}:
data = http_request(
method="GET",
base_url=args.base_url,
path=PATHS[args.command],
timeout=args.timeout,
)
elif file:
data = post_multipart(args, PATHS[args.command], payload, file)
else:
data = post_json(args, PATHS[args.command], payload)
if args.command in {"asr", "health", "models"}:
print(json.dumps(json.loads(data), ensure_ascii=False, indent=2))
else:
write_output(args.out, data)
print(
json.dumps(
{
"ok": True,
"output": str(Path(args.out).resolve()),
**wav_info(data),
},
ensure_ascii=False,
)
)
return 0
except (APIError, URLError, OSError, ValueError, RuntimeError) as exc:
report = {"ok": False, "error_type": type(exc).__name__}
if isinstance(exc, APIError):
report.update(http_status=exc.status, confirmed_busy=exc.busy)
elif isinstance(exc, (URLError, TimeoutError)):
report["message"] = (
"connection/timeout; outcome unknown, no automatic retry"
)
else:
message = str(exc)
if args.token:
message = message.replace(args.token, "[redacted]")
report["message"] = message[:240]
print(json.dumps(report, ensure_ascii=False), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,486 @@
from __future__ import annotations
import contextlib
import io
import json
import os
import sys
import tempfile
import unittest
import wave
from email.parser import BytesParser
from email.policy import default
from pathlib import Path
from unittest import mock
from urllib.error import HTTPError
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
import call_voice_api as api
import batch_clone_generated_audio as batch
def wav(seconds=0.02):
out = io.BytesIO()
with wave.open(out, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(24000)
w.writeframes(b"\x01\x00" * int(24000 * seconds))
return out.getvalue()
def response(data, media):
r = io.BytesIO(data)
r.headers = {"Content-Type": media}
return r
class ClientTests(unittest.TestCase):
def setUp(self):
p = mock.patch.object(
api, "urlopen", side_effect=AssertionError("unexpected network")
)
self.net = p.start()
self.addCleanup(p.stop)
def test_public_status_needs_no_auth(self):
self.net.side_effect = None
self.net.return_value = response(b'{"status":"ok"}', "application/json")
api.http_request(method="GET", base_url="https://example.test", path="/health")
req = self.net.call_args.args[0]
self.assertIsNone(req.get_header("Authorization"))
self.assertEqual(self.net.call_args.kwargs["timeout"], 600)
def test_environment_priority_and_explicit_variable(self):
with mock.patch.dict(
os.environ,
{
"AUDIO_API_TOKEN": "new",
"OPENAI_API_KEY": "old",
"AUDIO_API_BASE": "https://new.test",
"QWEN3_VOICE_API_BASE_URL": "https://old.test",
},
clear=True,
):
self.assertEqual(api.env_token(), "new")
self.assertEqual(api.env_base_url(), "https://new.test")
for base in (
"file:///tmp",
"https://token@example.test",
"https://example.test/v1",
"https://example.test?q=secret",
):
with self.subTest(base=base), self.assertRaises(ValueError):
api.build_url(base, "/health")
def test_json_wire_preserves_multiline_unicode_zero_and_false(self):
args = api.build_parser().parse_args(
[
"yue2",
"--style",
"中文",
"--lyrics",
"[Verse]\n海风",
"--seed",
"0",
"--cfg-scale",
"0",
]
)
args.token = "test-key"
payload = api.payload_from_args(args)
self.net.side_effect = None
self.net.return_value = response(wav(), "audio/wav")
api.post_json(args, api.PATHS["yue2"], payload)
req = self.net.call_args.args[0]
self.assertEqual(json.loads(req.data), payload)
self.assertEqual(payload["seed"], 0)
self.assertEqual(payload["lyrics"], "[Verse]\n海风")
self.assertNotIn("seconds", payload)
vox = api.build_parser().parse_args(
[
"design",
"--provider",
"voxcpm2",
"--text",
"你好",
"--instruct",
"平静",
"--denoise",
"false",
"--cfg-value",
"0",
]
)
self.assertIs(api.payload_from_args(vox)["denoise"], False)
def test_multipart_has_real_boundary_complete_file_and_false_zero(self):
with tempfile.TemporaryDirectory() as d:
file = Path(d) / "参考.wav"
file.write_bytes(wav())
body, media = api.multipart_body(
fields={"chunked": False, "temperature": 0, "ref_text": "你好"},
file_path=str(file),
)
msg = BytesParser(policy=default).parsebytes(
("Content-Type: " + media + "\r\nMIME-Version: 1.0\r\n\r\n").encode()
+ body
)
parts = list(msg.iter_parts())
values = {
p.get_param("name", header="content-disposition"): p.get_payload(
decode=True
)
for p in parts
}
self.assertEqual(values["file"], file.read_bytes())
self.assertEqual(values["chunked"], b"false")
self.assertEqual(values["temperature"], b"0")
self.assertEqual(values["ref_text"].decode(), "你好")
def test_success_media_and_truncated_wav_fail_before_writing(self):
cases = [
(b'{"error":"bad"}', "application/json"),
(b"<html>error</html>", "text/html"),
(b"garbage", "audio/wav"),
(wav()[:-2], "audio/wav"),
]
for data, media in cases:
with self.subTest(media=media), self.assertRaises(ValueError):
self.net.side_effect = None
self.net.return_value = response(data, media)
api.http_request(
method="POST",
base_url="https://example.test",
path=api.PATHS["music"],
token="test",
expected="wav",
)
def test_wav_header_durations_and_preflight(self):
self.assertEqual(api.wav_info(wav())["sample_rate"], 24000)
with tempfile.TemporaryDirectory() as d:
file = Path(d) / "long.wav"
file.write_bytes(wav(60.1))
with self.assertRaises(ValueError):
api.preflight_audio(str(file))
api.preflight_audio(str(file), False)
file.write_bytes(wav(60 + 1 / 24000))
with self.assertRaises(ValueError):
api.preflight_audio(str(file))
file.write_bytes(b"not a WAV")
with self.assertRaises(ValueError):
api.preflight_audio(str(file))
with tempfile.TemporaryDirectory() as d:
out = Path(d) / "out.wav"
with self.assertRaises(ValueError):
api.write_output(str(out), wav()[:-1])
self.assertFalse(out.exists())
def test_asr_invalid_json_or_wrong_text_type_fails(self):
for data in (b"not json", b'{"text":7}', b"[]"):
self.net.side_effect = None
self.net.return_value = response(data, "application/json")
with self.assertRaises(ValueError):
api.http_request(
method="POST",
base_url="https://example.test",
path=api.PATHS["asr"],
token="test",
)
def test_error_detail_string_array_html_and_no_retry(self):
for raw in (
b'{"detail":"busy"}',
b'{"detail":[{"msg":"invalid"}]}',
b"<html>bad</html>",
):
self.net.reset_mock()
self.net.side_effect = HTTPError(
"https://example.test", 503, "error", {}, io.BytesIO(raw)
)
with self.assertRaises(api.APIError) as caught:
api.http_request(
method="POST",
base_url="https://example.test",
path=api.PATHS["music"],
token="test",
)
self.assertEqual(caught.exception.status, 503)
self.net.assert_called_once()
self.assertFalse(caught.exception.busy)
def test_provider_constraints_and_yue2_combinations(self):
for command, payload in [
("clone", {"provider": "qwen", "text": "new"}),
(
"design",
{
"provider": "voxcpm2",
"text": "new",
"instruct": "style",
"temperature": 0,
},
),
(
"clone",
{
"provider": "voxcpm2",
"text": "new",
"ref_text": "reference",
"instruct": "ignored",
},
),
(
"yue2",
{"style": "folk", "lyrics": "song", "mode": "direct", "abc": "X:1"},
),
(
"yue2",
{
"style": "folk",
"lyrics": "song",
"min_duration_seconds": 30,
"max_duration_seconds": 8,
},
),
("music", {"prompt": " "}),
("synthesize", {"text": "new", "temperature": float("nan")}),
]:
with self.subTest(command=command), self.assertRaises(ValueError):
api.validate_payload(command, payload)
def test_new_routes_and_lyric_files_dry_run_require_no_key(self):
with tempfile.TemporaryDirectory() as d:
file = Path(d) / "speech.wav"
file.write_bytes(wav())
lyrics = Path(d) / "歌词.txt"
lyrics.write_text("[Verse]\n你好", encoding="utf-8-sig")
commands = [
["reuse", "--file", str(file)],
["music", "--prompt", "piano"],
["sfx", "--prompt", "rain"],
["yue2", "--style", "folk", "--lyrics-file", str(lyrics)],
]
for argv in commands:
output = io.StringIO()
with (
mock.patch.dict(os.environ, {}, clear=True),
contextlib.redirect_stdout(output),
):
self.assertEqual(api.main(argv + ["--dry-run"]), 0)
value = json.loads(output.getvalue())
self.assertTrue(value["url"].endswith(api.PATHS[argv[0]]))
self.assertNotIn("token", value)
self.net.assert_not_called()
class BatchTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
(self.root / "inputs").mkdir()
self.ref = self.root / "misleading-name.wav"
self.ref.write_bytes(wav())
self.src = self.root / "inputs" / "source.wav"
self.src.write_bytes(wav())
self.argv = [
"--input-dir",
str(self.root / "inputs"),
"--reference-audio",
str(self.ref),
"--output-dir",
str(self.root / "out"),
"--token",
"test",
]
p = mock.patch.object(
api, "urlopen", side_effect=AssertionError("unexpected network")
)
self.net = p.start()
self.addCleanup(p.stop)
def test_reference_uses_explicit_or_one_asr_never_stem(self):
args = batch.parse_args(self.argv)
with mock.patch.object(
batch, "transcribe", return_value={"text": "actual words"}
) as transcribe:
self.assertEqual(batch.reference_transcript(args), "actual words")
transcribe.assert_called_once()
args.reference_text = "explicit actual words"
with mock.patch.object(batch, "transcribe") as transcribe:
self.assertEqual(batch.reference_transcript(args), "explicit actual words")
transcribe.assert_not_called()
def test_empty_asr_never_clones_and_next_file_continues(self):
(self.root / "inputs" / "second.wav").write_bytes(wav())
with (
mock.patch.object(
batch, "transcribe", side_effect=[ValueError("empty"), {"text": "你好"}]
),
mock.patch.object(batch, "clone", return_value=wav()) as clone,
contextlib.redirect_stdout(io.StringIO()),
):
code = batch.main(self.argv + ["--reference-text", "known reference"])
self.assertEqual(code, 1)
clone.assert_called_once()
summary = json.loads(
(self.root / "out" / "conversion_summary.json").read_text(encoding="utf-8")
)
self.assertEqual([r["status"] for r in summary], ["error", "ok"])
self.assertEqual(summary[1]["text"], "你好")
def test_successful_outputs_skip_without_reference_asr_or_clone(self):
(self.root / "out").mkdir()
(self.root / "out" / "source.wav").write_bytes(wav())
with (
mock.patch.object(batch, "transcribe") as transcribe,
mock.patch.object(batch, "clone") as clone,
contextlib.redirect_stdout(io.StringIO()),
):
self.assertEqual(batch.main(self.argv), 0)
transcribe.assert_not_called()
clone.assert_not_called()
def test_empty_recognition_does_not_fall_back_to_name(self):
args = batch.parse_args(self.argv)
with (
mock.patch.object(api, "post_multipart", return_value=b'{"text":""}'),
self.assertRaises(ValueError),
):
batch.transcribe(args, self.src)
with self.assertRaises(ValueError):
batch.normalize_text("")
self.assertEqual(batch.normalize_text("你好"), "你好。")
def test_only_confirmed_busy_retries_and_last_failure_never_sleeps(self):
op = mock.Mock(side_effect=[api.APIError(503, "model busy"), b"ok"])
with mock.patch.object(batch.time, "sleep") as sleep:
self.assertEqual(batch.call_with_busy_retry(op, 2), b"ok")
sleep.assert_called_once()
for err in [
api.APIError(500, "busy"),
api.APIError(502, "busy"),
api.APIError(504, "timeout"),
api.APIError(503, "failed to load model"),
TimeoutError("unknown outcome"),
]:
op = mock.Mock(side_effect=err)
with (
mock.patch.object(batch.time, "sleep") as sleep,
self.assertRaises(type(err)),
):
batch.call_with_busy_retry(op, 3)
op.assert_called_once()
sleep.assert_not_called()
def test_busy_retry_reuploads_full_file(self):
args = batch.parse_args(self.argv)
seen = []
def send(**kw):
seen.append(kw["body"])
if len(seen) == 1:
raise api.APIError(503, "busy")
return b'{"text":"known"}'
with (
mock.patch.object(api, "http_request", side_effect=send),
mock.patch.object(batch.time, "sleep"),
):
args.retries = 2
batch.transcribe(args, self.src)
self.assertEqual(len(seen), 2)
self.assertTrue(all(self.src.read_bytes() in body for body in seen))
def test_busy_retry_honors_server_delay_and_stops_on_long_delay(self):
op = mock.Mock(side_effect=[api.APIError(503, "busy", "7"), b"ok"])
with mock.patch.object(batch.time, "sleep") as sleep:
batch.call_with_busy_retry(op, 2)
sleep.assert_called_once_with(7)
op = mock.Mock(side_effect=api.APIError(503, "busy", "120"))
with (
mock.patch.object(batch.time, "sleep") as sleep,
self.assertRaises(api.APIError),
):
batch.call_with_busy_retry(op, 2)
op.assert_called_once()
sleep.assert_not_called()
def test_collision_overlap_and_missing_file_fail_before_inference(self):
args = batch.parse_args(self.argv)
(self.root / "inputs" / "source.mp3").write_bytes(b"compressed")
with self.assertRaises(ValueError):
batch.prepare(args)
(self.root / "inputs" / "source.mp3").unlink()
args.output_dir = str(self.root / "inputs")
with self.assertRaises(ValueError):
batch.prepare(args)
args.output_dir = str(self.root / "out")
args.reference_audio = str(self.root / "missing.wav")
with self.assertRaises(ValueError):
batch.prepare(args)
self.net.assert_not_called()
def test_vox_batch_does_not_send_qwen_language(self):
args = batch.parse_args(self.argv + ["--provider", "voxcpm2"])
with mock.patch.object(api, "post_multipart", return_value=wav()) as send:
batch.clone(args, "reference words", "new text")
self.assertNotIn("language", send.call_args.args[2])
self.assertEqual(send.call_args.args[2]["ref_text"], "reference words")
def test_wav_no_gain_export_preserves_bytes_without_ffmpeg(self):
raw = self.root / "raw.wav"
out = self.root / "out.wav"
raw.write_bytes(wav())
with mock.patch.object(batch.subprocess, "run") as run:
batch.export_audio(raw, out, 0, "wav")
run.assert_not_called()
self.assertEqual(out.read_bytes(), raw.read_bytes())
def test_failed_conversion_leaves_no_partial_final_and_preserves_old_output(self):
raw = self.root / "raw.wav"
raw.write_bytes(wav())
output = self.root / "out.mp3"
def failed(command, **kwargs):
Path(command[-1]).write_bytes(b"partial invalid MP3")
return mock.Mock(returncode=1)
with (
mock.patch.object(batch.subprocess, "run", side_effect=failed),
self.assertRaises(RuntimeError),
):
batch.export_audio(raw, output, 0, "mp3")
self.assertFalse(output.exists())
self.assertEqual(list(self.root.glob(".audio-export-*")), [])
output.write_bytes(b"existing completed output")
with (
mock.patch.object(batch.subprocess, "run", side_effect=failed),
self.assertRaises(RuntimeError),
):
batch.export_audio(raw, output, 0, "mp3")
self.assertEqual(output.read_bytes(), b"existing completed output")
def test_failed_conversion_is_not_skipped_on_manual_rerun(self):
def failed(command, **kwargs):
Path(command[-1]).write_bytes(b"partial MP3")
return mock.Mock(returncode=1)
argv = self.argv + ["--reference-text", "known", "--format", "mp3"]
with (
mock.patch.object(
batch, "transcribe", return_value={"text": "known"}
) as transcribe,
mock.patch.object(batch, "clone", return_value=wav()),
mock.patch.object(batch.subprocess, "run", side_effect=failed),
contextlib.redirect_stdout(io.StringIO()),
):
self.assertEqual(batch.main(argv), 1)
self.assertFalse((self.root / "out" / "source.mp3").exists())
self.assertEqual(batch.main(argv), 1)
self.assertEqual(transcribe.call_count, 2)
if __name__ == "__main__":
unittest.main()