{"id":51333769,"url":"https://github.com/arkodeepsen/qwen3-tts","last_synced_at":"2026-07-02T01:02:00.748Z","repository":{"id":368695553,"uuid":"1286336064","full_name":"arkodeepsen/qwen3-tts","owner":"arkodeepsen","description":"RunPod serverless endpoint for Qwen3-TTS-12Hz-1.7B-Base voice cloning: register a voice once, generate long-form speech and sentence-level SRT by voice_id. Scale-to-zero.","archived":false,"fork":false,"pushed_at":"2026-07-01T20:18:02.000Z","size":74,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-07-01T20:26:19.353Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/arkodeepsen.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-07-01T17:12:06.000Z","updated_at":"2026-07-01T20:18:06.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/arkodeepsen/qwen3-tts","commit_stats":null,"previous_names":["arkodeepsen/qwen3-tts"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/arkodeepsen/qwen3-tts","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arkodeepsen%2Fqwen3-tts","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arkodeepsen%2Fqwen3-tts/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arkodeepsen%2Fqwen3-tts/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arkodeepsen%2Fqwen3-tts/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/arkodeepsen","download_url":"https://codeload.github.com/arkodeepsen/qwen3-tts/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arkodeepsen%2Fqwen3-tts/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35028642,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-01T02:00:05.325Z","response_time":130,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-07-02T01:01:37.366Z","updated_at":"2026-07-02T01:02:00.738Z","avatar_url":"https://github.com/arkodeepsen.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Qwen3-TTS Voice Cloning — Serverless RunPod Worker\n\n[![RunPod](https://api.runpod.io/badge/arkodeepsen/qwen3-tts)](https://console.runpod.io/hub/arkodeepsen/qwen3-tts)\n\nDeploy in one click from the [RunPod Hub](https://console.runpod.io/hub/arkodeepsen/qwen3-tts), or build the image yourself (below).\n\nA [RunPod Serverless](https://www.runpod.io/serverless-gpu) worker that wraps\n[Qwen3-TTS](https://github.com/QwenLM/Qwen3-TTS) voice cloning: register a\nspeaker from a short reference clip, then generate speech in that voice from\narbitrary text — with automatic sentence-level chunking, consistent timbre\nacross chunks, multi-format audio encoding, and optional SRT subtitle output.\n\nThe worker scales to zero when idle. You only pay while a job is actually\nrunning.\n\n## Features\n\n- **Voice cloning from a short reference clip** — register a speaker once\n  (`register_voice`), then reuse the `voice_id` for unlimited `generate`\n  calls without re-uploading reference audio.\n- **Persistent voice registry** on a RunPod network volume\n  (`/runpod-volume/voices/\u003cvoice_id\u003e/`) — voices survive worker restarts and\n  cold starts.\n- **Long-text chunking** — input text is split into sentences (each\n  generation unit ≤ 200 characters) and re-stitched with a fixed inter-chunk\n  gap, so arbitrarily long scripts synthesize with consistent timbre (a\n  single fixed seed is re-applied before every chunk).\n- **Multi-format output** — `wav`, `mp3`, `flac`, `opus`, returned as\n  base64 in the JSON response by default.\n- **Optional SRT subtitles** — request `return_srt: true` to get\n  per-sentence timed subtitle segments alongside the audio.\n- **Optional object storage (S3)** — `generate`'s `output` field\n  (`\"auto\"` by default) transparently uploads large results and returns a\n  `url` instead of inline base64, so you never have to think about response\n  size limits. See [Object storage (S3)](#object-storage-s3).\n- **Scale-to-zero billing** — `min_workers=0`, aggressive `idle_timeout`,\n  and `flashboot` for fast cold starts, so the endpoint costs nothing at\n  rest.\n\n## Model Specs\n\n| | |\n|---|---|\n| Model | [`Qwen/Qwen3-TTS-12Hz-1.7B-Base`](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-Base) |\n| Task | Zero-shot / few-shot voice cloning text-to-speech |\n| Precision | `bfloat16` |\n| Attention | `flash_attention_2` (falls back to `sdpa` if unavailable) |\n| Device | Single CUDA GPU (`cuda:0`) |\n| Min VRAM | 16 GB (weights ≈ 5 GB) |\n| Recommended GPUs | RTX 4090, L4, RTX A5000, RTX A4000, L40S |\n\n## API Usage\n\n\u003e **Plugging this into another project?** See **[API.md](./API.md)** — a\n\u003e language-agnostic HTTP integration guide with the production async\n\u003e `/run` → poll `/status` pattern and copy-paste **cURL / Python / Node·TypeScript**\n\u003e clients. The section below is a quick reference.\n\nThe worker exposes a single RunPod job endpoint that dispatches on an\n`\"action\"` field in the job input: `register_voice`, `generate`,\n`delete_output`, `list_voices`, `delete_voice`.\n\nEvery response is a JSON envelope: `{\"success\": true, ...}` on success or\n`{\"success\": false, \"error\": \"\u003cmessage\u003e\"}` on failure.\n\n### 1. `register_voice`\n\nRegisters a new voice from a short reference clip + transcript and returns a\n`voice_id` you reuse for all future `generate` calls.\n\nRequest:\n\n```json\n{\n  \"input\": {\n    \"action\": \"register_voice\",\n    \"name\": \"narrator\",\n    \"ref_audio\": \"\u003cbase64-encoded WAV/FLAC/MP3 bytes, data URI, or https:// URL\u003e\",\n    \"ref_text\": \"This is the exact transcript of the reference clip.\",\n    \"language\": \"Auto\"\n  }\n}\n```\n\nResponse:\n\n```json\n{\n  \"success\": true,\n  \"voice_id\": \"narrator-a1b2c3\",\n  \"sample_rate\": 24000,\n  \"name\": \"narrator\",\n  \"language\": \"Auto\"\n}\n```\n\n### 2. `generate`\n\nSynthesizes speech in a previously registered voice.\n\nRequest:\n\n```json\n{\n  \"input\": {\n    \"action\": \"generate\",\n    \"voice_id\": \"narrator-a1b2c3\",\n    \"text\": \"Hello there! This is a longer script that will be split into sentence-level chunks and re-stitched into one continuous clip.\",\n    \"language\": \"Auto\",\n    \"seed\": 42,\n    \"return_srt\": false,\n    \"response_format\": \"wav\"\n  }\n}\n```\n\nOptional per-request tuning params (override the worker's env defaults — see\n[Generation tuning \u0026 stability](#generation-tuning--stability)):\n\n```json\n{\n  \"input\": {\n    \"action\": \"generate\",\n    \"voice_id\": \"narrator-a1b2c3\",\n    \"text\": \"Hello there!\",\n    \"temperature\": 0.7,\n    \"top_p\": 0.85,\n    \"top_k\": 50,\n    \"repetition_penalty\": 1.2,\n    \"max_new_tokens\": 1024\n  }\n}\n```\n\nResponse:\n\n```json\n{\n  \"success\": true,\n  \"audio_base64\": \"UklGRiQAAABXQVZFZm10IBAAAAABAAEA...\",\n  \"format\": \"wav\",\n  \"sample_rate\": 24000,\n  \"duration_sec\": 4.816,\n  \"chunks\": 2,\n  \"size_bytes\": 231044,\n  \"srt\": null,\n  \"segments\": null\n}\n```\n\nWith `\"return_srt\": true`, `srt` and `segments` are populated (one segment\nper sentence, so each has a precisely measured duration):\n\n```json\n{\n  \"success\": true,\n  \"audio_base64\": \"UklGRiQAAABXQVZFZm10IBAAAAABAAEA...\",\n  \"format\": \"wav\",\n  \"sample_rate\": 24000,\n  \"duration_sec\": 4.816,\n  \"chunks\": 2,\n  \"size_bytes\": 231044,\n  \"srt\": \"1\\n00:00:00,000 --\u003e 00:00:02,340\\nHello there!\\n\\n2\\n00:00:02,490 --\u003e 00:00:04,816\\nThis is a longer script...\\n\",\n  \"segments\": [\n    {\"index\": 1, \"start\": 0.0, \"end\": 2.34, \"text\": \"Hello there!\"},\n    {\"index\": 2, \"start\": 2.49, \"end\": 4.816, \"text\": \"This is a longer script...\"}\n  ]\n}\n```\n\n**Getting a URL instead of base64:** the `output` field controls this —\n`\"auto\"` (default), `\"base64\"`, or `\"url\"`. `auto` returns inline\n`audio_base64` when the encoded audio is at or below `MAX_INLINE_BYTES`\n(5 MB by default); above that it uploads to [object storage](#object-storage-s3)\nand returns `url` + `key` instead. `base64` always inlines; `url` always\nuploads (and errors if storage isn't configured). If storage isn't\nconfigured at all, `auto` always falls back to base64. The response drops\n`audio_base64` and adds `url` + `key` when a URL is returned — every other\nfield (`format`, `sample_rate`, `duration_sec`, `chunks`, `size_bytes`, `srt`,\n`segments`) is unchanged:\n\n```json\n{\n  \"input\": {\n    \"action\": \"generate\",\n    \"voice_id\": \"narrator-a1b2c3\",\n    \"text\": \"Hello there!\",\n    \"output\": \"url\",\n    \"key\": \"narrations/intro.wav\"\n  }\n}\n```\n\n```json\n{\n  \"success\": true,\n  \"url\": \"https://\u003cbucket\u003e.\u003cendpoint\u003e/qwen3-tts/narrations/intro.wav?X-Amz-...\",\n  \"key\": \"qwen3-tts/narrations/intro.wav\",\n  \"format\": \"wav\",\n  \"sample_rate\": 24000,\n  \"duration_sec\": 4.816,\n  \"chunks\": 2,\n  \"size_bytes\": 231044,\n  \"srt\": null,\n  \"segments\": null\n}\n```\n\n`key` is optional — omit it and the worker auto-generates one\n(`outputs/\u003cuuid\u003e.\u003cformat\u003e`), namespaced under `S3_PREFIX` either way.\n\n### 3. `delete_output`\n\nDeletes a single previously-uploaded output object by key. Useful for\nfreeing space right after you've fetched a `url` from `generate` — see the\n[cleanup warning](#object-storage-s3) below on why this matters for the\nRunPod network-volume bucket.\n\nRequest:\n\n```json\n{\n  \"input\": {\n    \"action\": \"delete_output\",\n    \"key\": \"qwen3-tts/outputs/1a2b3c4d.wav\"\n  }\n}\n```\n\nResponse:\n\n```json\n{\n  \"success\": true,\n  \"deleted\": \"qwen3-tts/outputs/1a2b3c4d.wav\"\n}\n```\n\nOnly keys under the `outputs/` prefix can be deleted this way — anything\nelse returns the standard error envelope.\n\n### 4. `list_voices`\n\nLists every voice currently registered on the network volume.\n\nRequest:\n\n```json\n{\n  \"input\": {\n    \"action\": \"list_voices\"\n  }\n}\n```\n\nResponse:\n\n```json\n{\n  \"success\": true,\n  \"voices\": [\n    {\"voice_id\": \"narrator-a1b2c3\", \"name\": \"narrator\", \"language\": \"Auto\", \"sample_rate\": 24000, \"created_at\": \"2026-07-01T12:00:00+00:00\"},\n    {\"voice_id\": \"assistant-9f8e7d\", \"name\": \"assistant\", \"language\": \"en\", \"sample_rate\": 22050, \"created_at\": \"2026-07-01T12:05:00+00:00\"}\n  ]\n}\n```\n\n### 5. `delete_voice`\n\nPermanently removes a voice profile from the registry.\n\nRequest:\n\n```json\n{\n  \"input\": {\n    \"action\": \"delete_voice\",\n    \"voice_id\": \"narrator-a1b2c3\"\n  }\n}\n```\n\nResponse:\n\n```json\n{\n  \"success\": true,\n  \"deleted\": \"narrator-a1b2c3\"\n}\n```\n\nIf the `voice_id` is unknown, `success` is `false` and `deleted` is `null`:\n\n```json\n{\n  \"success\": false,\n  \"deleted\": null,\n  \"error\": \"Unknown voice_id: narrator-a1b2c3\"\n}\n```\n\n## Parameters\n\n### `register_voice`\n\n| Parameter | Type | Required | Default | Description |\n|---|---|---|---|---|\n| `name` | string | yes | — | Human-readable voice name. Slugified into part of the generated `voice_id`. |\n| `ref_audio` | string | yes | — | Reference clip: base64 audio, a `data:` URI, or a public `https://` URL. Local filesystem paths are rejected. |\n| `ref_text` | string | yes | — | Exact transcript of `ref_audio`. |\n| `language` | string | no | `\"Auto\"` | Language hint for prompt construction. |\n\n### `generate`\n\n| Parameter | Type | Required | Default | Description |\n|---|---|---|---|---|\n| `voice_id` | string | yes | — | `voice_id` returned by `register_voice`. |\n| `text` | string | yes | — | Text to synthesize. Split into ≤ 200-character sentence chunks internally. |\n| `language` | string | no | `\"Auto\"` | Language hint for synthesis. |\n| `seed` | integer | no | `42` | Applied before every chunk (`torch.manual_seed`) so timbre stays consistent across a long script. |\n| `return_srt` | boolean | no | `false` | If `true`, forces one generation unit per sentence and returns timed `srt`/`segments`. |\n| `response_format` | string | no | `\"wav\"` | One of `wav`, `mp3`, `flac`, `opus`. |\n| `temperature` | float | no | `0.8` | Sampling temperature. Higher = more varied/expressive, lower = more stable. |\n| `top_p` | float | no | `0.9` | Nucleus sampling threshold. Lower = more stable. |\n| `top_k` | integer | no | `50` | Top-k sampling cutoff. |\n| `repetition_penalty` | float | no | `1.1` | Raise toward `1.3` if you hear looping/rambling. |\n| `max_new_tokens` | integer | no | `1024` | Hard cap on tokens per sentence-chunk (safety against runaway generation). |\n| `output` | string | no | `\"auto\"` | `\"auto\"` (inline for small results, uploads and returns a URL for large ones), `\"base64\"` (always inline), or `\"url\"` (always upload; errors if S3 isn't configured). See [Object storage (S3)](#object-storage-s3). |\n| `key` | string | no | auto-generated | Object name for the upload when a URL is returned, namespaced under `S3_PREFIX`. |\n\nAll five tuning params are optional per-request overrides of the worker's\nenv-tunable defaults — omit them to use the operator defaults (see\n[Generation tuning \u0026 stability](#generation-tuning--stability)).\n\n### `delete_output`\n\n| Parameter | Type | Required | Default | Description |\n|---|---|---|---|---|\n| `key` | string | yes | — | Output object key to delete (must be under the `outputs/` prefix). |\n\nRequires [object storage](#object-storage-s3) to be configured.\n\n### `delete_voice`\n\n| Parameter | Type | Required | Default | Description |\n|---|---|---|---|---|\n| `voice_id` | string | yes | — | Voice profile to delete. |\n\n## Generation tuning \u0026 stability\n\nThe 1.7B-Base model can occasionally **ramble or fail to emit an\nend-of-sequence token**, producing minutes of audio for what should be a\nsingle short sentence. This is a known model quirk, not a bug in this worker\n— see [QwenLM/Qwen3-TTS#239](https://github.com/QwenLM/Qwen3-TTS/issues/239).\n\nTwo known triggers:\n\n- **Mismatched `ref_text`.** If the transcript passed to `register_voice`\n  does not *exactly* match the words spoken in `ref_audio`, the ICL\n  voice-clone conditioning is corrupted and the model is more likely to run\n  away. **Always register with the precise transcript of the clip.**\n- **Loose sampling.** High temperature / high top_p / low repetition penalty\n  make runaway generation more likely. This worker ships **stability-focused\n  defaults** (`repetition_penalty=1.1`, `top_p=0.9`, `temperature=0.8`)\n  specifically to minimize this.\n\nReference audio is also trimmed to `REF_AUDIO_MAX_SEC` (30s by default) at\nregistration — long reference clips are another EOS-failure trigger.\n\n**If you still hear rambling/looping on a particular voice or line:**\n\n- Raise `repetition_penalty` (`1.1` → `1.3`).\n- Lower `top_p` (`0.9` → `0.8`) and/or `temperature` (`0.8` → `0.7`).\n- For more expressive/varied delivery (at some stability cost), raise\n  `temperature` a little and watch for looping.\n\nTune these either **per-request** (the `generate` params above) or as\n**operator defaults** via env vars, which apply to every request that\ndoesn't override them:\n\n| Env var | Default | Purpose |\n|---|---|---|\n| `TOP_K` | `50` | Default `top_k` for all `generate` calls. |\n| `TOP_P` | `0.9` | Default `top_p`. |\n| `TEMPERATURE` | `0.8` | Default `temperature`. |\n| `REPETITION_PENALTY` | `1.1` | Default `repetition_penalty`. |\n| `MAX_NEW_TOKENS` | `1024` | Default `max_new_tokens` (per-chunk hard cap). |\n| `REF_AUDIO_MAX_SEC` | `30` | Max reference-clip length kept at registration. |\n\nPer-request `generate` params always override these env defaults.\n\n## Object storage (S3)\n\n**Optional.** The default `generate` behavior (base64 audio inline in the\nresponse) works with **no storage configured at all** — `output: \"auto\"`\nsilently stays on base64 if S3 isn't set up. Storage is only needed if you\nwant `generate` to hand back a `url` for large results (or you explicitly\nrequest `output: \"url\"`), or you want to use `delete_output`.\n\nAny S3-compatible provider works — Cloudflare R2, AWS S3, Backblaze B2,\nMinIO, etc.\n\n**Using RunPod's own network-volume S3 (recommended, since it needs no\nseparate account):** every RunPod network volume exposes an S3-compatible\nAPI. Point the worker at your volume with:\n\n| Env var | Value |\n|---|---|\n| `S3_ENDPOINT_URL` | `https://s3api-\u003cregion\u003e.runpod.io` (e.g. `https://s3api-eu-ro-1.runpod.io`) |\n| `S3_BUCKET` | your network-volume ID |\n| `S3_REGION` | the volume's region (e.g. `eu-ro-1`) |\n| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | a RunPod S3 API key pair (RunPod console → Settings → S3 API Keys) |\n\nR2 / AWS S3 / Backblaze B2 / MinIO are configured the same way — just swap\nin that provider's endpoint, bucket, region, and credentials.\n\nFull env-var reference:\n\n| Env var | Required? | Default | Purpose |\n|---|---|---|---|\n| `S3_ENDPOINT_URL` | required for non-AWS | — | e.g. RunPod's `https://s3api-eu-ro-1.runpod.io`, or R2's `https://\u003cacct\u003e.r2.cloudflarestorage.com`. Blank for AWS S3. |\n| `S3_BUCKET` | required (to enable storage) | — | Bucket name (for RunPod: your network-volume ID). |\n| `S3_ACCESS_KEY_ID` | required (to enable storage) | — | Access key. |\n| `S3_SECRET_ACCESS_KEY` | required (to enable storage) | — | Secret key. |\n| `S3_REGION` | no | `auto` | Region passed to the S3 client. |\n| `S3_PUBLIC_BASE_URL` | no | — | If set, returned URLs are public (`S3_PUBLIC_BASE_URL/\u003ckey\u003e`). If unset, time-limited presigned URLs are returned instead. |\n| `S3_URL_EXPIRY` | no | `604800` (7 days) | Presigned URL lifetime, in seconds. |\n| `S3_PREFIX` | no | `qwen3-tts` | Key prefix all auto-generated/namespaced objects are stored under. |\n| `MAX_INLINE_BYTES` | no | `5242880` (5 MB) | `output: \"auto\"` threshold — encoded audio at or below this size is returned inline as base64; above it, uploaded and returned as a `url`. |\n| `MAX_BASE64_BYTES` | no | `8388608` (8 MB) | Hard cap for inline base64 (RunPod rejects oversized responses). Above it, the result is uploaded and returned as a `url` when S3 is configured (even for `output: \"base64\"`), otherwise the request errors — never a silent job failure. Tune to your endpoint's response limit. |\n| `OUTPUT_TTL_SEC` | no | `86400` (24 h) | Age at which objects under `outputs/` are auto-pruned (see warning below). Set to `0` to disable auto-pruning. |\n\nStorage is considered configured once `S3_BUCKET`, `S3_ACCESS_KEY_ID`, and\n`S3_SECRET_ACCESS_KEY` are all set. If `output: \"url\"` (or `auto` needing to\nupload) is requested without storage configured, the job returns the\nstandard error envelope (`{\"success\": false, \"error\": \"S3 storage is not\nconfigured. ...\"}`) instead of failing silently.\n\n**Important — the RunPod network-volume bucket is shared.** If you use\nRunPod's network-volume S3, it is the **same 10 GB volume** that also holds\nthe model cache (~5 GB) and the voice registry — output files must not be\nallowed to accumulate on it. To guard against that, every upload triggers a\nbest-effort prune of objects under `outputs/` older than `OUTPUT_TTL_SEC`\n(default 24 hours). Call [`delete_output`](#3-delete_output) to free a\nspecific object immediately, e.g. right after you've downloaded it from its\n`url`.\n\n## Long-form audio (audiobooks / hours)\n\nA single serverless job **cannot** return hours of audio: it's bounded by the\nper-job `execution_timeout` (~300s, see [Deployment](#deployment)) and by the\nresponse payload size. The API is intentionally simple — one call does one\nsynthesis — so long-form audio is a **client-side concern**: split the script\ninto blocks and fire off **parallel `generate` requests** against your\nendpoint (RunPod fans them out across `max_workers` automatically, which is\nfaster than one long sequential job anyway), then assemble the results\nyourself.\n\n`client/longform.py` does exactly that. Register the voice **once** first\nwith `register_voice` (or the `client/cli.py register` subcommand), then:\n\n```bash\npython client/longform.py --voice-id \u003cid\u003e --input book.txt --output audiobook.mp3 --concurrency 12\n```\n\nIt splits the script paragraph/sentence-aware (`--block-chars`, default\n1200), generates blocks **in parallel** (`--concurrency` — uncapped; set it\nto your endpoint's `max_workers` for maximum throughput), downloads each\nblock's base64 audio, and concatenates them **on your machine** into one\noutput file (`--format wav|mp3|flac|opus`). It also accepts the tuning flags\nfrom the section above (`--temperature`, `--top-p`, `--top-k`,\n`--repetition-penalty`, `--max-new-tokens`, `--seed`).\n\nTotal compute time is roughly `audio length × real-time factor` (see\n[Performance](#performance)) — e.g. a two-hour audiobook takes on the order\nof two hours of *billed GPU time*, but wall-clock time shrinks close to\n`total time ÷ concurrency` since blocks run in parallel across your\nendpoint's workers.\n\nNo object storage is required for this flow — everything is assembled\nlocally. If you want the final file available as a URL, either upload the\nlocal file yourself or make one more `generate` call with `output: \"url\"`\nagainst the assembled/re-split text; there is no server-side merge action.\n\n## Output Format\n\nWith `output: \"auto\"` (the default) or `\"base64\"`, binary audio is returned\nas a base64 string in `audio_base64`, alongside:\n\n| Field | Type | Description |\n|---|---|---|\n| `audio_base64` | string | Base64-encoded audio bytes in the requested `format`. Present when the response is inline (`output: \"base64\"`, or `\"auto\"` under the inline-size threshold). |\n| `format` | string | Echo of the resolved output format (`wav`, `mp3`, `flac`, `opus`). |\n| `sample_rate` | integer | Sample rate of the generated audio, in Hz. This is model-native — it is whatever the model emits, not a caller-selectable parameter. |\n| `duration_sec` | float | Total duration of the stitched clip, in seconds. |\n| `chunks` | integer | Number of generation units (sentences/packed groups) synthesized and concatenated. |\n| `size_bytes` | integer | Size of the decoded (pre-base64) audio bytes. |\n| `srt` | string \\| null | SRT-formatted subtitle text when `return_srt: true`, else `null`. |\n| `segments` | array \\| null | Per-sentence `{index, start, end, text}` timing objects when `return_srt: true`, else `null`. |\n\nWhen a URL is returned instead (`output: \"url\"`, or `\"auto\"` over the\ninline-size threshold), `audio_base64` is replaced by `url` (a public or\ntime-limited presigned link, see [Object storage](#object-storage-s3)) and\n`key` (the S3 object key) — every other field above is unchanged.\n\n## Python Example\n\n```python\nimport base64\nimport runpod\n\nrunpod.api_key = \"YOUR_RUNPOD_API_KEY\"\nendpoint = runpod.Endpoint(\"YOUR_ENDPOINT_ID\")\n\n# 1. Register a voice from a local reference clip\nwith open(\"reference.wav\", \"rb\") as f:\n    ref_b64 = base64.b64encode(f.read()).decode(\"utf-8\")\n\nreg = endpoint.run_sync({\n    \"action\": \"register_voice\",\n    \"name\": \"narrator\",\n    \"ref_audio\": ref_b64,\n    \"ref_text\": \"This is the exact transcript of the reference clip.\",\n    \"language\": \"Auto\",\n})\nvoice_id = reg[\"voice_id\"]\n\n# 2. Generate speech in that voice\nresult = endpoint.run_sync({\n    \"action\": \"generate\",\n    \"voice_id\": voice_id,\n    \"text\": \"Hello there! This is Qwen3-TTS speaking in a cloned voice.\",\n    \"return_srt\": True,\n    \"response_format\": \"mp3\",\n})\n\nwith open(\"output.mp3\", \"wb\") as f:\n    f.write(base64.b64decode(result[\"audio_base64\"]))\n\nprint(result[\"duration_sec\"], \"seconds,\", result[\"chunks\"], \"chunks\")\nprint(result[\"srt\"])\n```\n\n## cURL Example\n\n```bash\n# register_voice\ncurl -s -X POST \"https://api.runpod.ai/v2/YOUR_ENDPOINT_ID/runsync\" \\\n  -H \"Authorization: Bearer $RUNPOD_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"input\": {\n          \"action\": \"register_voice\",\n          \"name\": \"narrator\",\n          \"ref_audio\": \"'\"$(base64 -w0 reference.wav)\"'\",\n          \"ref_text\": \"This is the exact transcript of the reference clip.\",\n          \"language\": \"Auto\"\n        }\n      }'\n\n# generate (using the voice_id returned above)\ncurl -s -X POST \"https://api.runpod.ai/v2/YOUR_ENDPOINT_ID/runsync\" \\\n  -H \"Authorization: Bearer $RUNPOD_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"input\": {\n          \"action\": \"generate\",\n          \"voice_id\": \"narrator-a1b2c3\",\n          \"text\": \"Hello there! This is Qwen3-TTS speaking in a cloned voice.\",\n          \"response_format\": \"wav\"\n        }\n      }'\n```\n\n## Deployment\n\n### Network volume\n\nVoice profiles must persist across cold starts and across workers, so this\nworker requires a RunPod **network volume** attached at `/runpod-volume`:\n\n| Env var | Value | Purpose |\n|---|---|---|\n| `HF_HOME` | `/runpod-volume` | Hugging Face cache root, persisted on the volume. |\n| `HF_HUB_CACHE` | `/runpod-volume` | Model weights cache, persisted on the volume (avoids re-downloading ~5 GB on every cold start). |\n| `VOICE_DIR` | `/runpod-volume/voices` | Voice registry root — `meta.json`, `ref.wav`, `prompt.pt` per `voice_id`. |\n\nRecommended volume size: **20 GB** (≈5 GB model weights + voice registry\nheadroom).\n\n### Scale to zero\n\nThe endpoint is configured (see `runpod.toml`) to **scale to zero** when\nidle, so it costs nothing between requests:\n\n| Setting | Value | Effect |\n|---|---|---|\n| `min_workers` | `0` | No always-on workers — $0 at rest. |\n| `idle_timeout` | `5` | Worker stays warm only 5s after the last job before spinning down. |\n| `flashboot` | `true` | Fast cold starts, making a short `idle_timeout` practical. |\n| `max_workers` | `3` | Caps concurrent scale-out. |\n| `execution_timeout` | `300` | Caps runaway/hung jobs. |\n\n## Cost \u0026 Billing\n\n- Only **`/run`** (async) and **`/runsync`** (sync) invocations start a\n  worker and bill GPU time. Every `generate`, `register_voice`,\n  `list_voices`, `delete_voice`, and `delete_output` call above goes through\n  one of these two endpoints and is billable while the worker is active.\n- **`/status`** polling (used to check the result of an async `/run` job)\n  is **free** — it queries RunPod's control plane and **never wakes a\n  worker**. Poll `/status` as often as you like while waiting on a job.\n- **Never health-check via `/run` or `/runsync`.** Doing so bills a worker\n  and defeats scale-to-zero. For liveness, use RunPod's endpoint-level\n  `GET https://api.runpod.ai/v2/\u003cendpoint_id\u003e/health` — it reports queue and\n  worker counts from the control plane and does **not** wake or bill a worker.\n  This service's own handler exposes no HTTP route (it is a RunPod serverless\n  worker), so all interaction is through the `/run`, `/runsync`, and `/status`\n  job API.\n- Because `min_workers=0`, the endpoint bills **$0 while idle** — cost is\n  incurred only for the wall-clock duration of active `/run`/`/runsync`\n  jobs, plus the short `idle_timeout` warm tail after each job.\n\n## Performance\n\nBenchmarked on a single 24 GB-class GPU (RTX 4090 / L40S) with\n`flash_attention_2` and `bfloat16`: **~1.56× real-time factor (RTF)** —\ni.e. generating 1 second of audio takes roughly 0.64 seconds of compute\n(excluding cold start / model load time).\n\n## License\n\n[Apache License 2.0](./LICENSE).\n\n## Credits\n\nBuilt on [Qwen3-TTS](https://github.com/QwenLM/Qwen3-TTS) by the\n[QwenLM](https://github.com/QwenLM) team. Model weights:\n[`Qwen/Qwen3-TTS-12Hz-1.7B-Base`](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-Base).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Farkodeepsen%2Fqwen3-tts","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Farkodeepsen%2Fqwen3-tts","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Farkodeepsen%2Fqwen3-tts/lists"}