From 4e572a4377f3164a2d4b7bd62876a63a86494551 Mon Sep 17 00:00:00 2001 From: chenmingxuan Date: Thu, 15 Oct 2026 10:52:04 +0800 Subject: [PATCH] feat(voice): add ACE-Step Turbo and release 1.1.0 --- AGENTS.md | 2 +- README.md | 13 +- VERSION | 2 +- docs/releases/1.1.0.md | 31 ++ plugins/design-md/.codex-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- plugins/eapil-voice/.codex-plugin/plugin.json | 6 +- .../eapil-voice/skills/eapil-voice/SKILL.md | 7 +- .../skills/eapil-voice/references/acestep.md | 77 +++++ .../skills/eapil-voice/references/api.md | 10 +- .../skills/eapil-voice/references/music.md | 2 +- .../eapil-voice/references/verification.md | 12 + .../skills/eapil-voice/references/yue2.md | 2 +- .../eapil-voice/scripts/call_voice_api.py | 120 ++++++- .../eapil-voice/tests/test_acestep_client.py | 304 ++++++++++++++++++ .../.codex-plugin/plugin.json | 2 +- 17 files changed, 569 insertions(+), 27 deletions(-) create mode 100644 docs/releases/1.1.0.md create mode 100644 plugins/eapil-voice/skills/eapil-voice/references/acestep.md create mode 100644 plugins/eapil-voice/skills/eapil-voice/tests/test_acestep_client.py diff --git a/AGENTS.md b/AGENTS.md index f598a03..f18d880 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Overview -本仓库集中维护 5 个 Codex 插件及其 skill 源码。日常按插件逐个更新,发布时将所有插件作为一个整体发版。统一版本以根目录 `VERSION` 为准,当前基线为 1.0.0。 +本仓库集中维护 5 个 Codex 插件及其 skill 源码。日常按插件逐个更新,发布时将所有插件作为一个整体发版。统一版本以根目录 `VERSION` 为准,当前统一版本为 1.1.0。 ## Scope diff --git a/README.md b/README.md index a74986c..6719579 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,20 @@ Git 远程:,初始分支:`main`。 -当前统一版本:**1.0.0**,以根目录 [VERSION](VERSION) 为准。日常逐个更新 skill,发布时整体发版,所有插件版本保持一致。仓库协作规则见 [AGENTS.md](AGENTS.md)。 +当前统一版本:**1.1.0**,以根目录 [VERSION](VERSION) 为准。日常逐个更新 skill,发布时整体发版,所有插件版本保持一致。仓库协作规则见 [AGENTS.md](AGENTS.md)。 ## 发布记录 +- **[v1.1.0](https://git.playones.com/chenmingxuan/ManySkills/releases/tag/v1.1.0)**(2026-10-15):eapil-voice 新增 ACE-Step 1.5 Turbo 纯音乐与歌词歌曲调用,五个插件统一升至 1.1.0。见 [发布说明](docs/releases/1.1.0.md)。Release 提供合集、内含五个独立插件 ZIP 的下载包及 SHA256 校验文件。 - **[v1.0.0](https://git.playones.com/chenmingxuan/ManySkills/releases/tag/v1.0.0)**(2026-10-06):首次整体发布,包含全部 5 个插件的更新。变更、兼容性与验证范围见 [发布说明](docs/releases/1.0.0.md)。Release 提供合集、各插件独立 ZIP 和 SHA256 校验文件。 | 插件 | 市场显示名 | 统一版本 | 入口 | | --- | --- | --- | --- | -| `generate-agents-md` | AGENTS.md生成器 | 1.0.0 | [SKILL.md](plugins/generate-agents-md/skills/generate-agents-md/SKILL.md) | -| `design-md` | DESIGN.md生成器 | 1.0.0 | [SKILL.md](plugins/design-md/skills/design-md/SKILL.md) | -| `eapil-gpt2-image` | eapil-gpt2-image | 1.0.0 | [SKILL.md](plugins/eapil-gpt2-image/skills/eapil-gpt2-image/SKILL.md) | -| `eapil-llmapi-structured` | 易瞳LLM调用指南 | 1.0.0 | [SKILL.md](plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/SKILL.md) | -| `eapil-voice` | 易瞳音频:语音 / 转录 / 降噪 / 音乐 / 音效 | 1.0.0 | [SKILL.md](plugins/eapil-voice/skills/eapil-voice/SKILL.md) | +| `generate-agents-md` | AGENTS.md生成器 | 1.1.0 | [SKILL.md](plugins/generate-agents-md/skills/generate-agents-md/SKILL.md) | +| `design-md` | DESIGN.md生成器 | 1.1.0 | [SKILL.md](plugins/design-md/skills/design-md/SKILL.md) | +| `eapil-gpt2-image` | eapil-gpt2-image | 1.1.0 | [SKILL.md](plugins/eapil-gpt2-image/skills/eapil-gpt2-image/SKILL.md) | +| `eapil-llmapi-structured` | 易瞳LLM调用指南 | 1.1.0 | [SKILL.md](plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/SKILL.md) | +| `eapil-voice` | 易瞳音频:语音 / 转录 / 降噪 / 音乐 / 音效 | 1.1.0 | [SKILL.md](plugins/eapil-voice/skills/eapil-voice/SKILL.md) | ## 目录结构 diff --git a/VERSION b/VERSION index 3eefcb9..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 diff --git a/docs/releases/1.1.0.md b/docs/releases/1.1.0.md new file mode 100644 index 0000000..00a9dae --- /dev/null +++ b/docs/releases/1.1.0.md @@ -0,0 +1,31 @@ +# ManySkills 1.1.0 + +发布日期:2026-10-15。整体标签:`v1.1.0`。五个插件统一升至 1.1.0,本轮功能更新集中在 eapil-voice。 + +## eapil-voice:ACE-Step 1.5 Turbo + +- 新增 `POST /v1/audio/music/acestep` 和客户端 `acestep` 命令,覆盖纯音乐、指定歌词歌曲及可选 0.6B 语义 LM。 +- 新增按需调用说明、UTF-8 歌词文件示例及参数校验:歌词模式冲突、10–600 秒、1–20 步、32 位无符号种子、语言、BPM、调性和拍号;拒绝混入其他端点的参数。 +- 保留显式 false/0 和多行中文歌词,读取响应 `X-Audio-Seed` 中的实际种子,校验完整 WAV 后保存。 +- 默认客户端超时 900 秒,失败不自动重试;区分 busy、未安装、内部超时和代理超时。成功音频按 WAV 二进制处理,不因生成的 OpenAPI 200 条目写成 JSON 而误用解析方式。 + +## 兼容性 + +- 现有插件 ID、鉴权环境变量、Stable Audio 的 `music`/`sfx` 和 Yue2 的 `yue2` 路由保留;通过新命令显式选择 ACE-Step。 +- ACE-Step 使用本服务的受限统一协议,不支持上游协议中的参考音频、翻唱、局部重绘、续写、分轨或批量生成。已有服务 key 或已开通的 KeyInfo 成员 key 仍通过环境变量提供,不需要单独的 ACE-Step key。 +- generate-agents-md、design-md、eapil-gpt2-image、eapil-llmapi-structured 本轮仅同步 manifest 版本;本次为兼容新功能的整体 minor 发布。 + +## 验证与范围 + +- 77 项离线回归通过:AGENTS 19、图像 13、LLM 15、音频 30(含 8 项新增 ACE-Step 测试)。 +- 根 AGENTS.md、skill frontmatter、插件 manifest/资源/统一版本、文档链接及修改脚本的 Ruff 正确性与导入检查通过。 +- 2026-10-15 公网 `/models` 与 `/openapi.json` 已核对模型、路由和请求 schema;纯音乐 CLI dry-run 与模拟 HTTP 歌词文件调用通过。 +- 本轮未提交 ACE-Step 线上推理,也未进行试听或质量评估;维护者文档中的本机基准不作为公网性能或歌曲质量承诺。详情见 [音频验证记录](../../plugins/eapil-voice/skills/eapil-voice/references/verification.md)。 + +## 下载 + +- `ManySkills-1.1.0.zip`:完整仓库合集,插件位于 `plugins/`。 +- `ManySkills-plugins-1.1.0.zip`:内含五个独立插件 ZIP,每个插件包根目录包含 `.codex-plugin/`、`skills/`、`assets/`。 +- `SHA256SUMS.txt`:合集、独立插件集合包及五个插件 ZIP 的 SHA256 校验值。 + +发布包来自标签对应提交的跟踪文件,不含凭据、运行日志或生成媒体。本次发布至 Git/Gitea,原技能市场的同步另行执行。 diff --git a/plugins/design-md/.codex-plugin/plugin.json b/plugins/design-md/.codex-plugin/plugin.json index 3490239..1620d7b 100644 --- a/plugins/design-md/.codex-plugin/plugin.json +++ b/plugins/design-md/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "design-md", - "version": "1.0.0", + "version": "1.1.0", "description": "创建或完善 DESIGN.md,维护项目设计读取入口,并应用、核对界面设计规则。", "author": { "name": "EAPIL", diff --git a/plugins/eapil-gpt2-image/.codex-plugin/plugin.json b/plugins/eapil-gpt2-image/.codex-plugin/plugin.json index 6ae5d66..5b7a4c5 100644 --- a/plugins/eapil-gpt2-image/.codex-plugin/plugin.json +++ b/plugins/eapil-gpt2-image/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "eapil-gpt2-image", - "version": "1.0.0", + "version": "1.1.0", "description": "通过 EAPIL 图像 API 生成、编辑图像或完善提示词,保留精确文字、参考图角色和用户设计意图。", "author": { "name": "EAPIL", diff --git a/plugins/eapil-llmapi-structured/.codex-plugin/plugin.json b/plugins/eapil-llmapi-structured/.codex-plugin/plugin.json index 25ee719..ae5102f 100644 --- a/plugins/eapil-llmapi-structured/.codex-plugin/plugin.json +++ b/plugins/eapil-llmapi-structured/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "eapil-llmapi-structured", - "version": "1.0.0", + "version": "1.1.0", "description": "易瞳 LLM API 通用调用指南:选择、构建和实测 Chat、Responses、结构化输出、工具、视觉、流式、Embedding、Rerank 与 Decisions 请求,核对模型和中转渠道的实际兼容性。", "author": { "name": "EAPIL", diff --git a/plugins/eapil-voice/.codex-plugin/plugin.json b/plugins/eapil-voice/.codex-plugin/plugin.json index 73dc0e0..a2543bd 100644 --- a/plugins/eapil-voice/.codex-plugin/plugin.json +++ b/plugins/eapil-voice/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "eapil-voice", - "version": "1.0.0", - "description": "易瞳音频服务调用指南:语音设计、朗读、克隆、ASR、MossFormer2/RE-USE 降噪、Stable Audio 音乐与音效、Yue2 歌曲及批量导出。包含请求校验、错误处理和实测记录。", + "version": "1.1.0", + "description": "易瞳音频服务调用指南:语音设计、朗读、克隆、ASR、MossFormer2/RE-USE 降噪、Stable Audio 音乐与音效、ACE-Step 1.5 Turbo 纯音乐/歌曲、Yue2 歌曲及批量导出。包含请求校验、错误处理和验证记录。", "author": { "name": "EAPIL", "url": "https://git.playones.com/arechen/EapilSkillMarket" @@ -18,7 +18,7 @@ "interface": { "displayName": "易瞳音频:语音 / 转录 / 降噪 / 音乐 / 音效", "shortDescription": "语音、ASR、降噪、音乐、音效与歌曲的调用及验证指南。", - "longDescription": "按需选择 Qwen/VoxCPM2 语音、转录、两类降噪、Stable Audio 和 Yue2 接口。配套单请求客户端与顺序批处理,校验 WAV/JSON 响应、使用准确参考转写,并明确超时不可盲目重发及部署限制。保留 eapil-voice 插件 ID。", + "longDescription": "按需选择 Qwen/VoxCPM2 语音、转录、两类降噪、Stable Audio、ACE-Step 1.5 Turbo 和 Yue2 接口。ACE-Step 支持显式纯音乐/歌词模式、语义 LM、调性拍号及实际种子读取。配套单请求客户端与顺序语音批处理,校验 WAV/JSON 响应,并明确超时不可盲目重发及部署限制。保留 eapil-voice 插件 ID。", "developerName": "EAPIL", "category": "Creativity", "capabilities": [ diff --git a/plugins/eapil-voice/skills/eapil-voice/SKILL.md b/plugins/eapil-voice/skills/eapil-voice/SKILL.md index 8310731..dcff066 100644 --- a/plugins/eapil-voice/skills/eapil-voice/SKILL.md +++ b/plugins/eapil-voice/skills/eapil-voice/SKILL.md @@ -1,6 +1,6 @@ --- 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. +description: Build, test or use EAPIL audio API calls for speech, transcription, denoising, music, sound effects and ACE-Step/Yue2 songs; includes sequential batch speech export. --- # EAPIL audio service @@ -16,6 +16,7 @@ Use the hosted service at `https://ades.playones.com`, or the deployment chosen | 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) | +| ACE-Step 1.5 Turbo instrumentals or lyric songs | `/v1/audio/music/acestep` | [ACE-Step](references/acestep.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) | @@ -25,9 +26,9 @@ Read [transport and errors](references/api.md) for client integration or trouble 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. +Speech design/synthesis, music/SFX, ACE-Step 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. ACE-Step uses this service's bounded protocol, not the upstream API; choose `acestep` explicitly without rerouting existing `music` or `yue2` calls. -[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: +[call_voice_api.py](scripts/call_voice_api.py) makes one validated request; `acestep` adds Turbo music/song generation to the existing commands. Dry run prints the payload without a key/network call, so share only synthetic inputs. Resolve the actual skill directory: ~~~powershell python '/scripts/call_voice_api.py' design --text '你好。' --instruct '温暖自然、清晰的女声。' --language Chinese --max-tokens 512 --out voice.wav diff --git a/plugins/eapil-voice/skills/eapil-voice/references/acestep.md b/plugins/eapil-voice/skills/eapil-voice/references/acestep.md new file mode 100644 index 0000000..bf81b3c --- /dev/null +++ b/plugins/eapil-voice/skills/eapil-voice/references/acestep.md @@ -0,0 +1,77 @@ +# ACE-Step 1.5 Turbo music and songs + +Source: maintainer's **2026-10-15** unified API guide; public [models](https://ades.playones.com/models) and [OpenAPI](https://ades.playones.com/openapi.json) inspected the same day. This is the hosted service's protocol, **not upstream ACE-Step API**. + +`POST /v1/audio/music/acestep`, JSON. Model `acestep-v15-turbo`, optional semantic LM `acestep-5Hz-lm-0.6B`. Reuse the existing service Bearer token or an enabled KeyInfo member key via `AUDIO_API_TOKEN`; no separate ACE-Step key. Never embed a real key in the skill or a public frontend. + +Choose ACE-Step explicitly for Turbo instrumentals or supplied-lyric songs. Existing `music`/`sfx` still call Stable Audio; `yue2` still supports its own lyric/ABC workflow. This ACE-Step route has no reference audio, covers, inpainting, continuation, stems or multi-output batch mode. + +## Fields and mode checks + +Only `prompt` is required. Unknown fields return 422. Character limits count Unicode characters, not UTF-8 bytes. + +| Field | Default | Allowed / meaning | +|---|---|---| +| prompt | required | Nonblank 1–512 characters; describe 2–5 core traits such as genre, instruments, mood and arrangement. Put sung words in lyrics | +| lyrics | `""` | String <=4096 characters; omitted/empty is valid for instrumentals, **null is invalid** | +| instrumental | true | Boolean; set **false explicitly** for a lyric song | +| seconds | 30 | Number **10–600**; deployment may lower the upper bound, see `models.acestep.max_seconds` | +| steps | 8 | Integer **1–20**; start at 8, try 4 for drafts. Do not copy non-Turbo 30/50-step settings | +| seed | null/random | Integer **0–4294967295** or null; actual value in response `X-Audio-Seed` | +| use_lm | true | Boolean; optional 0.6B planning. False usually lowers latency but changes results; the text encoder still runs | +| vocal_language | unknown | Exact code below; zh Mandarin, yue Cantonese, en English. Not auto/zh-CN; does not translate lyrics | +| bpm | null | Integer **30–300** or null; rhythmic condition, not a guarantee of measured beat accuracy | +| keyscale | `""` | <=16 characters; empty or A–G + optional #/b/♯/♭ + space + Major/Minor, e.g. `C Major`, `F# minor`, `Bb Major`; no `Am` shorthand | +| timesignature | `""` | String `""`, `"2"`, `"3"`, `"4"`, `"6"`; correspond to unspecified, 2/4, 3/4, 4/4, 6/8. Not `"4/4"` | + +`instrumental=true` allows only omitted/empty/whitespace lyrics or `[Instrumental]`. `instrumental=false` requires nonblank lyrics other than that marker. Conflicts return 422 instead of silently discarding lyrics. Instrumental conditioning does not guarantee the absence of voice-like sounds. + +Language codes: + +```text +ar az bg bn ca cs da de el en es fa fi fr he hi hr ht hu id +is it ja ko la lt ms ne nl no pa pl pt ro ru sa sk sr sv sw +ta te th tl tr uk ur vi yue zh unknown +``` + +Turbo guidance is fixed at 1.0: **do not send cfg, cfg_scale or guidance_scale**. Internal shift=3.0, batch_size=1 and ODE are not request controls. LM does not write/rewrite lyrics or prompt, and does not fill unspecified BPM/key/meter via CoT. More steps do not guarantee better quality. + +## CLI and payload examples + +Use existing environment credentials. In this repository prefix external commands with `rtk proxy`; replace `` with the actual skill path. + +~~~powershell +python '/scripts/call_voice_api.py' acestep --prompt 'Warm piano, bright acoustic guitar, steady drums, upbeat indie pop. Instrumental, no vocals.' --instrumental true --seconds 30 --steps 8 --seed 7 --out music.wav +python '/scripts/call_voice_api.py' acestep --prompt 'Mandarin acoustic pop, clear female vocal, warm piano, gentle drums.' --instrumental false --lyrics-file lyrics.txt --vocal-language zh --seconds 30 --steps 8 --use-lm true --bpm 120 --keyscale 'C Major' --timesignature 4 --seed 7 --out song.wav +~~~ + +`--lyrics` and UTF-8/BOM `--lyrics-file` are alternatives; add `--dry-run` to inspect a request without credentials or inference. The helper defaults to a 900-second timeout and prints the actual response seed when a valid `X-Audio-Seed` header is available; it does not substitute the requested seed for a missing header. + +Equivalent song JSON: + +```json +{ + "prompt": "Mandarin acoustic pop, clear female vocal, warm piano, gentle drums.", + "instrumental": false, + "lyrics": "[Verse]\n风吹过安静的街道\n你的笑还留在转角\n[Chorus]\n我们向着晨光奔跑\n让每个明天都闪耀", + "vocal_language": "zh", + "seconds": 30, + "steps": 8, + "use_lm": true, + "seed": 7 +} +``` + +Use a JSON serializer for real multiline strings; do not double-escape `\n`. `[Verse]`/`[Chorus]` guide structure but are not strict score instructions. 4096 input characters do not fit automatically in 30 seconds: start with a short verse/chorus, match duration to lyrics, then listen for omissions, truncation, repetition and word accuracy. Neither lyrics nor seed ensure exact performance across hardware/versions. + +## Output, availability and failures + +Success is complete **48 kHz stereo PCM16 WAV** with `Content-Disposition: attachment; filename=acestep.wav` and `X-Audio-Seed`. Use bytes/arrayBuffer/Blob after checking HTTP status/media; no JSON/Base64/download URL. The generated OpenAPI 200 entry currently says application/json; follow the supplied binary contract and check the actual response. Approximate PCM data: 30 s = 5.76 MB, 600 s = 115.2 MB; account for client/proxy response size and memory. CORS/proxies must expose `X-Audio-Seed` for browser access. + +One active ACE-Step task per API process, including first load; busy rejects immediately with **503 + Retry-After: 2**, without queueing. Missing installation, disabled autostart and worker connection failures can also be 503; retry only explicit busy rejection with a finite bound. The helper never retries. + +This is synchronous: output starts after generation, with no streaming playback, job ID, progress or cancellation. First use includes weight verification/loading/compilation; first LM use loads the LM too. Client disconnect/timeout or proxy HTML 504 may leave inference running. The guide distinguishes internal JSON 504, which terminates the worker, from proxy timeout; diagnose before manually resubmitting either. + +401 needs credential correction; 422 needs schema/mode/deployment-limit correction; 500 indicates failed/invalid generation and worker cleanup. Startup and inference have separate timeouts (currently 120 + 600 seconds); a client timeout of 900 does not extend a gateway timeout. + +Public `/models` reports `models.acestep` installed/state/loaded/lm_loaded/max_seconds; `/health` and `/models` do not load or prewarm models. `installed=true` is not proof that weight validation or inference succeeds; stopped/loaded=false can be normal lazy startup. Hosted callers need no local runtime/extra port. Service deployment/worker changes belong to the operator, not this skill's request workflow. diff --git a/plugins/eapil-voice/skills/eapil-voice/references/api.md b/plugins/eapil-voice/skills/eapil-voice/references/api.md index 2fddb28..d6095a6 100644 --- a/plugins/eapil-voice/skills/eapil-voice/references/api.md +++ b/plugins/eapil-voice/skills/eapil-voice/references/api.md @@ -1,6 +1,6 @@ # 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. +Source: maintainer's API usage guide dated 2026-10-06, ACE-Step addition dated 2026-10-15 and live [OpenAPI](https://ades.playones.com/openapi.json). Schema limits, deployment caps and model recommendations differ; deployment settings can change. ## Requests and credentials @@ -14,18 +14,20 @@ Size limit: 26,214,400 bytes (25 MiB). Qwen clone, ASR, MossFormer2 and RE-USE e 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. +Read actual rate/channels. Stable Audio music/SFX currently use 44.1 kHz stereo PCM16; Yue2 and ACE-Step 48 kHz stereo PCM16. ACE-Step returns the actual seed in X-Audio-Seed; the CLI includes it in success JSON when available. 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. +Speech/ASR/enhancement client timeout starts at 600 seconds; music/SFX/Yue2 at 1800; ACE-Step at 900 to cover its separate startup/generation budgets plus transport. 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. +ACE-Step allows one active task per API process, including first load; busy rejects immediately with 503/Retry-After: 2 instead of queueing. Read [ACE-Step](acestep.md) for its mode constraints, response-size budget and JSON-versus-HTML 504 distinction. + 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 @@ -34,4 +36,4 @@ Failure detail can be a string/array or gateway HTML. Check status/media before 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. +Health ok means API responsiveness. Models keys include voice_design_tts, base_tts, asr, speech_enhancement, reuse, stable_audio_3, yue2, voxcpm2, acestep. Loaded=false or a stopped lazy worker can be normal. Hosted API use does not require local model startup. diff --git a/plugins/eapil-voice/skills/eapil-voice/references/music.md b/plugins/eapil-voice/skills/eapil-voice/references/music.md index 6d908b8..9f8b98b 100644 --- a/plugins/eapil-voice/skills/eapil-voice/references/music.md +++ b/plugins/eapil-voice/skills/eapil-voice/references/music.md @@ -1,6 +1,6 @@ # 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. +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 [ACE-Step](acestep.md) or [Yue2](yue2.md) for explicit lyrics. MiniMax was removed. This route remains Stable Audio; ACE-Step has a separate command and schema. | Field | Default | Schema / effective meaning | |---|---|---| diff --git a/plugins/eapil-voice/skills/eapil-voice/references/verification.md b/plugins/eapil-voice/skills/eapil-voice/references/verification.md index 66abbe7..403dc90 100644 --- a/plugins/eapil-voice/skills/eapil-voice/references/verification.md +++ b/plugins/eapil-voice/skills/eapil-voice/references/verification.md @@ -48,3 +48,15 @@ Skill frontmatter/UI metadata, local documentation links, root AGENTS.md and all - 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. + +## ACE-Step addition — 2026-10-15 + +Basis: maintainer's **ACE-Step 1.5 Turbo 调用指南**, dated 2026-10-15, plus public GET inspection of hosted `/models` and `/openapi.json`. These reads did not authenticate, load models or submit inference. + +- Hosted OpenAPI lists `POST /v1/audio/music/acestep` and `AceStepGenerationRequest`: 11 allowed fields, required prompt, 512/4096-character prompt/lyrics limits, 10–600 seconds, 1–20 steps, unsigned 32-bit seed, language/meter enums and no additional properties. Lyric-mode/keyscale checks also follow the supplied guide; they are not fully expressed in OpenAPI. +- `/models` reported `acestep-v15-turbo`, optional `acestep-5Hz-lm-0.6B`, installed=true, stopped, loaded/lm_loaded=false, autostart=true, max_seconds=600, max_steps=20, startup timeout=120 and generation timeout=600. Stopped is consistent with lazy startup, not a successful-inference result. +- The generated OpenAPI 200 response lists application/json; the supplied guide specifies PCM16 stereo 48 kHz WAV and X-Audio-Seed. The client checks actual WAV media/structure and reads the actual seed header rather than interpreting successful audio as JSON or echoing the requested seed. + +**30 offline regression tests passed** (22 existing + 8 ACE-Step tests). Coverage added: independent endpoint/defaults without changing Stable Audio/Yue2, instrumental/lyric conflicts, Unicode character bounds, numeric types/ranges/nullability, unknown-field rejection, musical controls, UTF-8/BOM multiline lyric files, false/zero wire values, full uint32 seeds, response seed handling, valid WAV saving, and 401/422/503/500/JSON-or-HTML-504 failures without retry or overwriting existing output. HTTP was mocked; no real service token is in the fixtures. Frontmatter, changed-script Ruff correctness/import checks, local reference links and all five plugin manifests/resources/versions were also checked. + +The documented instrumental request was executed through the actual CLI in dry-run mode; the song/lyric-file path was exercised by the regression suite. **No ACE-Step inference or listening test was run in this update.** The guide's four local HTTP benchmarks on Apple M3 Ultra are maintainer-reported evidence, not a reproduced hosted-service test, latency SLA or quality guarantee. No deployment configuration, installed plugin cache or 1.0.0 release/tag was changed. diff --git a/plugins/eapil-voice/skills/eapil-voice/references/yue2.md b/plugins/eapil-voice/skills/eapil-voice/references/yue2.md index 768afef..13e7e07 100644 --- a/plugins/eapil-voice/skills/eapil-voice/references/yue2.md +++ b/plugins/eapil-voice/skills/eapil-voice/references/yue2.md @@ -23,4 +23,4 @@ python '/scripts/call_voice_api.py' yue2 --style 'Mandopop ballad, fe python '/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. +Lyrics must not be blank/absent. [Instrumental] is experimental, not a guarantee; use Stable Audio or [ACE-Step's explicit instrumental mode](acestep.md) for pure instrumentals. ACE-Step also supports lyric songs, with a separate bounded schema and no ABC input. Current Yue2 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. diff --git a/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py b/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py index 3bf1167..78502ac 100644 --- a/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py +++ b/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py @@ -2,11 +2,13 @@ """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 re import struct import sys import uuid @@ -31,9 +33,15 @@ PATHS = { "music": "/v1/audio/music", "sfx": "/v1/audio/sfx", "yue2": "/v1/audio/music/yue2", + "acestep": "/v1/audio/music/acestep", } QWEN = ("language", "temperature", "max_tokens", "top_k", "top_p", "repetition_penalty") VOX = ("cfg_value", "inference_timesteps", "normalize", "denoise", "retry_badcase") +ACESTEP_LANGUAGES = ( + "ar az bg bn ca cs da de el en es fa fi fr he hi hr ht hu id " + "is it ja ko la lt ms ne nl no pa pl pt ro ru sa sk sr sv sw " + "ta te th tl tr uk ur vi yue zh unknown" +).split() class APIError(RuntimeError): @@ -210,6 +218,7 @@ def http_request( headers=None, timeout=600, expected="json", + response_metadata=None, ): if not math.isfinite(timeout) or timeout <= 0: raise ValueError("timeout must be positive and finite") @@ -227,6 +236,15 @@ def http_request( .strip() ) data = response.read() + if response_metadata is not None and path == PATHS["acestep"]: + seed = response.headers.get("X-Audio-Seed", "").strip() + if ( + seed.isascii() + and seed.isdecimal() + and len(seed) <= 10 + and int(seed) <= 4294967295 + ): + response_metadata["seed"] = int(seed) except HTTPError as exc: raw = exc.read().decode("utf-8", "replace") try: @@ -258,7 +276,7 @@ def http_request( return data -def post_json(args, path, payload): +def post_json(args, path, payload, *, response_metadata=None): return http_request( method="POST", base_url=args.base_url, @@ -268,6 +286,7 @@ def post_json(args, path, payload): headers={"Content-Type": "application/json"}, timeout=args.timeout, expected="wav", + response_metadata=response_metadata, ) @@ -294,7 +313,74 @@ def write_output(path, data): target.write_bytes(data) +def validate_acestep_payload(payload): + allowed = { + "prompt", + "lyrics", + "instrumental", + "seconds", + "steps", + "seed", + "use_lm", + "vocal_language", + "bpm", + "keyscale", + "timesignature", + } + if payload.keys() - allowed: + raise ValueError("unknown ACE-Step fields; use its dedicated request schema") + if not isinstance(payload.get("prompt"), str) or not payload["prompt"].strip(): + raise ValueError("prompt must be nonblank text") + for key, limit in (("prompt", 512), ("lyrics", 4096), ("keyscale", 16)): + value = payload.get(key, "") + if not isinstance(value, str) or len(value) > limit: + raise ValueError(f"{key} must be text of at most {limit} characters") + for key in ("instrumental", "use_lm"): + if not isinstance(payload.get(key, True), bool): + raise ValueError(f"{key} must be a boolean") + for key, low, high, integer, nullable in ( + ("seconds", 10, 600, False, False), + ("steps", 1, 20, True, False), + ("seed", 0, 4294967295, True, True), + ("bpm", 30, 300, True, True), + ): + if key not in payload or payload[key] is None and nullable: + continue + value = payload[key] + if ( + isinstance(value, bool) + or not isinstance(value, int if integer else (int, float)) + or not low <= value <= high + or not math.isfinite(value) + ): + raise ValueError( + f"{key} must be {'an integer' if integer else 'a number'} between {low} and {high}" + ) + language = payload.get("vocal_language", "unknown") + if not isinstance(language, str) or language not in ACESTEP_LANGUAGES: + raise ValueError("vocal_language must be an ACE-Step language code") + signature = payload.get("timesignature", "") + if not isinstance(signature, str) or signature not in {"", "2", "3", "4", "6"}: + raise ValueError('timesignature must be "", "2", "3", "4" or "6"') + keyscale = payload.get("keyscale", "") + if keyscale and not re.fullmatch(r"[A-G][#b♯♭]? (?i:major|minor)", keyscale): + raise ValueError( + 'keyscale must be empty or e.g. "C Major", "F# minor", "Bb Major"' + ) + lyrics = payload.get("lyrics", "").strip() + if payload.get("instrumental", True): + if lyrics and lyrics != "[Instrumental]": + raise ValueError( + "instrumental=true allows only empty lyrics or [Instrumental]" + ) + elif not lyrics or lyrics == "[Instrumental]": + raise ValueError("instrumental=false requires nonblank song lyrics") + + def validate_payload(command, payload): + if command == "acestep": + validate_acestep_payload(payload) + return required = { "design": ("text", "instruct"), "synthesize": ("text",), @@ -388,7 +474,11 @@ def build_parser(): sub.add_argument( "--timeout", type=float, - default=1800 if command in {"music", "sfx", "yue2"} else 600, + default=900 + if command == "acestep" + else 1800 + if command in {"music", "sfx", "yue2"} + else 600, ) sub.add_argument("--dry-run", action="store_true") if command in {"health", "models"}: @@ -460,6 +550,24 @@ def build_parser(): 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) + if command == "acestep": + sub.add_argument("--prompt", required=True) + lyrics = sub.add_mutually_exclusive_group() + lyrics.add_argument("--lyrics") + lyrics.add_argument("--lyrics-file") + sub.add_argument("--instrumental", type=bool_value, default=True) + sub.add_argument("--seconds", type=float, default=30) + sub.add_argument("--steps", type=int, default=8) + sub.add_argument("--seed", type=int) + sub.add_argument("--use-lm", type=bool_value, default=True) + sub.add_argument( + "--vocal-language", choices=ACESTEP_LANGUAGES, default="unknown" + ) + sub.add_argument("--bpm", type=int) + sub.add_argument("--keyscale", default="") + sub.add_argument( + "--timesignature", choices=["", "2", "3", "4", "6"], default="" + ) return parser @@ -484,6 +592,8 @@ def payload_from_args(args): if key in payload: raise ValueError(f"supply {key} text or file, not both") payload[key] = Path(path).read_text(encoding="utf-8-sig") + if args.command == "acestep": + payload.setdefault("lyrics", "") validate_payload(args.command, payload) return payload @@ -519,6 +629,7 @@ def main(argv=None): args.token = args.token or ( os.getenv(args.api_key_env) if args.api_key_env else env_token() ) + response_metadata = {} if args.command in {"health", "models"}: data = http_request( method="GET", @@ -529,7 +640,9 @@ def main(argv=None): elif file: data = post_multipart(args, PATHS[args.command], payload, file) else: - data = post_json(args, PATHS[args.command], payload) + data = post_json( + args, PATHS[args.command], payload, response_metadata=response_metadata + ) if args.command in {"asr", "health", "models"}: print(json.dumps(json.loads(data), ensure_ascii=False, indent=2)) else: @@ -540,6 +653,7 @@ def main(argv=None): "ok": True, "output": str(Path(args.out).resolve()), **wav_info(data), + **response_metadata, }, ensure_ascii=False, ) diff --git a/plugins/eapil-voice/skills/eapil-voice/tests/test_acestep_client.py b/plugins/eapil-voice/skills/eapil-voice/tests/test_acestep_client.py new file mode 100644 index 0000000..43f066e --- /dev/null +++ b/plugins/eapil-voice/skills/eapil-voice/tests/test_acestep_client.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest +import wave +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 + + +def stereo_wav(): + data = io.BytesIO() + with wave.open(data, "wb") as stream: + stream.setnchannels(2) + stream.setsampwidth(2) + stream.setframerate(48000) + stream.writeframes(b"\x01\x00\x02\x00" * 480) + return data.getvalue() + + +class AceStepTests(unittest.TestCase): + def setUp(self): + patch = mock.patch.object( + api, "urlopen", side_effect=AssertionError("unexpected network") + ) + self.net = patch.start() + self.addCleanup(patch.stop) + env = mock.patch.dict(os.environ, {"AUDIO_API_TOKEN": "test-key"}, clear=True) + env.start() + self.addCleanup(env.stop) + + def test_defaults_and_existing_routes_stay_separate(self): + args = api.build_parser().parse_args(["acestep", "--prompt", "piano"]) + payload = api.payload_from_args(args) + self.assertEqual(args.timeout, 900) + self.assertEqual(payload["seconds"], 30) + self.assertEqual(payload["steps"], 8) + self.assertIs(payload["instrumental"], True) + self.assertIs(payload["use_lm"], True) + self.assertEqual(payload["lyrics"], "") + self.assertNotIn("seed", payload) + self.assertNotIn("bpm", payload) + self.assertEqual(api.PATHS["acestep"], "/v1/audio/music/acestep") + music = api.build_parser().parse_args(["music", "--prompt", "piano"]) + self.assertEqual(api.PATHS[music.command], "/v1/audio/music") + self.assertEqual(music.timeout, 1800) + self.assertEqual(api.payload_from_args(music)["cfg"], 1) + self.assertNotIn("instrumental", api.payload_from_args(music)) + api.validate_payload("music", {"prompt": "p" * 513, "seconds": 5}) + api.validate_payload( + "yue2", {"style": "pop", "lyrics": "字" * 4097, "seed": 2**63 - 1} + ) + with self.assertRaises(ValueError): + api.validate_payload("music", {"prompt": "piano", "seed": 2**32 - 1}) + + def test_instrumental_and_lyric_mode_conflicts(self): + for lyrics in (None, "", " \n", "[Instrumental]"): + payload = {"prompt": "piano"} + if lyrics is not None: + payload["lyrics"] = lyrics + api.validate_payload("acestep", payload) + for lyrics, instrumental in ( + (None, True), + ("歌声", True), + ("", False), + (" \n", False), + ("[Instrumental]", False), + ): + with ( + self.subTest(lyrics=lyrics, instrumental=instrumental), + self.assertRaises(ValueError), + ): + api.validate_payload( + "acestep", + {"prompt": "pop", "lyrics": lyrics, "instrumental": instrumental}, + ) + api.validate_payload( + "acestep", + {"prompt": "pop", "lyrics": "[Verse]\n你好", "instrumental": False}, + ) + + def test_string_and_numeric_boundaries(self): + api.validate_payload( + "acestep", + { + "prompt": "音" * 512, + "lyrics": "词" * 4096, + "instrumental": False, + "seconds": 600, + "steps": 20, + "seed": 2**32 - 1, + "bpm": 300, + }, + ) + api.validate_payload( + "acestep", + {"prompt": "piano", "seconds": 10, "steps": 1, "seed": 0, "bpm": 30}, + ) + api.validate_payload( + "acestep", {"prompt": "piano", "seconds": 10.5, "seed": None, "bpm": None} + ) + invalid = { + "prompt": ("", " \n", "音" * 513, None), + "lyrics": (None, "词" * 4097), + "seconds": (9.99, 600.01, float("nan"), float("inf"), True, "30", None), + "steps": (0, 21, 8.5, True, "8", None), + "seed": (-1, 2**32, 10**1000, 7.5, True, "7"), + "bpm": (29, 301, 120.5, False, "120"), + "instrumental": (None, "false", 0), + "use_lm": (None, "false", 0), + } + for field, values in invalid.items(): + for value in values: + with ( + self.subTest(field=field, value=value), + self.assertRaises(ValueError), + ): + api.validate_payload("acestep", {"prompt": "pop", field: value}) + + def test_music_controls_and_unknown_fields(self): + for key in ("", "C Major", "F# minor", "Bb Major", "G♯ Minor", "D♭ major"): + api.validate_payload("acestep", {"prompt": "pop", "keyscale": key}) + for signature in ("", "2", "3", "4", "6"): + api.validate_payload( + "acestep", {"prompt": "pop", "timesignature": signature} + ) + for lang in ("unknown", "zh", "yue", "en"): + api.validate_payload("acestep", {"prompt": "pop", "vocal_language": lang}) + for field, value in ( + ("keyscale", "Am"), + ("keyscale", "H Major"), + ("keyscale", None), + ("timesignature", "4/4"), + ("timesignature", 4), + ("vocal_language", "auto"), + ("vocal_language", "zh-CN"), + ("cfg", 1), + ("cfg_scale", 1), + ("guidance_scale", 1), + ("negative_prompt", "noise"), + ("abc", "X:1"), + ("model", "other"), + ("batch_size", 2), + ): + with self.subTest(field=field), self.assertRaises(ValueError): + api.validate_payload("acestep", {"prompt": "pop", field: value}) + + def test_lyric_file_wire_preserves_unicode_false_and_seed_zero(self): + with tempfile.TemporaryDirectory() as directory: + lyrics = Path(directory) / "歌词.txt" + lyrics.write_text("[Verse]\n清晨的风\n[Chorus]\n你好", encoding="utf-8-sig") + args = api.build_parser().parse_args( + [ + "acestep", + "--prompt", + "中文流行", + "--lyrics-file", + str(lyrics), + "--instrumental", + "false", + "--use-lm", + "false", + "--seed", + "0", + "--vocal-language", + "zh", + "--bpm", + "120", + "--keyscale", + "C Major", + "--timesignature", + "4", + ] + ) + args.token = "test-key" + payload = api.payload_from_args(args) + result = io.BytesIO(stereo_wav()) + result.headers = {"Content-Type": "audio/wav", "X-Audio-Seed": "0"} + self.net.side_effect = None + self.net.return_value = result + metadata = {} + api.post_json( + args, api.PATHS[args.command], payload, response_metadata=metadata + ) + request = self.net.call_args.args[0] + self.assertEqual( + request.full_url, "https://ades.playones.com/v1/audio/music/acestep" + ) + self.assertEqual(request.get_header("Authorization"), "Bearer test-key") + self.assertEqual(request.get_header("Accept"), "audio/wav") + self.assertEqual(json.loads(request.data), payload) + self.assertEqual(payload["lyrics"], "[Verse]\n清晨的风\n[Chorus]\n你好") + self.assertIs(payload["instrumental"], False) + self.assertIs(payload["use_lm"], False) + self.assertEqual(payload["seed"], 0) + self.assertEqual(payload["timesignature"], "4") + self.assertEqual(metadata, {"seed": 0}) + self.assertEqual(self.net.call_args.kwargs["timeout"], 900) + + def test_dry_run_and_conflict_never_call_network(self): + output = io.StringIO() + with ( + mock.patch.dict(os.environ, {}, clear=True), + contextlib.redirect_stdout(output), + ): + self.assertEqual(api.main(["acestep", "--prompt", "piano", "--dry-run"]), 0) + data = json.loads(output.getvalue()) + self.assertNotIn("token", data) + self.assertEqual(data["payload"]["lyrics"], "") + with contextlib.redirect_stderr(io.StringIO()): + self.assertEqual( + api.main(["acestep", "--prompt", "pop", "--lyrics", "歌词"]), 1 + ) + self.net.assert_not_called() + + def test_cli_saves_binary_wav_and_reports_actual_response_seed(self): + with tempfile.TemporaryDirectory() as directory: + for seed in ("4294967295", None, "not-a-seed", "4294967296"): + with self.subTest(seed=seed): + result = io.BytesIO(stereo_wav()) + result.headers = {"Content-Type": "audio/wav"} + if seed is not None: + result.headers["X-Audio-Seed"] = seed + self.net.side_effect = None + self.net.return_value = result + output = io.StringIO() + target = Path(directory) / "music.wav" + with contextlib.redirect_stdout(output): + self.assertEqual( + api.main( + [ + "acestep", + "--prompt", + "piano", + "--seed", + "7", + "--out", + str(target), + ] + ), + 0, + ) + report = json.loads(output.getvalue()) + self.assertEqual(target.read_bytes(), stereo_wav()) + self.assertEqual( + (report["sample_rate"], report["channels"], report["bits"]), + (48000, 2, 16), + ) + if seed == "4294967295": + self.assertEqual(report["seed"], 2**32 - 1) + else: + self.assertNotIn("seed", report) + self.assertNotIn("test-key", output.getvalue()) + + def test_failed_inference_never_retries_or_overwrites_output(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "music.wav" + target.write_bytes(b"existing user output") + for status, raw in ( + (401, b'{"detail":"invalid key"}'), + (422, b'{"detail":"deployment duration limit"}'), + (503, b'{"detail":"ACE-Step busy"}'), + (503, b'{"detail":"ACE-Step not installed"}'), + (500, b'{"detail":"invalid output"}'), + (504, b'{"detail":"worker timeout"}'), + (504, b"Gateway Time-out"), + ): + with self.subTest(status=status, raw=raw): + self.net.reset_mock() + self.net.side_effect = HTTPError( + "https://example.test", + status, + "error", + {"Retry-After": "2"}, + io.BytesIO(raw), + ) + error = io.StringIO() + with contextlib.redirect_stderr(error): + self.assertEqual( + api.main( + ["acestep", "--prompt", "piano", "--out", str(target)] + ), + 1, + ) + self.net.assert_called_once() + self.assertEqual(target.read_bytes(), b"existing user output") + report = json.loads(error.getvalue()) + self.assertEqual(report["http_status"], status) + self.assertEqual( + report["confirmed_busy"], raw == b'{"detail":"ACE-Step busy"}' + ) + self.assertNotIn("test-key", error.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/generate-agents-md/.codex-plugin/plugin.json b/plugins/generate-agents-md/.codex-plugin/plugin.json index f798c00..08be6f0 100644 --- a/plugins/generate-agents-md/.codex-plugin/plugin.json +++ b/plugins/generate-agents-md/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "generate-agents-md", - "version": "1.0.0", + "version": "1.1.0", "description": "创建、更新或审查 AGENTS.md;基于仓库事实生成精炼、准确的 Agent 指南。", "author": { "name": "EAPIL",