
Stepfun Tts
- 412 installs
- 1.3k repo stars
- Updated August 4, 2026
- daymade/claude-code-skills
stepfun-tts is a Claude Code skill that generates Chinese and Japanese speech through StepFun stepaudio-2.5-tts contextual TTS for developers who need controllable voice output in agents, demos, or apps.
About
stepfun-tts is a daymade/claude-code-skills integration for StepFun stepaudio-2.5-tts, released 2026-04 and verified 2026-04-23. The skill replaces step-tts-2 voice_label tags with natural-language instruction fields up to 200 characters plus inline () prosody directives inside the spoken text. Bundled scripts include tts_generate.py for single-line or JSONL batch synthesis, ab_compare.sh for model A/B checks, and three reference guides covering API JSON, migration from step-tts-2, and censorship pitfalls. Developers reach for stepfun-tts when building game voice lines, accessibility narration, podcast drafts, or agent responses that need whisper, pause, stress, or mood control without maintaining a separate audio toolchain. The workflow handles censorship_block responses per line, enforces a 1000-character hard input cap, and stores API keys in STEPFUN_API_KEY or CLAUDE_PLUGIN_DATA config.json. Pricing guidance in the skill cites roughly 5.8 yuan per 10,000 characters for contextual synthesis.
- StepFun text-to-speech API wiring
- Narration and alert audio generation
- Accessibility and demo voice output
- In-session synthesis without context switching
- Content and mobile voice feature support
Stepfun Tts by the numbers
- 412 all-time installs (skills.sh)
- Ranked #435 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/daymade/claude-code-skills --skill stepfun-ttsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 412 |
|---|---|
| repo stars | ★ 1.3k |
| Last updated | August 4, 2026 |
| Repository | daymade/claude-code-skills ↗ |
How do you migrate StepFun TTS to stepaudio-2.5?
Generate spoken audio from scripts, UI copy, or agent responses via StepFun TTS for demos, accessibility, podcasts, or in-app voice.
Who is it for?
Developers adding Chinese or Japanese contextual TTS to Claude Code agents, game voice batches, or accessibility demos with StepFun APIs.
Skip if: Developers needing English-only TTS, offline synthesis, or transcription—use stepfun-asr or another provider instead.
When should I use this skill?
User mentions StepFun TTS, stepaudio-2.5-tts, 语音合成, voice_label migration, or batch game/app voice generation.
What you get
MP3 audio files, batch voice-line directories, migration notes, and censorship-skip logs from StepFun synthesis runs.
- mp3 audio files
- batch voice directories
- censorship skip logs
By the numbers
- instruction parameter limited to 200 characters
- 1000-character hard cap on TTS input text
- bundled references cover API, migration, and known issues
Files
StepFun stepaudio-2.5-tts
Generate Chinese / Japanese speech with stepaudio-2.5-tts (released 2026-04, verified 2026-04-23). Contextual TTS — emotion and prosody go through natural-language description, not fixed labels.
Companion: for transcription withstepaudio-2.5-asr(the sibling model), use thestepfun-asrskill — they share an API key but live on different endpoints with different body shapes.
Why this skill exists — StepAudio 2.5 has two non-obvious pitfalls that cost hours if you don't know them:
1. stepaudio-2.5-tts rejects voice_label (the step-tts-2 way). Emotion/prosody now goes through instruction (natural-language description, ≤200 chars) and inline () parentheses inside the text itself. 2. Censorship is stricter — anything containing 死 / 消失 / sensitive political terms returns censorship_block. Your rewrite options are in references/migration_from_v2.md.
Config and auth
API key lives in $STEPFUN_API_KEY (preferred) or ${CLAUDE_PLUGIN_DATA}/config.json (fallback for cross-session persistence). All bundled scripts try env first, then config.
First-time setup (one-liner):
mkdir -p "${CLAUDE_PLUGIN_DATA}" && cat > "${CLAUDE_PLUGIN_DATA}/config.json" <<EOF
{"api_key": "<paste key here>"}
EOFIf the user hasn't set a key, ask them to paste it (don't guess / don't use a placeholder). StepFun API keys are available at https://platform.stepfun.com/ → API Keys. Use a Normal key, not a Plan key (Plan keys are restricted to text models and silently fail on audio endpoints).
Common tasks — decision tree
| User wants... | Script | Key detail |
|---|---|---|
| Synthesize 1–500 char Chinese with emotion | scripts/tts_generate.py | Use instruction for mood, () for inline prosody |
| Synthesize long text (500–1000 char) | scripts/tts_generate.py | 1000 char is the hard cap; split at semantic boundaries above that |
| Batch-generate game/app voice lines | scripts/tts_generate.py --batch <jsonl> | Handle censorship_block fallback individually |
| A/B compare two TTS models | scripts/ab_compare.sh | Compares duration/size across two directories |
Migrate from step-tts-2 | see references/migration_from_v2.md | voice_label.emotion → instruction rewrite + censorship list |
Starting points
- Synthesize a single line: Run
python3 scripts/tts_generate.py --text "你好" --out /tmp/hello.mp3 --instruction "温暖的希望感". For fine-grained control read the "Contextual TTS" section below. - A full migration from
step-tts-2→stepaudio-2.5-tts: readreferences/migration_from_v2.mdend-to-end before touching code. It has theINSTRUCTION_MAP, the SKIP_CENSORED list pattern, and the output-directory-strategy for non-destructive A/B.
Contextual TTS — beyond emotion labels
The headline feature of stepaudio-2.5-tts is that you stop mapping emotions to fixed tags and start describing what you want in natural language. Two layers:
Global context (`instruction` parameter) — sets the overall tone for the entire utterance. ≤200 chars. Think of it like giving stage direction to a voice actor.
instruction: "克制的悲伤,语气低沉柔弱,像快要消失一样"Inline context (`()` parentheses inside `input`) —句内 directives. Parenthesised content is consumed as directions and is NOT read aloud. Use for precise control of pauses, breath, emphasis, or mid-sentence emotion shifts.
input: "(试探着问)你好吗?(开心地)太好了!(突然沉下来)不过...我快要消失了。"Examples that worked in practice (from 2026-04-23 verification):
instruction: "活泼俏皮,像是在撒娇,带点嘴硬"— visibly speeds up delivery vs neutralinstruction: "耳语声,气声很重,几乎听不清"— produces audible whisper/breathinput: "你好(停顿一下)我是蕾格(轻声)今天(加重)的天气真不错。"— inline directives all respected
What `stepaudio-2.5-tts` will NOT accept — voice_label parameter. Error: voice_label is not supported for v2 models. This is the #1 migration gotcha from step-tts-2.
Common error patterns (real errors, real fixes)
| Error response | Actual cause | Fix |
|---|---|---|
"voice_label is not supported for v2 models" | Sent voice_label to stepaudio-2.5-tts | Remove voice_label; put the same intent into instruction as natural language |
"The content you provided or machine outputted is blocked." type: censorship_block | Sensitive word (死 / 消失 / etc.) | Rewrite the phrase OR fall back to step-tts-2 for that specific line (mixed-model is fine) |
| Silent audio truncation (input > 1000 chars) | Hard cap exceeded | Split at semantic boundaries; don't truncate mid-sentence |
More in references/known_issues.md.
When to read references
references/api_reference.md— exact request/response JSON for/v1/audio/speech, all fields, error responses. Read when writing raw HTTP calls instead of using the bundled scripts.references/migration_from_v2.md— complete playbook for moving a step-tts-2 project to stepaudio-2.5-tts. Has the emotion→instruction rewrite table, the A/B directory strategy, decision checkpoints, and the 2026-04 speed/quality trade-off data (stepaudio-2.5-ttsis ~20% slower than step-tts-2; audible prosody improvement). Read before any migration work.references/known_issues.md— censorship patterns, TTS duration inflation, v2-family parameter naming gotcha, 1000-char hard cap. Read when debugging anomalous output or evaluating whether to adopt.
Design invariants (don't break these)
1. Non-destructive A/B output — when regenerating a corpus with a new model, write to a parallel directory (voice/zh_v25/), never overwrite the production corpus. The migration playbook shows why. 2. Per-line censorship handling — if 2/29 lines get censorship_block, don't fail the batch. Log the skipped IDs, continue. Mixed-model fallback (step-tts-2 for the skipped 2) is normal. 3. Don't duplicate voice_label logic in new code — any new TTS code targeting stepaudio-2.5-tts should only use instruction + inline (). Do not write a branch that conditionally emits voice_label.
Pricing (verified 2026-04-23, volatile)
stepaudio-2.5-ttscontextual synthesis: ~5.8 元 / 万字符- Zero-shot voice cloning: ~9.9 元 / 音色
Re-verify at https://platform.stepfun.com/docs/zh/guides/pricing/details before quoting to stakeholders.
Security scan passed
Scanned at: 2026-04-30T16:45:06.762254
Tool: gitleaks + pattern-based validation
Content hash: ee9f2151ac3b8e5f198b2a4fadd1e57662b0bfb0e856b4cb27f46a36950bcb23
stepaudio-2.5-tts API Reference
Exact request/response shapes for stepaudio-2.5-tts. Verified 2026-04-23 against the live StepFun API. Read this when you need to call the API by hand (curl, custom HTTP client) instead of using the bundled scripts/tts_generate.py.
Endpoint
POST https://api.stepfun.com/v1/audio/speech
Content-Type: application/json
Authorization: Bearer <STEPFUN_API_KEY>Request body
{
"model": "stepaudio-2.5-tts",
"input": "你好,我是蕾格。",
"voice": "shuangkuaijiejie",
"response_format": "mp3",
"speed": 1.0,
"volume": 1.0,
"instruction": "克制的悲伤,语气低沉柔弱"
}| Field | Required | Type | Notes |
|---|---|---|---|
model | yes | string | Must be stepaudio-2.5-tts |
input | yes | string | ≤1000 chars; can contain inline (directive) parentheses |
voice | yes | string | e.g. shuangkuaijiejie. Zero-shot clones use the clone's ID |
response_format | yes | string | mp3 (default), wav, or opus |
speed | no | float | 0.5-2.0, default 1.0 |
volume | no | float | 0.0-2.0, default 1.0 |
instruction | no | string | Global tone directive, natural language, ≤200 chars |
voice_label | — | — | DO NOT SEND. Returns voice_label is not supported for v2 models. Belongs to step-tts-2 |
Inline directives inside input
Parentheses () in the input are consumed as TTS control signals, not pronounced. Examples that work:
(停顿一下)— insert a pause(轻声)— reduce volume / breathy(加重)— stress the following word(试探着问)— apply a tone shift mid-sentence(突然沉下来)— emotion pivot
You can mix instruction (global tone) with inline () (per-phrase micro-control):
{
"instruction": "富有情绪弧线的独白",
"input": "(试探着问)你好吗?(开心地)太好了!(突然沉下来)不过...我快要消失了。"
}Response
On success: binary audio stream in the requested response_format. HTTP 200. No JSON wrapper. Save the body directly as .mp3/.wav/.opus.
Known error responses
{"error":{"message":"voice_label is not supported for v2 models","type":"request_params_invalid"}}→ Remove voice_label, use instruction instead.
{"error":{"message":"The content you provided or machine outputted is blocked.","type":"censorship_block"}}→ Content triggered censorship. Common triggers: 死, 消失, politically sensitive terms. See known_issues.md.
Comparison with sibling and legacy endpoints
| Model | Endpoint | Request format |
|---|---|---|
stepaudio-2.5-tts (this skill) | /v1/audio/speech | JSON with instruction (no voice_label) |
stepaudio-2.5-asr (sibling, see stepfun-asr skill) | /v1/audio/asr/sse | JSON + base64 audio + SSE response |
step-tts-2 / step-tts-mini (legacy) | /v1/audio/speech | JSON with voice_label |
step-asr / step-asr-1.1 (legacy) | /v1/audio/transcriptions | multipart/form-data |
Legacy step-tts-2 still works. It's the baseline in migration_from_v2.md and the per-line fallback when stepaudio-2.5-tts hits censorship_block.
Auth and key handling
- Key header:
Authorization: Bearer <key> - Keys can be retrieved at https://platform.stepfun.com/ → API Keys
- "Plan" keys (cheaper subscription) are restricted to text models on
api.stepfun.com/step_plan. They cannot call audio endpoints. Use a "Normal" key for all TTS calls. - Same key works for both TTS and ASR — no separate scopes
Rate / throughput notes (observed, not officially documented)
- ~400ms sleep between batch requests avoids 429s in practice
- MP3 responses consistently at 128kbps 24kHz mono (TTS default)
stepaudio-2.5-tts — Known Issues and Non-Obvious Behavior
Collected from end-to-end testing 2026-04-23. These are things that burned real time to discover; they are not in the official docs.
Stricter content censorship than step-tts-2
Symptom: stepaudio-2.5-tts returns {"error":{"message":"The content you provided or machine outputted is blocked.","type":"censorship_block"}} for content that step-tts-2 happily synthesized.
Observed triggers:
- 死 (die/dead) in any context, even negation
- 消失 (disappear / vanish)
- Combinations with emotional context: "我快要...消失了"
- Politically sensitive terms (standard CN content rules)
Key insight: Rewriting negations doesn't help — "我没有死" blocks as readily as "我死了". The classifier isn't doing deep semantic parsing.
Response strategies (pick per line): 1. Rewrite: "RAG 已死" → "这个技术过时了" 2. Fallback: keep step-tts-2 for the 2-5% of lines that block 3. Whitelist: contact StepFun BD (worth it at >5% blockage)
See migration_from_v2.md for the full blocking→fallback workflow.
TTS duration inflation on short lines
Observation: Very short lines (1-2s in step-tts-2) become dramatically longer in stepaudio-2.5-tts.
Example from the reference project:
...你能看到我吗?(10 chars)- step-tts-2: 1.24s
- stepaudio-2.5-tts: 2.57s (+107%)
Cause: The new model adds a pre-breath, pauses on ... ellipses, and gives the line emotional weight — all of which lengthens delivery.
Not a bug, but have a plan:
- If your UI has per-line timing (auto-advance, animation sync), re-tune it after migration
- If you want the old pacing, write
instruction: "快速、干脆、不要停顿"— but this negates a lot of what you're paying for in the new model
stepaudio-2.5-tts is a "v2 model" for parameter rejection
Why the error says "v2 models": StepFun internally groups stepaudio-2.5-tts with their v2 family despite the "2.5" version number. The error message voice_label is not supported for v2 models uses this internal grouping, which is confusing.
Don't pattern-match on the version string. Just know that:
stepaudio-2.5-tts→ useinstructionparameterstep-tts-2→ usevoice_labelparameter- They are NOT API-compatible despite sharing
/v1/audio/speech
TTS text cap: 1000 chars (hard, not soft)
The API rejects >1000 char inputs with a 400 error. Split at sentence boundaries before sending.
Non-obvious caveat when probing the limit: don't use highly-repetitive test text. The TTS itself accepts repetitive 800-char inputs and produces normal audio, but if you then transcribe that audio with stepaudio-2.5-asr for round-trip verification, the ASR can hallucinate 3-4× character expansion (a known ASR-side bug, see the stepfun-asr skill's known_issues.md). Use varied real-world text for cap-probing tests.
Voice cloning — not tested in this skill
Zero-shot voice cloning (9.9 元/音色) is advertised as a headline feature but was not verified in this skill's test pass. If you need voice cloning, check the StepFun docs at https://platform.stepfun.com/docs/zh/api-reference/audio/create-voice and validate on your own data — don't assume the quality claims without a listen test.
"Plan key" vs "Normal key" — silent audio failure
StepFun sells a cheap "Plan" subscription for text models (step_plan endpoint). Plan keys cannot call audio endpoints. This silently manifests as 4xx errors that don't mention auth at all.
If you hit auth-shaped failures and your account has a Plan subscription, verify you're using a Normal key (different value, obtained separately in the StepFun console under the same "API Keys" page).
Migrating from step-tts-2 to stepaudio-2.5-tts
Complete playbook for moving a production step-tts-2 voice corpus to stepaudio-2.5-tts. Based on a real end-to-end migration done 2026-04-23 on a ~30-line Chinese voice-acting project. Read this before changing any production code.
What "migration" actually means here
The API endpoint is the same (/v1/audio/speech), but the emotional control model is completely different:
| Aspect | step-tts-2 | stepaudio-2.5-tts |
|---|---|---|
| Emotion mechanism | voice_label.emotion = "悲伤" etc. (discrete tags) | instruction = "克制的悲伤,语气低沉" (natural language) |
| Multi-language | voice_label.language = "日语" etc. | instruction or Zero-shot clone |
| Inline prosody | N/A | () parentheses in the input text |
| Max text | 1000 chars | 1000 chars (same) |
| Censorship | Moderate | Stricter (2/29 lines blocked in the reference project) |
| Typical duration for same text | baseline | +20% (more pauses, more breathy) |
| Subjective quality | Clearly synthetic | Still audibly synthetic, but with prosody variance that "sounds like someone reading" more often |
You are not just swapping a model ID. You need to rewrite every voice_label.emotion call and handle a new class of error (censorship_block).
The rewrite — emotion tags → instruction sentences
The step-tts-2 style usually looked like:
const EMOTION_MAP = {
sad: '悲伤',
hopeful: '高兴',
relieved: '高兴',
smile: '非常高兴',
};
body.voice_label = { emotion: EMOTION_MAP[expression] };The stepaudio-2.5-tts equivalent:
const INSTRUCTION_MAP = {
sad: '克制的悲伤,语气低沉柔弱,像快要消失一样',
hopeful: '温暖的希望感,语气鼓励,带着期待',
relieved: '如释重负,语气柔和放松',
smile: '明朗开心,语气上扬,带着微笑',
};
body.instruction = INSTRUCTION_MAP[expression];
// DELETE body.voice_label entirely — sending it triggers an errorHow to write a good instruction
Writing instruction sentences is a craft. They should describe what the performance feels like, not just "happy" or "sad". Good instructions use:
- A core emotion word (悲伤, 希望, 开心)
- A qualifier that bounds intensity (克制的, 温暖的, 如释重负)
- A specific vocal behavior (语气低沉柔弱, 带着微笑, 像快要消失一样)
Bad: "悲伤" — same semantic content as the old emotion tag; wastes the instruction parameter Good: "克制的悲伤,语气低沉柔弱,像快要消失一样" — three distinct signals the model can combine
Keep under 200 chars. In practice 30-50 chars is plenty.
Inline () directives — new capability
Beyond the global instruction, stepaudio-2.5-tts parses parentheses inside the input itself. Directives in parentheses are not read aloud — they control delivery.
Use when a single line has multiple emotional beats:
input: "(试探着问)你好吗?(开心地)太好了!(突然沉下来)不过...我快要消失了。"Also useful for micro-control:
(停顿一下)between clauses that shouldn't run together(轻声)for intimate moments(加重)on the key word of a sentence
This is genuinely new vs step-tts-2 and worth using on your most dramatic lines.
Handling censorship_block
stepaudio-2.5-tts rejects more content than step-tts-2. Observed triggers from the reference project:
| Trigger phrase | Example line that failed |
|---|---|
| "死" in any context | "他们都在说'RAG 已死'... 我快要...消失了。" |
| "没有死" | "但我没有死,对吧?" (negation doesn't help) |
| "消失" / "透明" | Combined with "死" is especially reliable at triggering |
The error:
{"error":{"message":"The content you provided or machine outputted is blocked.","type":"censorship_block"}}Three response strategies (pick per line, not globally):
1. Rewrite — "RAG 已死" → "这个技术过时了" keeps the narrative, passes censorship 2. Mixed-model fallback — keep step-tts-2 for the 2-3 blocked lines, use stepaudio-2.5-tts for the rest. The voice difference is audible but tolerable for 2 lines out of 30 3. Request whitelist — contact StepFun BD for your account; only worth it if you have >5% blockage rate
The batch script (scripts/tts_generate.py --batch) logs censored IDs separately so you can handle them individually rather than aborting.
A/B directory strategy — don't overwrite production
Non-destructive layout. Output the new model's files to a parallel directory, not on top of the existing corpus:
public/data/voice/
├── zh/ ← step-tts-2 production (untouched)
└── zh_v25/ ← stepaudio-2.5-tts candidate (A/B)In the runtime voice loader, add a fallback for lines that are not in zh_v25/ (censored ones):
const getVoicePath = (nodeId: string, lang: string) => {
// 2 lines censored on v25 — keep step-tts-2 for those
const censored = new Set(['encounter_3', 'chapter_3_final_hope']);
const useV25 = lang === 'zh' && !censored.has(nodeId);
const dir = useV25 ? 'zh_v25' : lang;
return `/data/voice/${dir}/${nodeId}.mp3`;
};Benefits:
- Instant rollback: delete the
zh_v25/directory - Run the game/app with old voices while evaluating the new ones
- Human A/B: toggle the flag per-session to compare
Don't hard-code the censored list across multiple files
If you put ['encounter_3', 'chapter_3_final_hope'] in the runtime loader, the generation script, AND the docs, you'll have three places to update when the list changes. Treat the generation script's SKIP_CENSORED set as the SSOT; the loader can import or mirror it but shouldn't independently enumerate the same IDs.
The time-cost you're buying
Across 26 lines of the reference project, stepaudio-2.5-tts produced ~20% longer total duration than step-tts-2 for the same text. Per-line:
| Line type | step-tts-2 | stepaudio-2.5-tts | Δ |
|---|---|---|---|
| Very short (1-2s) | 1.24s | 2.57s | +107% |
| Short (3-5s) | 3.00s | 3.94s | +31% |
| Medium (8-12s) | 9.64s | 8.54s | -11% (mixed) |
| Long (12-15s) | 12.48s | 9.82s | -21% |
Short lines grow a lot because the new model adds breath and pause before starting. Long lines often shrink because English loanwords are handled faster.
Implications for UI timing:
- Auto-advance timers need re-tuning; anything that assumed 6-8 chars/sec is now 5.5 chars/sec
- Short lines feel markedly slower — the "...你能看到我吗?" intake of breath is noticeable
- Overall dialogue pacing in a visual novel becomes 20% slower
This is the main trade-off users report: the new model is more "alive" but the app feels slower. Whether that's acceptable is a product decision, not a technical one.
Decision checkpoints — before you commit
Use these to keep the migration from becoming a one-way door.
Before the first full regeneration
- [ ] Confirm the A/B directory layout matches the loader's fallback logic (production directory untouched)
- [ ] Write the
INSTRUCTION_MAP— don't just mechanically translate emotion tags, actually describe the performance - [ ] Have 3-5 sample lines ready for listening test BEFORE regenerating the whole corpus (spend 5 min on quality, save 20 min of regeneration if it sounds wrong)
After the first full regeneration
- [ ] Listen to every censored line manually. Decide rewrite vs fallback vs whitelist
- [ ] Check
ab_compare.shoutput: is the total duration change within acceptable bounds? - [ ] Play 5 random new lines in-context (the actual game/app) — don't trust the raw mp3 listen-through
Before switching production
- [ ] Run a real user-playthrough session end-to-end on the new voices
- [ ] Check every line with
...punctuation for how the new model handles the ellipsis (sometimes too slow, sometimes too abrupt) - [ ] Re-tune any auto-advance, skip, or per-line timing parameters
- [ ] Write a rollback script:
rm -rf zh_v25/+ revert loader change. If it's more than one commit, you've leaked complexity into the codebase
After production rollout
- [ ] Monitor user sentiment on pacing for 1-2 weeks before pronouncing success
- [ ] Keep the step-tts-2 generation script in version control for at least one release cycle
What NOT to do
- Don't regenerate blindly. Listen to samples first. A/B of 30 lines takes ~10 min; regenerate-without-listening is how you ship something that "technically works" but sounds worse.
- Don't mix `voice_label` and `instruction` in the same request. stepaudio-2.5-tts rejects
voice_labelentirely, but people occasionally leave it in to "be safe" — it's not safe, it's an error. - Don't try to pass emotion tags through `instruction`.
instruction: "悲伤"works but wastes the parameter. Write a full sentence. - Don't delete the step-tts-2 generation script until you've shipped the new model to production and had it stable for a full release cycle. You will want the rollback path.
- Don't assume Japanese migrates the same way. The reference project didn't test Japanese voices under stepaudio-2.5-tts. Run a separate mini-A/B for Japanese before committing.
Batch migration with the bundled script
The skill's scripts/tts_generate.py --batch is built for this workflow. Feed it a JSONL file:
{"id": "encounter_1", "text": "...你能看到我吗?", "instruction": "克制的悲伤,语气低沉柔弱"}
{"id": "encounter_2", "text": "太好了... 最近能看到我的人,越来越少了。", "instruction": "克制的悲伤,语气低沉柔弱"}Run:
python3 scripts/tts_generate.py --batch lines.jsonl --out-dir ./voice/zh_v25The script handles censorship_block per-line, reports the skipped IDs at the end, and keeps going. You get a clean list of "what to fall back on step-tts-2 for" without having to retry the whole batch.
#!/usr/bin/env bash
#
# ab_compare.sh — compare two directories of mp3 files (size + duration).
#
# Typical use: after regenerating a voice corpus with stepaudio-2.5-tts into a
# parallel `zh_v25/` directory, compare against the step-tts-2 baseline `zh/`.
# Outputs a GitHub-flavored markdown table so you can paste it into a report.
#
# Usage:
# ./ab_compare.sh <dir_a_baseline> <dir_b_candidate>
# ./ab_compare.sh ./voice/zh ./voice/zh_v25
#
# Only files present in BOTH directories are compared. Files unique to either
# side are reported separately at the end.
#
# Dependencies: ffprobe (from ffmpeg), GNU-style stat OR BSD stat (macOS).
set -euo pipefail
if [ $# -ne 2 ]; then
echo "Usage: $0 <baseline_dir> <candidate_dir>" >&2
echo " Compares mp3 files present in both directories by size and duration." >&2
exit 2
fi
DIR_A="$1"
DIR_B="$2"
if [ ! -d "$DIR_A" ]; then
echo "ERROR: baseline dir not found: $DIR_A" >&2
exit 2
fi
if [ ! -d "$DIR_B" ]; then
echo "ERROR: candidate dir not found: $DIR_B" >&2
exit 2
fi
if ! command -v ffprobe >/dev/null 2>&1; then
echo "ERROR: ffprobe not found. Install ffmpeg: brew install ffmpeg (macOS)" >&2
exit 2
fi
# Portable byte-size function (macOS stat vs GNU stat)
filesize() {
if stat -f%z "$1" >/dev/null 2>&1; then
stat -f%z "$1" # BSD / macOS
else
stat -c%s "$1" # GNU / Linux
fi
}
duration() {
ffprobe -v quiet -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$1"
}
# Compute the intersection (files present in both)
common_files=$(comm -12 \
<(cd "$DIR_A" && ls *.mp3 2>/dev/null | sort) \
<(cd "$DIR_B" && ls *.mp3 2>/dev/null | sort) || true)
only_a=$(comm -23 \
<(cd "$DIR_A" && ls *.mp3 2>/dev/null | sort) \
<(cd "$DIR_B" && ls *.mp3 2>/dev/null | sort) || true)
only_b=$(comm -13 \
<(cd "$DIR_A" && ls *.mp3 2>/dev/null | sort) \
<(cd "$DIR_B" && ls *.mp3 2>/dev/null | sort) || true)
if [ -z "$common_files" ]; then
echo "ERROR: no .mp3 files common to both directories." >&2
exit 1
fi
# Header
printf "| %-28s | %10s | %10s | %9s | %8s | %8s | %7s |\n" \
"id" "A bytes" "B bytes" "Δsize%" "A dur" "B dur" "Δdur%"
printf "|%s|%s|%s|%s|%s|%s|%s|\n" \
"-----------------------------" "------------" "------------" "-----------" "----------" "----------" "---------"
total_a_size=0
total_b_size=0
total_a_dur=0
total_b_dur=0
n=0
while IFS= read -r fname; do
[ -z "$fname" ] && continue
a_path="$DIR_A/$fname"
b_path="$DIR_B/$fname"
a_size=$(filesize "$a_path")
b_size=$(filesize "$b_path")
a_dur=$(duration "$a_path")
b_dur=$(duration "$b_path")
if [ "$a_size" -gt 0 ]; then
dsize=$(awk -v a="$a_size" -v b="$b_size" 'BEGIN{printf "%.1f", (b-a)*100/a}')
else
dsize="N/A"
fi
ddur=$(awk -v a="$a_dur" -v b="$b_dur" 'BEGIN{if(a+0==0){print "N/A"}else{printf "%.1f", (b-a)*100/a}}')
id_only="${fname%.mp3}"
printf "| %-28s | %10d | %10d | %8s%% | %7.2fs | %7.2fs | %6s%% |\n" \
"$id_only" "$a_size" "$b_size" "$dsize" "$a_dur" "$b_dur" "$ddur"
total_a_size=$((total_a_size + a_size))
total_b_size=$((total_b_size + b_size))
total_a_dur=$(awk -v s="$total_a_dur" -v d="$a_dur" 'BEGIN{printf "%.3f", s+d}')
total_b_dur=$(awk -v s="$total_b_dur" -v d="$b_dur" 'BEGIN{printf "%.3f", s+d}')
n=$((n + 1))
done <<< "$common_files"
echo ""
echo "**Totals (${n} common files):**"
echo ""
if [ "$total_a_size" -gt 0 ]; then
size_delta=$(awk -v a="$total_a_size" -v b="$total_b_size" 'BEGIN{printf "%.1f", (b-a)*100/a}')
dur_delta=$(awk -v a="$total_a_dur" -v b="$total_b_dur" 'BEGIN{printf "%.1f", (b-a)*100/a}')
echo "- Size: A=${total_a_size} B=${total_b_size} (Δ ${size_delta}%)"
echo "- Duration: A=${total_a_dur}s B=${total_b_dur}s (Δ ${dur_delta}%)"
fi
if [ -n "$only_a" ]; then
echo ""
echo "**Only in A (${DIR_A}):**"
echo "$only_a" | sed 's/^/ - /'
fi
if [ -n "$only_b" ]; then
echo ""
echo "**Only in B (${DIR_B}):**"
echo "$only_b" | sed 's/^/ - /'
fi
#!/usr/bin/env python3
"""
stepaudio-2.5-tts synthesis — single line or batch.
Endpoint: POST https://api.stepfun.com/v1/audio/speech
Key things this script handles that naive implementations miss:
- Does NOT send voice_label (would trigger "voice_label is not supported for v2 models")
- Puts emotion/prosody into `instruction` (natural-language, ≤200 chars)
- Preserves inline `()` directives in the text — these are consumed by the TTS as directions, not read aloud
- Per-line censorship_block fallback: log and skip, don't fail the whole batch
- Reads API key from $STEPFUN_API_KEY or $CLAUDE_PLUGIN_DATA/config.json
Usage:
# Single line
python3 tts_generate.py --text "你好,我是蕾格。" --out /tmp/hello.mp3 \\
--instruction "温暖的希望感,语气鼓励"
# Batch from JSONL (one JSON object per line: {"id": "...", "text": "...", "instruction": "..."})
python3 tts_generate.py --batch lines.jsonl --out-dir /tmp/voices/
# With inline prosody directives in the text itself
python3 tts_generate.py --text "你好(停顿一下)我是蕾格(轻声)" --out /tmp/hello.mp3
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Iterable
API_URL = "https://api.stepfun.com/v1/audio/speech"
MODEL = "stepaudio-2.5-tts"
DEFAULT_VOICE = "shuangkuaijiejie" # 爽快姐姐 — verified in the 2026-04 test pass
def load_api_key() -> str:
"""Env first, then ${CLAUDE_PLUGIN_DATA}/config.json. Fail fast — no placeholder fallback."""
k = os.environ.get("STEPFUN_API_KEY", "").strip()
if k:
return k
plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA", "").strip()
if plugin_data:
cfg = Path(plugin_data) / "config.json"
if cfg.exists():
try:
k = json.loads(cfg.read_text()).get("api_key", "").strip()
if k:
return k
except json.JSONDecodeError:
pass
print(
"ERROR: no API key found.\n"
" Set $STEPFUN_API_KEY, or create ${CLAUDE_PLUGIN_DATA}/config.json with {\"api_key\": \"...\"}\n"
" Get a key at https://platform.stepfun.com/ → API Keys",
file=sys.stderr,
)
sys.exit(2)
def synthesize(
*,
api_key: str,
text: str,
instruction: str | None = None,
voice: str = DEFAULT_VOICE,
speed: float = 1.0,
volume: float = 1.0,
response_format: str = "mp3",
timeout: int = 300,
) -> dict[str, Any]:
"""
Call /v1/audio/speech with stepaudio-2.5-tts.
Returns {ok, audio_bytes?, status, err?, censored?}.
censored=True signals a censorship_block which callers should handle individually
rather than aborting a batch.
"""
body: dict[str, Any] = {
"model": MODEL,
"input": text,
"voice": voice,
"response_format": response_format,
"speed": speed,
"volume": volume,
}
if instruction:
if len(instruction) > 200:
return {"ok": False, "status": 0, "err": f"instruction too long: {len(instruction)} > 200 chars"}
body["instruction"] = instruction
req = urllib.request.Request(
API_URL,
data=json.dumps(body).encode(),
method="POST",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return {"ok": True, "status": resp.status, "audio_bytes": resp.read()}
except urllib.error.HTTPError as e:
raw = e.read().decode(errors="replace")
# Detect censorship_block so caller can decide whether to skip
censored = "censorship_block" in raw or "blocked" in raw.lower()
# Detect the known voice_label migration error and make the message actionable
if "voice_label is not supported" in raw:
hint = (
"\n HINT: stepaudio-2.5-tts does not accept voice_label. "
"Put emotion/prosody into `instruction` (natural language) instead."
)
raw = raw + hint
return {"ok": False, "status": e.code, "err": raw[:500], "censored": censored}
def read_batch(path: Path) -> Iterable[dict[str, Any]]:
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
yield json.loads(line)
def main() -> int:
ap = argparse.ArgumentParser(description="stepaudio-2.5-tts single-line or batch synthesis")
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--text", help="Single text to synthesize")
g.add_argument("--batch", type=Path, help="JSONL file: {id, text, instruction?} per line")
ap.add_argument("--out", help="Output mp3 path (single mode)")
ap.add_argument("--out-dir", type=Path, help="Output directory (batch mode)")
ap.add_argument("--instruction", help="Global tone directive (natural language, ≤200 chars)")
ap.add_argument("--voice", default=DEFAULT_VOICE, help=f"Voice ID (default: {DEFAULT_VOICE})")
ap.add_argument("--speed", type=float, default=1.0)
ap.add_argument("--volume", type=float, default=1.0)
ap.add_argument("--delay-ms", type=int, default=400, help="Sleep between batch requests to avoid throttling")
args = ap.parse_args()
api_key = load_api_key()
if args.text:
if not args.out:
print("ERROR: --out is required with --text", file=sys.stderr)
return 2
result = synthesize(
api_key=api_key,
text=args.text,
instruction=args.instruction,
voice=args.voice,
speed=args.speed,
volume=args.volume,
)
if not result["ok"]:
print(f"FAIL status={result['status']}: {result.get('err', '')}", file=sys.stderr)
return 1
Path(args.out).write_bytes(result["audio_bytes"])
print(f"OK wrote {args.out} ({len(result['audio_bytes'])} bytes)")
return 0
# Batch mode
if not args.out_dir:
print("ERROR: --out-dir is required with --batch", file=sys.stderr)
return 2
args.out_dir.mkdir(parents=True, exist_ok=True)
success = 0
censored: list[str] = []
failed: list[tuple[str, str]] = []
for item in read_batch(args.batch):
line_id = item.get("id")
text = item.get("text")
if not line_id or not text:
print(f"skip (missing id/text): {item}", file=sys.stderr)
continue
instr = item.get("instruction", args.instruction)
result = synthesize(
api_key=api_key,
text=text,
instruction=instr,
voice=item.get("voice", args.voice),
speed=item.get("speed", args.speed),
volume=item.get("volume", args.volume),
)
out_path = args.out_dir / f"{line_id}.mp3"
if result["ok"]:
out_path.write_bytes(result["audio_bytes"])
print(f" ✓ {line_id} ({len(result['audio_bytes'])} bytes)")
success += 1
elif result.get("censored"):
print(f" ⚠ {line_id} CENSORED — skipped (consider rewriting or fallback to step-tts-2)")
censored.append(line_id)
else:
err = result.get("err", "")[:160]
print(f" ✗ {line_id} status={result['status']}: {err}", file=sys.stderr)
failed.append((line_id, err))
time.sleep(args.delay_ms / 1000)
print("")
print(f"Done: {success} ok, {len(censored)} censored, {len(failed)} failed")
if censored:
print(f" Censored IDs: {', '.join(censored)}")
if failed:
print(" Failed IDs:")
for lid, err in failed:
print(f" {lid}: {err}")
return 0 if not failed else 1
if __name__ == "__main__":
sys.exit(main())
Related skills
How it compares
Pick stepfun-tts for Chinese/Japanese contextual StepFun speech; use fal-audio or other providers when you need music, SFX, or English-first fal.ai audio endpoints.
FAQ
Does stepfun-tts still accept voice_label?
stepfun-tts targets stepaudio-2.5-tts, which rejects voice_label with a v2-model error. stepfun-tts maps emotion and prosody into the instruction parameter and inline () directives instead of step-tts-2 label tags.
What is the StepFun TTS input limit?
stepfun-tts documents a 1000-character hard cap on stepaudio-2.5-tts input. For longer scripts, split at semantic boundaries and run tts_generate.py per chunk or via JSONL batch mode.
How does stepfun-tts authenticate to StepFun?
stepfun-tts reads STEPFUN_API_KEY from the environment or api_key from CLAUDE_PLUGIN_DATA config.json. The skill requires a Normal StepFun API key, not a Plan key restricted to text models.