
Happy Audio Gen
- 145 installs
- 303 repo stars
- Updated April 20, 2026
- iamzhihuix/happy-claude-skills
Generate speech, music, or sound assets via audio APIs and pipelines, integrating outputs into apps, agents, or multimedia content workflows.
About
happy-audio-gen from iamzhihuix/happy-claude-skills guides Claude through programmatic audio generation: invoking TTS or music APIs, tuning voice and format parameters, saving assets, and embedding generated speech or sound into agent workflows, apps, and automated content production pipelines.
- Synthetic speech generation
- Music and sound asset creation
- Audio API integration
- Agent multimedia output
- Content pipeline automation
Happy Audio Gen by the numbers
- 145 all-time installs (skills.sh)
- +3 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #725 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/iamzhihuix/happy-claude-skills --skill happy-audio-genAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 145 |
|---|---|
| repo stars | ★ 303 |
| Last updated | April 20, 2026 |
| Repository | iamzhihuix/happy-claude-skills ↗ |
What it does
Generate speech, music, or sound assets via audio APIs and pipelines, integrating outputs into apps, agents, or multimedia content workflows.
Files
happy-audio-gen
Turns text into speech across 6 providers through one CLI. All providers are synchronous (TTS is fast — typically under 10 seconds) except Bailian's voice-design flow (which is still covered but uses a longer poll window).
Quick usage
# Shortest path — OpenAI default voice
bun scripts/main.ts --text "Hello, world" --out ./hello.mp3
# Chinese, MiniMax
bun scripts/main.ts --provider minimax --text "大家好" --voice male-qn-qingse --out ./hello.mp3
# Long-form, Bailian (auto-splits by sentence)
bun scripts/main.ts --provider bailian --textfiles ./script.md --out ./narration.mp3When to invoke this skill
- User asks to synthesize speech / TTS / read aloud / narrate / dub / make a voice-over.
- User asks to convert script / text / article into audio.
- User names a TTS voice or model.
Do not route here when the user wants to transcribe audio → text (that's STT, different domain), or edit / mix audio files (use a dedicated audio editor).
Step 0: Preflight (BLOCKING)
1. Locate EXTEND.md:
./.happy-skills/happy-audio-gen/EXTEND.md$XDG_CONFIG_HOME/happy-skills/happy-audio-gen/EXTEND.md~/.happy-skills/happy-audio-gen/EXTEND.md
If none found, run bun scripts/main.ts --setup and walk the user through references/config/first-time-setup.md.
2. Verify at least one provider has credentials (env var or 1Password reference).
3. Verify Bun is available. Fallback: npx -y bun.
Step 1: Choose provider
Preference order:
1. --provider <id> 2. EXTEND.md default_provider 3. Auto-detect env vars: openai > elevenlabs > bailian > minimax > siliconflow > playht
Pick by language / voice intent:
- English, natural + fast →
openai(gpt-4o-mini-tts / tts-1). - Multilingual, voice cloning →
elevenlabs. - Chinese, long-form →
bailian(qwen-tts auto-chunks long scripts) orminimax. - Chinese dialect / voice design →
bailian(voice-design with qwen3-tts-vd) orsiliconflow(CosyVoice2). - Ultra-realistic, short-form →
playht(2.0).
Step 2: Fill parameters
- `--text` or `--textfiles`: input. Always quote.
- `--out <path>`: REQUIRED. Extension determines format (
.mp3/.wav/.ogg/.flac). - `--voice <id>`: provider-specific. See
references/voices.mdfor the short list of well-known voices. - `--rate 0.5..2.0`: speaking rate.
- `--instruction "..."`: voice direction (only
openaigpt-4o-mini-tts andsiliconflowhonor this). - `--language <code>`:
en,zh,ja— only a few providers honor this explicitly.
Step 3: Run
bun scripts/main.ts \
--provider openai \
--model gpt-4o-mini-tts \
--voice alloy \
--text "..." \
--out ./out.mp3JSON mode:
{ "success": true, "provider": "openai", "model": "gpt-4o-mini-tts", "voice": "alloy", "output": "/abs/out.mp3", "size_bytes": 76032, "format": "mp3" }Step 4: Long text handling
happy-audio-genautomatically splits long input for providers that cap per-call length (Bailian ≤ 200 Chinese chars per call). Chunks are concatenated byte-for-byte on output.- For best fidelity with concatenated MP3s, stitch the segments with ffmpeg afterward rather than relying on byte concat.
Step 5: Errors
[openai] OpenAI TTS 400withinvalid voice→ the voice name is not supported by the model. Use one ofalloy,ash,coral,echo,fable,onyx,nova,sage,shimmer.[minimax] ... 2049 invalid api key→ tryMINIMAX_BASE_URL=https://api.minimaxi.com/v1(different region).[bailian] ... 400 DataInspectionFailed→ Aliyun content filter. Surface to the user.[elevenlabs] 401→ key invalid or subscription expired.
References
references/providers.md— per-provider env vars, default models, voice lists.references/voices.md— curated voices for each provider.references/error_codes.md— common errors and fixes.references/config/first-time-setup.mdreferences/config/extend-schema.mdassets/EXTEND.template.md
happy-audio-gen — EXTEND.md template
Copy to:
./.happy-skills/happy-audio-gen/EXTEND.md(project)- or
~/.happy-skills/happy-audio-gen/EXTEND.md(user)
version: 1
default_provider: openai
default_format: mp3
default_save_dir: ~/Music/claude-tts
default_model:
openai: gpt-4o-mini-tts
# elevenlabs: eleven_multilingual_v2
# bailian: qwen-tts
# minimax: speech-02-hd
# siliconflow: FunAudioLLM/CosyVoice2-0.5B
# playht: PlayHT2.0
default_voice:
openai: alloy
# elevenlabs: "21m00Tcm4TlvDq8ikWAM"
# bailian: Cherry
# minimax: male-qn-qingse
# siliconflow: "FunAudioLLM/CosyVoice2-0.5B:alex"
# playht: "s3://voice-cloning-zero-shot/.../manifest.json"
providers:
openai:
api_key_env: OPENAI_API_KEY
# elevenlabs:
# api_key_env: ELEVENLABS_API_KEY
# bailian:
# api_key_env: DASHSCOPE_API_KEY
# minimax:
# api_key_env: MINIMAX_API_KEY
# base_url: https://api.minimaxi.com/v1 # try this if default region errors
# siliconflow:
# api_key_env: SILICONFLOW_API_KEY
# playht:
# api_key_env: PLAYHT_API_KEY
# # Note: PlayHT also requires PLAYHT_USER_ID in the shell; EXTEND.md can't represent dual credentials yet.Security
- Never paste raw keys.
- If committed at project level, make sure every
providers.*usesapi_key_envorapi_key_source.
{
"name": "happy-audio-gen",
"version": "0.1.0",
"description": "Universal TTS / voice generation skill — OpenAI TTS, ElevenLabs, Bailian qwen-tts, MiniMax, SiliconFlow CosyVoice, PlayHT",
"type": "module",
"private": true,
"engines": {
"bun": ">=1.1.0"
},
"scripts": {
"gen": "bun scripts/main.ts"
}
}
EXTEND.md schema — happy-audio-gen
Resolution order:
1. ./.happy-skills/happy-audio-gen/EXTEND.md (project) 2. $XDG_CONFIG_HOME/happy-skills/happy-audio-gen/EXTEND.md 3. ~/.happy-skills/happy-audio-gen/EXTEND.md (user)
---
Top-level fields
| Field | Type | Default | Description |
|---|---|---|---|
version | integer | 1 | |
default_provider | provider id | (auto-detect) | |
default_format | mp3 / wav / ogg / pcm / flac | mp3 | |
default_language | string | (unset) | Only a few providers honor it |
default_save_dir | path | (cwd) | Used for relative --out paths |
default_model | object | {} | Per-provider default model |
default_voice | object | {} | Per-provider default voice |
providers | object | {} | Credentials + base_url overrides |
---
Example — minimal
default_provider: openai
default_format: mp3
providers:
openai:
api_key_env: OPENAI_API_KEYExample — full
version: 1
default_provider: bailian
default_format: mp3
default_language: zh
default_save_dir: ~/Music/claude-tts
default_model:
openai: gpt-4o-mini-tts
bailian: qwen-tts
minimax: speech-02-hd
siliconflow: FunAudioLLM/CosyVoice2-0.5B
default_voice:
openai: nova
bailian: Cherry
minimax: male-qn-qingse
siliconflow: FunAudioLLM/CosyVoice2-0.5B:alex
providers:
openai:
api_key_env: OPENAI_API_KEY
elevenlabs:
api_key_env: ELEVENLABS_API_KEY
bailian:
api_key_env: DASHSCOPE_API_KEY
minimax:
api_key_env: MINIMAX_API_KEY
base_url: https://api.minimaxi.com/v1
siliconflow:
api_key_env: SILICONFLOW_API_KEY
playht:
api_key_env: PLAYHT_API_KEY
# PlayHT also needs PLAYHT_USER_ID; export it separately in the shell.Never paste raw keys; use api_key_env or api_key_source.
happy-audio-gen — first-time setup
TTS is cheap — typical 30-second narration costs under $0.01. Fewer setup hoops than video generation.
1. Bun
command -v bun
# or: curl -fsSL https://bun.sh/install | bashSkill can also run under npx -y bun.
2. Pick a provider
Typical combinations:
- Quickest + cheapest:
openaigpt-4o-mini-tts. - Best multilingual + voice cloning:
elevenlabs. - Chinese long-form:
bailianqwen-tts (auto-chunking) orminimax. - Cheap CosyVoice:
siliconflow.
3. Issue keys
| Provider | Env var | Dashboard |
|---|---|---|
| openai | OPENAI_API_KEY | https://platform.openai.com/api-keys |
| elevenlabs | ELEVENLABS_API_KEY | https://elevenlabs.io/app/settings/api-keys |
| bailian | DASHSCOPE_API_KEY | https://dashscope.console.aliyun.com/apiKey |
| minimax | MINIMAX_API_KEY | https://platform.minimaxi.com |
| siliconflow | SILICONFLOW_API_KEY | https://cloud.siliconflow.cn/account/ak |
| playht | PLAYHT_USER_ID + PLAYHT_API_KEY | https://play.ht/app/api-access |
Export in shell:
export OPENAI_API_KEY="sk-..."4. EXTEND.md (optional but recommended)
Copy assets/EXTEND.template.md to ~/.happy-skills/happy-audio-gen/EXTEND.md or project-level ./.happy-skills/happy-audio-gen/EXTEND.md. Minimum:
default_provider: openai
default_format: mp3
default_voice:
openai: alloy
providers:
openai:
api_key_env: OPENAI_API_KEY5. Smoke test
bun scripts/main.ts --text "Hello from happy audio gen." --out /tmp/test.mp3Expected: after <5 seconds a ~40–80 KB MP3 lands in /tmp/test.mp3.
Try a Chinese smoke if you set up a Chinese provider:
bun scripts/main.ts --provider bailian --text "大家好,这是一段中文测试。" --out /tmp/test-zh.mp3happy-audio-gen — common errors
| Error contains | Cause | Fix |
|---|---|---|
config: No text provided | Neither --text nor --textfiles given | Pass one |
config: --out is required | Missing output path | Pass --out out.mp3 |
config: No provider selected ... | No env key / no --provider | Set a provider env var or pass --provider |
[openai] OpenAI TTS 400 invalid voice | Voice name not recognized by model | Use one of alloy/ash/coral/echo/fable/onyx/nova/sage/shimmer |
[openai] ... 400 model_not_found | Older account without gpt-4o-mini-tts | Fall back to --model tts-1 |
[elevenlabs] ELEVENLABS_API_KEY not set | Missing key | Generate at elevenlabs.io/app/settings/api-keys |
[elevenlabs] 401 | Key invalid or expired | Refresh |
[elevenlabs] 404 Voice not found | Voice ID mismatch | Copy the ID from the voice library, not the display name |
[bailian] 400 DataInspectionFailed | Content filter | Surface raw error, do not rephrase silently |
[bailian] ... 200 chars | Input too long for one call | The CLI auto-chunks — if the error still surfaces, update to the latest skill version |
[minimax] ... 2049 invalid api key | Wrong region / host | Try MINIMAX_BASE_URL=https://api.minimaxi.com/v1 or .io/v1 |
[minimax] ... unknown voice_id | Voice not provisioned for your account | Check the MiniMax console voice list |
[siliconflow] ... 400 invalid voice | Wrong <model>:<voice> format | Must be FunAudioLLM/CosyVoice2-0.5B:alex style |
[playht] ... needs both USER_ID and API_KEY | Only one env var set | PlayHT requires both |
[playht] 400 invalid_voice_manifest | Voice URL malformed | Use an s3:// manifest URL, not a voice name |
Truncated audio? Glitches between sentences?
Byte-concatenated MP3 segments are OK for a single provider / same-config run but can glitch across ffmpeg-like tools. For broadcast-quality long-form, split the text yourself into clearly-boundaried chunks, generate each to a separate file, then stitch with ffmpeg:
ffmpeg -f concat -safe 0 -i <(for f in part*.mp3; do echo "file '$f'"; done) -c copy out.mp3happy-audio-gen — provider reference
| Provider | Env var(s) | Default model | Default voice | Long-form chunking | Voice direction | Voice cloning |
|---|---|---|---|---|---|---|
openai | OPENAI_API_KEY | gpt-4o-mini-tts | alloy | none (per-call cap ~4k chars) | ✅ via --instruction | — |
elevenlabs | ELEVENLABS_API_KEY | eleven_multilingual_v2 | Rachel (21m00Tcm4TlvDq8ikWAM) | none | — | ✅ |
bailian | DASHSCOPE_API_KEY | qwen-tts | Cherry | ✅ (auto-splits at sentence boundaries, max 200 chars/call) | partial | ✅ via qwen3-tts-vd voice-design |
minimax | MINIMAX_API_KEY | speech-02-hd | male-qn-qingse | none | via pitch / rate / vol | ✅ (separate API) |
siliconflow | SILICONFLOW_API_KEY | FunAudioLLM/CosyVoice2-0.5B | ...:alex | none | ✅ | ✅ (CosyVoice2) |
playht | PLAYHT_USER_ID + PLAYHT_API_KEY | PlayHT2.0 | S3 manifest url | none | — | ✅ |
---
1. OpenAI TTS
- Models:
gpt-4o-mini-tts(direction-capable),tts-1,tts-1-hd. - Voices:
alloy,ash,coral,echo,fable,onyx,nova,sage,shimmer. - Accepts
--rate(speed) and--instruction(only forgpt-4o-mini-tts). - Output: MP3 / WAV / OPUS / AAC / FLAC / PCM (via
response_format).
2. ElevenLabs
- Auth header is
xi-api-key, notAuthorization. - Voices are 20-char IDs (e.g.,
21m00Tcm4TlvDq8ikWAM). Browse https://elevenlabs.io/app/voice-library. - Models:
eleven_multilingual_v2,eleven_turbo_v2_5,eleven_monolingual_v1. - For voice cloning, use their
/v1/voices/addendpoint separately (not covered by the CLI yet).
3. Bailian — Qwen TTS (Aliyun DashScope)
- Endpoint:
/api/v1/services/audio/tts/speech-synthesis. - Models:
qwen-tts,qwen-tts-plus,qwen3-tts-vd(voice-design). - Voices include
Cherry,Ethan,Ivy(English), plus Chinese dialect / emotion voices — full list in the Aliyun console. - Long text: the CLI auto-splits at sentence boundaries to ≤200 chars per call, then concatenates bytes. For best quality rerun the chunks with ffmpeg concat.
qwen3-tts-vdvoice-design is a separate async flow — not wired into the default sync path; seereferences/voice-design.md(roadmap).
4. MiniMax — speech-02-hd
MINIMAX_BASE_URLmay need to behttps://api.minimaxi.com/v1(mainland) vshttps://api.minimax.io/v1(overseas) depending on how the key was provisioned.- Voices:
male-qn-qingse,female-tianxin,presenter_male, etc. Full voice list in the MiniMax console. - Output encoding from the API is hex — the CLI decodes automatically.
5. SiliconFlow — CosyVoice / SenseVoice
- Endpoint:
/v1/audio/speech(OpenAI-compatible). - Voice format:
<model-slug>:<voice-name>. Example:FunAudioLLM/CosyVoice2-0.5B:alex. - Supports
--rate(speed) and--instruction.
6. PlayHT 2.0
- Dual auth:
PLAYHT_USER_ID+PLAYHT_API_KEY. - Voice field is typically an S3 manifest URL.
- Streaming endpoint used for short clips; for long-form use the async
/ttsendpoint (roadmap).
---
Choosing a provider
| Intent | Provider |
|---|---|
| English natural voice, fast | openai |
| Multilingual with voice direction | openai (gpt-4o-mini-tts) |
| Ultra realism, voice cloning | elevenlabs or playht |
| Chinese long-form narration | bailian (auto-chunk) or minimax |
| Cheap batch processing | siliconflow CosyVoice2 |
| Custom voice creation | elevenlabs, bailian voice-design, playht |
happy-audio-gen — voice cheatsheet
Curated voice IDs for each provider. Pass via --voice <id> or set default_voice.<provider> in EXTEND.md.
OpenAI
Use any of these voice names directly:
| Voice | Character |
|---|---|
alloy | neutral, flexible (default) |
ash | direct, casual |
coral | warm, narrator |
echo | clear, even-tempered |
fable | storytelling, theatrical |
onyx | deep, authoritative |
nova | young, energetic |
sage | measured, academic |
shimmer | bright, upbeat |
ElevenLabs
Voices are 20-char IDs. Useful starters:
| ID | Name | Style |
|---|---|---|
21m00Tcm4TlvDq8ikWAM | Rachel | American, calm (default) |
AZnzlk1XvdvUeBnXmlld | Domi | American, young |
MF3mGyEYCl7XYWbV9V6O | Elli | American, emotional |
TxGEqnHWrfWFTfGW9XjX | Josh | American, narrator |
VR6AewLTigWG4xSOukaG | Arnold | American, deep |
onwK4e9ZLuTAKqWW03F9 | Daniel | British, news |
Browse more at https://elevenlabs.io/app/voice-library.
Bailian (DashScope Qwen-TTS)
| Voice | Language |
|---|---|
Cherry | English + Chinese female (default) |
Ethan | English + Chinese male |
Ivy | English female |
Chelsie | English female |
Serena | English female, news |
Sichuan | Sichuan dialect |
Cantonese | Cantonese |
qwen3-tts-vd voice-design lets you generate a custom voice from a short sample — see provider docs.
MiniMax
Common system voices:
| ID | Style |
|---|---|
male-qn-qingse | male, youthful (default) |
male-qn-jingying | male, business elite |
female-tianxin | female, sweet |
female-chengshu | female, mature |
female-shaonv | female, teen |
presenter_male | male news anchor |
presenter_female | female news anchor |
Full voice list available in the MiniMax console after signup.
SiliconFlow
Voice format: <model-slug>:<voice-name>.
| Voice | Example |
|---|---|
FunAudioLLM/CosyVoice2-0.5B:alex | generic male (default) |
FunAudioLLM/CosyVoice2-0.5B:anna | generic female |
FunAudioLLM/CosyVoice2-0.5B:david | deep male |
| Clone voice URI | siliconflow://voices/your-clone-id |
PlayHT
Voices are S3 manifest URLs. Use the PlayHT dashboard to browse → copy manifest URL:
- Default:
s3://voice-cloning-zero-shot/d9ff78ba-d016-47f6-b0ef-dd630f59414e/female-cs/manifest.json - Browse more at https://play.ht/app/voices.
import path from "node:path";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { AudioFormat, CliArgs, Provider, ProviderModule } from "./types";
import { ConfigError, ProviderError } from "./types";
import { inferFormatFromPath, validateAudioMagic, writeBytesToFile } from "./utils/output";
const KNOWN_PROVIDERS: Provider[] = [
"openai",
"elevenlabs",
"bailian",
"minimax",
"siliconflow",
"playht",
];
const IMPLEMENTED: Set<Provider> = new Set(KNOWN_PROVIDERS);
function printUsage(): void {
console.log(`happy-audio-gen — universal TTS / voice generation CLI
Usage:
bun scripts/main.ts --text "Hello world" --out ./hello.mp3
bun scripts/main.ts --textfiles script.md --out ./narration.mp3
bun scripts/main.ts --text "..." --voice alloy --provider openai --out out.mp3
Options:
-t, --text <text> Text to speak (double-quote)
--textfiles <files...> Read text from files (concatenated)
--out <path> Output file path (REQUIRED). Extension sets the format (mp3/wav/ogg/flac).
--provider <id> openai | elevenlabs | bailian | minimax | siliconflow | playht
-m, --model <id> Provider-specific model id
--voice <id> Voice id or name
--format <fmt> Override format (mp3/wav/pcm/opus/ogg/flac)
--rate <n> Speaking rate (1.0 default; 0.5–2.0 where supported)
--pitch <n> Pitch adjustment where supported
--volume <n> Volume adjustment where supported
--language <code> e.g. en, zh, ja (provider-dependent)
--instruction <text> Voice direction (gpt-4o-mini-tts style)
--poll-timeout <seconds> Default 300 (only used by async provider flows, e.g. Bailian voice-design)
--poll-interval <seconds> Default 3
--json Emit JSON result
--setup Print first-time setup guide
-h, --help Show this message
Environment variables (set at least one):
OPENAI_API_KEY, ELEVENLABS_API_KEY, DASHSCOPE_API_KEY, MINIMAX_API_KEY,
SILICONFLOW_API_KEY, PLAYHT_USER_ID + PLAYHT_API_KEY
See references/providers.md and references/config/first-time-setup.md for details.
`);
}
async function printSetupGuide(): Promise<void> {
const here = path.dirname(fileURLToPath(import.meta.url));
const guidePath = path.resolve(here, "../references/config/first-time-setup.md");
try {
const guide = await readFile(guidePath, "utf8");
console.log(guide);
} catch {
console.log("Export OPENAI_API_KEY (or another provider env var) and rerun with --text ... --out ...");
}
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
text: null,
textFiles: [],
outputPath: null,
provider: null,
model: null,
voice: null,
format: null,
rate: null,
pitch: null,
volume: null,
language: null,
instruction: null,
pollTimeoutMs: 300_000,
pollIntervalMs: 3_000,
json: false,
setup: false,
help: false,
};
const tokens = [...argv];
while (tokens.length > 0) {
const token = tokens.shift()!;
switch (token) {
case "-t":
case "--text":
args.text = tokens.shift() ?? null;
break;
case "--textfiles":
while (tokens.length > 0 && !tokens[0]!.startsWith("--") && tokens[0] !== "-t") {
args.textFiles.push(tokens.shift()!);
}
break;
case "--out":
case "--output":
args.outputPath = tokens.shift() ?? null;
break;
case "--provider":
args.provider = tokens.shift() as Provider | null;
break;
case "-m":
case "--model":
args.model = tokens.shift() ?? null;
break;
case "--voice":
args.voice = tokens.shift() ?? null;
break;
case "--format":
args.format = tokens.shift() as AudioFormat | null;
break;
case "--rate":
args.rate = Number(tokens.shift() || 1);
break;
case "--pitch":
args.pitch = Number(tokens.shift() || 0);
break;
case "--volume":
args.volume = Number(tokens.shift() || 1);
break;
case "--language":
case "--lang":
args.language = tokens.shift() ?? null;
break;
case "--instruction":
case "--direction":
args.instruction = tokens.shift() ?? null;
break;
case "--poll-timeout":
args.pollTimeoutMs = Math.max(10, Number(tokens.shift() || 300)) * 1000;
break;
case "--poll-interval":
args.pollIntervalMs = Math.max(1, Number(tokens.shift() || 3)) * 1000;
break;
case "--json":
args.json = true;
break;
case "--setup":
args.setup = true;
break;
case "-h":
case "--help":
args.help = true;
break;
default:
if (!token.startsWith("-") && args.text === null && args.textFiles.length === 0) {
args.text = token;
} else {
console.error(`Unknown argument: ${token}`);
args.help = true;
}
}
}
return args;
}
async function resolveText(args: CliArgs): Promise<string> {
if (args.text && args.textFiles.length === 0) return args.text;
if (args.textFiles.length > 0) {
const parts: string[] = [];
if (args.text) parts.push(args.text);
for (const file of args.textFiles) {
parts.push(await readFile(file, "utf8"));
}
return parts.join("\n\n").trim();
}
throw new ConfigError("No text provided. Use --text or --textfiles.");
}
function detectProvider(explicit: Provider | null): Provider {
if (explicit) {
if (!KNOWN_PROVIDERS.includes(explicit)) {
throw new ConfigError(`Unknown provider: ${explicit}. Known: ${KNOWN_PROVIDERS.join(", ")}`);
}
return explicit;
}
if (process.env.OPENAI_API_KEY) return "openai";
if (process.env.ELEVENLABS_API_KEY) return "elevenlabs";
if (process.env.DASHSCOPE_API_KEY) return "bailian";
if (process.env.MINIMAX_API_KEY) return "minimax";
if (process.env.SILICONFLOW_API_KEY) return "siliconflow";
if (process.env.PLAYHT_USER_ID && process.env.PLAYHT_API_KEY) return "playht";
throw new ConfigError(
"No provider selected and no audio-capable API key detected. Pass --provider or set one of the env vars listed in --help."
);
}
async function loadProvider(provider: Provider): Promise<ProviderModule> {
const mod = (await import(`./providers/${provider}.ts`)) as ProviderModule;
if (typeof mod.defaultModel !== "function" || typeof mod.generate !== "function") {
throw new ConfigError(`Provider module '${provider}' missing required exports.`);
}
return mod;
}
async function run(argv: string[]): Promise<number> {
const args = parseArgs(argv);
if (args.help) {
printUsage();
return 0;
}
if (args.setup) {
await printSetupGuide();
return 0;
}
if (!args.outputPath) {
console.error("--out is required (output file path).");
printUsage();
return 2;
}
const text = await resolveText(args);
const provider = detectProvider(args.provider);
const mod = await loadProvider(provider);
const model = args.model ?? mod.defaultModel();
if (!args.voice) args.voice = mod.defaultVoice();
if (!args.format) args.format = inferFormatFromPath(args.outputPath);
const bytes = await mod.generate(text, model, args);
await writeBytesToFile(bytes, args.outputPath);
const kind = validateAudioMagic(bytes);
if (args.json) {
console.log(
JSON.stringify(
{
success: true,
provider,
model,
voice: args.voice,
output: path.resolve(args.outputPath),
size_bytes: bytes.byteLength,
format: kind,
},
null,
2
)
);
} else {
console.log(
`✓ Generated ${kind.toUpperCase()} (${(bytes.byteLength / 1024).toFixed(1)} KB) via ${provider}/${model} voice=${args.voice}`
);
console.log(` ${path.resolve(args.outputPath)}`);
}
return 0;
}
run(process.argv.slice(2))
.then((code) => process.exit(code))
.catch((err) => {
if (err instanceof ProviderError) {
console.error(`[${err.provider}] ${err.message}`);
} else if (err instanceof ConfigError) {
console.error(`config: ${err.message}`);
} else if (err instanceof Error) {
console.error(err.stack || err.message);
} else {
console.error(String(err));
}
process.exit(1);
});
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { splitForTts } from "../utils/text_chunk";
import { concatenateBytes } from "../utils/output";
const API_BASE =
process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com/api/v1";
const MAX_CHARS_PER_CALL = 200; // qwen-tts family recommends <=200 Chinese chars per call
export function defaultModel(): string {
return process.env.DASHSCOPE_TTS_MODEL || "qwen-tts";
}
export function defaultVoice(): string {
return process.env.DASHSCOPE_TTS_VOICE || "Cherry";
}
function requireApiKey(): string {
const key = process.env.DASHSCOPE_API_KEY;
if (!key) {
throw new ProviderError(
"DASHSCOPE_API_KEY not set. Get one at https://dashscope.console.aliyun.com/apiKey",
"bailian"
);
}
return key;
}
type TtsResponse = {
output?: {
audio?: { data?: string; url?: string; expires_at?: number };
finish_reason?: string;
};
code?: string;
message?: string;
};
async function synthesizeOne(apiKey: string, model: string, voice: string, chunk: string): Promise<Uint8Array> {
const body = {
model,
input: { text: chunk, voice },
};
const res = await fetch(`${API_BASE}/services/audio/tts/speech-synthesis`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new ProviderError(`Bailian TTS ${res.status}: ${text}`, "bailian");
}
const payload = (await res.json()) as TtsResponse;
const audio = payload.output?.audio;
if (!audio) {
throw new ProviderError(
`Bailian returned no audio: ${payload.message ?? payload.code ?? JSON.stringify(payload)}`,
"bailian"
);
}
if (audio.data) {
return Uint8Array.from(Buffer.from(audio.data, "base64"));
}
if (audio.url) {
const r = await fetch(audio.url);
if (!r.ok) {
throw new ProviderError(`Bailian audio download ${r.status}`, "bailian");
}
return new Uint8Array(await r.arrayBuffer());
}
throw new ProviderError("Bailian response contained no audio data or url", "bailian");
}
export async function generate(text: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const voice = args.voice || defaultVoice();
const chunks = splitForTts(text, MAX_CHARS_PER_CALL);
const pieces: Uint8Array[] = [];
for (const chunk of chunks) {
pieces.push(await synthesizeOne(apiKey, model, voice, chunk));
}
if (pieces.length === 1) return pieces[0]!;
// Simple byte concat. For MP3 this works for same-sample-rate segments; for higher fidelity output
// users should split pieces into separate files and stitch with ffmpeg.
return concatenateBytes(pieces);
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
const API_BASE = process.env.ELEVENLABS_BASE_URL || "https://api.elevenlabs.io/v1";
export function defaultModel(): string {
return process.env.ELEVENLABS_TTS_MODEL || "eleven_multilingual_v2";
}
export function defaultVoice(): string {
// "Rachel" — ElevenLabs' ubiquitous default voice id
return process.env.ELEVENLABS_VOICE || "21m00Tcm4TlvDq8ikWAM";
}
function requireApiKey(): string {
const key = process.env.ELEVENLABS_API_KEY || process.env.ELEVEN_API_KEY;
if (!key) {
throw new ProviderError(
"ELEVENLABS_API_KEY not set. Get one at https://elevenlabs.io/app/settings/api-keys",
"elevenlabs"
);
}
return key;
}
function acceptHeader(format: string | null): string {
if (format === "wav") return "audio/wav";
if (format === "ogg") return "audio/ogg";
if (format === "pcm") return "audio/pcm";
if (format === "flac") return "audio/flac";
return "audio/mpeg"; // mp3
}
export async function generate(text: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const voiceId = args.voice || defaultVoice();
const voiceSettings: Record<string, unknown> = {};
if (args.rate != null) voiceSettings.speed = args.rate;
// We leave stability / similarity / style at ElevenLabs defaults.
const body = {
text,
model_id: model,
voice_settings: voiceSettings,
};
const res = await fetch(`${API_BASE}/text-to-speech/${encodeURIComponent(voiceId)}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: acceptHeader(args.format),
"xi-api-key": apiKey,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const errBody = await res.text();
throw new ProviderError(`ElevenLabs ${res.status}: ${errBody}`, "elevenlabs");
}
return new Uint8Array(await res.arrayBuffer());
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
const API_BASE = process.env.MINIMAX_BASE_URL || "https://api.minimaxi.com/v1";
export function defaultModel(): string {
return process.env.MINIMAX_TTS_MODEL || "speech-02-hd";
}
export function defaultVoice(): string {
return process.env.MINIMAX_VOICE || "male-qn-qingse";
}
function requireApiKey(): string {
const key = process.env.MINIMAX_API_KEY;
if (!key) {
throw new ProviderError(
"MINIMAX_API_KEY not set. Get one at https://platform.minimaxi.com",
"minimax"
);
}
return key;
}
type TtsResponse = {
data?: { audio?: string; status?: number };
audio_file?: string;
base_resp?: { status_code?: number; status_msg?: string };
};
function mapFormat(fmt: string | null): string {
if (fmt === "wav") return "wav";
if (fmt === "pcm") return "pcm";
if (fmt === "flac") return "flac";
return "mp3";
}
export async function generate(text: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const body: Record<string, unknown> = {
model,
text,
stream: false,
voice_setting: {
voice_id: args.voice,
speed: args.rate ?? 1,
pitch: args.pitch ?? 0,
vol: args.volume ?? 1,
},
audio_setting: {
sample_rate: 32000,
bitrate: 128000,
format: mapFormat(args.format),
channel: 1,
},
};
if (args.language) (body as Record<string, unknown>).language_boost = args.language;
const res = await fetch(`${API_BASE}/t2a_v2`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const errBody = await res.text();
throw new ProviderError(`MiniMax TTS ${res.status}: ${errBody}`, "minimax");
}
const payload = (await res.json()) as TtsResponse;
if (payload.base_resp && payload.base_resp.status_code && payload.base_resp.status_code !== 0) {
throw new ProviderError(
`MiniMax TTS ${payload.base_resp.status_code}: ${payload.base_resp.status_msg ?? "(no detail)"}`,
"minimax"
);
}
const hex = payload.data?.audio ?? payload.audio_file;
if (!hex) {
throw new ProviderError(`MiniMax returned no audio: ${JSON.stringify(payload)}`, "minimax");
}
// Payload is hex-encoded bytes.
return Uint8Array.from(Buffer.from(hex, "hex"));
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
const API_BASE = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
export function defaultModel(): string {
return process.env.OPENAI_TTS_MODEL || "gpt-4o-mini-tts";
}
export function defaultVoice(): string {
return process.env.OPENAI_TTS_VOICE || "alloy";
}
function requireApiKey(): string {
const key = process.env.OPENAI_API_KEY;
if (!key) {
throw new ProviderError(
"OPENAI_API_KEY not set. Get one at https://platform.openai.com/api-keys",
"openai"
);
}
return key;
}
export async function generate(text: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const body: Record<string, unknown> = {
model,
voice: args.voice,
input: text,
response_format: args.format ?? "mp3",
};
if (args.rate != null && args.rate > 0) body.speed = args.rate;
if (args.instruction) body.instructions = args.instruction;
const res = await fetch(`${API_BASE}/audio/speech`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const textBody = await res.text();
throw new ProviderError(`OpenAI TTS ${res.status}: ${textBody}`, "openai");
}
const buf = await res.arrayBuffer();
return new Uint8Array(buf);
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
const API_BASE = process.env.PLAYHT_BASE_URL || "https://api.play.ht/api/v2";
export function defaultModel(): string {
return process.env.PLAYHT_MODEL || "PlayHT2.0";
}
export function defaultVoice(): string {
// A ubiquitous PlayHT voice manifest URL.
return (
process.env.PLAYHT_VOICE ||
"s3://voice-cloning-zero-shot/d9ff78ba-d016-47f6-b0ef-dd630f59414e/female-cs/manifest.json"
);
}
function requireCredentials(): { userId: string; apiKey: string } {
const userId = process.env.PLAYHT_USER_ID;
const apiKey = process.env.PLAYHT_API_KEY || process.env.PLAY_HT_API_KEY;
if (!userId || !apiKey) {
throw new ProviderError(
"PLAYHT_USER_ID and PLAYHT_API_KEY must both be set. Get them at https://play.ht/app/api-access",
"playht"
);
}
return { userId, apiKey };
}
function mapFormat(fmt: string | null): string {
if (fmt === "wav") return "wav";
if (fmt === "ogg") return "ogg";
if (fmt === "flac") return "flac";
return "mp3";
}
export async function generate(text: string, model: string, args: CliArgs): Promise<Uint8Array> {
const { userId, apiKey } = requireCredentials();
const body: Record<string, unknown> = {
text,
voice: args.voice,
voice_engine: model,
output_format: mapFormat(args.format),
};
if (args.rate != null) body.speed = args.rate;
if (args.language) body.language = args.language;
const res = await fetch(`${API_BASE}/tts/stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "audio/mpeg",
Authorization: `Bearer ${apiKey}`,
"X-USER-ID": userId,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const errBody = await res.text();
throw new ProviderError(`PlayHT TTS ${res.status}: ${errBody}`, "playht");
}
return new Uint8Array(await res.arrayBuffer());
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
const API_BASE = process.env.SILICONFLOW_BASE_URL || "https://api.siliconflow.cn/v1";
export function defaultModel(): string {
return process.env.SILICONFLOW_TTS_MODEL || "FunAudioLLM/CosyVoice2-0.5B";
}
export function defaultVoice(): string {
return process.env.SILICONFLOW_VOICE || "FunAudioLLM/CosyVoice2-0.5B:alex";
}
function requireApiKey(): string {
const key = process.env.SILICONFLOW_API_KEY;
if (!key) {
throw new ProviderError(
"SILICONFLOW_API_KEY not set. Get one at https://cloud.siliconflow.cn/account/ak",
"siliconflow"
);
}
return key;
}
export async function generate(text: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const body: Record<string, unknown> = {
model,
input: text,
voice: args.voice,
response_format: args.format ?? "mp3",
};
if (args.rate != null) body.speed = args.rate;
if (args.instruction) body.instructions = args.instruction;
const res = await fetch(`${API_BASE}/audio/speech`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const errBody = await res.text();
throw new ProviderError(`SiliconFlow TTS ${res.status}: ${errBody}`, "siliconflow");
}
return new Uint8Array(await res.arrayBuffer());
}
export type Provider =
| "openai"
| "elevenlabs"
| "bailian"
| "minimax"
| "siliconflow"
| "playht";
export type AudioFormat = "mp3" | "wav" | "pcm" | "opus" | "ogg" | "flac";
export type CliArgs = {
text: string | null;
textFiles: string[];
outputPath: string | null;
provider: Provider | null;
model: string | null;
voice: string | null;
format: AudioFormat | null;
rate: number | null;
pitch: number | null;
volume: number | null;
language: string | null;
instruction: string | null; // voice instruction for gpt-4o-mini-tts
pollTimeoutMs: number;
pollIntervalMs: number;
json: boolean;
setup: boolean;
help: boolean;
};
export type ExtendConfig = {
version?: number;
default_provider?: Provider;
default_format?: AudioFormat;
default_language?: string;
default_save_dir?: string;
default_model?: Partial<Record<Provider, string>>;
default_voice?: Partial<Record<Provider, string>>;
providers?: Partial<
Record<
Provider,
{
api_key_env?: string;
api_key_source?: string;
base_url?: string;
}
>
>;
};
export type ProviderModule = {
defaultModel: () => string;
defaultVoice: () => string;
generate: (text: string, model: string, args: CliArgs) => Promise<Uint8Array>;
};
export class ConfigError extends Error {
code = "CONFIG_ERROR";
}
export class ProviderError extends Error {
constructor(message: string, public provider: Provider) {
super(message);
}
code = "PROVIDER_ERROR";
}
import path from "node:path";
import { mkdir, writeFile } from "node:fs/promises";
import type { AudioFormat } from "../types";
export async function writeBytesToFile(bytes: Uint8Array, outputPath: string): Promise<void> {
const dir = path.dirname(outputPath);
await mkdir(dir, { recursive: true });
await writeFile(outputPath, bytes);
}
export function inferFormatFromPath(outputPath: string, fallback: AudioFormat = "mp3"): AudioFormat {
const ext = path.extname(outputPath).toLowerCase().replace(/^\./, "");
if (ext === "mp3" || ext === "wav" || ext === "pcm" || ext === "opus" || ext === "ogg" || ext === "flac") {
return ext;
}
return fallback;
}
export function validateAudioMagic(bytes: Uint8Array): "mp3" | "wav" | "ogg" | "flac" | "unknown" {
if (bytes.length < 4) return "unknown";
// MP3: starts with ID3 or MPEG sync 0xFFEx
if (bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) return "mp3";
if (bytes[0] === 0xff && (bytes[1] === 0xfb || bytes[1] === 0xf3 || bytes[1] === 0xf2)) return "mp3";
// WAV: RIFF....WAVE
if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) return "wav";
// OGG: OggS
if (bytes[0] === 0x4f && bytes[1] === 0x67 && bytes[2] === 0x67 && bytes[3] === 0x53) return "ogg";
// FLAC: fLaC
if (bytes[0] === 0x66 && bytes[1] === 0x4c && bytes[2] === 0x61 && bytes[3] === 0x43) return "flac";
return "unknown";
}
export function concatenateBytes(parts: Uint8Array[]): Uint8Array {
let total = 0;
for (const p of parts) total += p.length;
const out = new Uint8Array(total);
let offset = 0;
for (const p of parts) {
out.set(p, offset);
offset += p.length;
}
return out;
}
// Split long text into segments small enough for a per-call TTS limit.
// Prefer splitting on sentence boundaries (Chinese 。!? + English . ! ?).
// Falls back to hard cut at `maxChars` if no punctuation is found.
const SENTENCE_END = /([.!?。!?…\n])\s*/g;
export function splitForTts(text: string, maxChars: number): string[] {
const trimmed = text.trim();
if (trimmed.length <= maxChars) return [trimmed];
const sentences: string[] = [];
let lastIndex = 0;
for (const match of trimmed.matchAll(SENTENCE_END)) {
const endIndex = (match.index ?? 0) + match[0].length;
const chunk = trimmed.slice(lastIndex, endIndex).trim();
if (chunk.length > 0) sentences.push(chunk);
lastIndex = endIndex;
}
if (lastIndex < trimmed.length) {
const tail = trimmed.slice(lastIndex).trim();
if (tail.length > 0) sentences.push(tail);
}
if (sentences.length === 0) {
// No punctuation found — fall back to hard chunking.
const chunks: string[] = [];
for (let i = 0; i < trimmed.length; i += maxChars) {
chunks.push(trimmed.slice(i, i + maxChars));
}
return chunks;
}
// Greedily pack sentences into buckets <= maxChars.
const buckets: string[] = [];
let current = "";
for (const s of sentences) {
if (s.length > maxChars) {
if (current.length > 0) {
buckets.push(current);
current = "";
}
for (let i = 0; i < s.length; i += maxChars) {
buckets.push(s.slice(i, i + maxChars));
}
continue;
}
if ((current + s).length <= maxChars) {
current = current.length > 0 ? `${current} ${s}` : s;
} else {
buckets.push(current);
current = s;
}
}
if (current.length > 0) buckets.push(current);
return buckets;
}