Qwen3-TTS 1.7B Custom Voice

Text read aloud in one of the voices the model publishes, with an optional instruction for how to read it.

Speechl40sAlibabaApache-2.0Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice

What runs

This is the whole app. The entry names the model and what serves it, and the build turns that into these files: there is nothing else to write.

model.py
"""Qwen3-TTS 1.7B Custom Voice, generated from a declarative template."""

from __future__ import annotations

import base64
import tempfile

from pydantic import BaseModel, ConfigDict, Field

from runware_serverless import serve

MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
REVISION = "0c0e3051f131929182e2c023b9537f8b1c68adfe"
CACHE = "/weights"
DEFAULT_LANGUAGE = "auto"


class Request(BaseModel):
    # An input this model does not read is a line the caller believed was doing
    # something, so it is refused rather than dropped. It matters more here than
    # elsewhere: the three checkpoint families take different fields, and a
    # speaker sent to a voice-design model would otherwise be silently lost.
    model_config = ConfigDict(extra="forbid")

    text: str = Field(description="What to read aloud.")
    speaker: str = Field(description="Which published voice to read it in. The response of a failed request lists the names this checkpoint supports.")
    instruct: str | None = Field(default=None, description="How to read it, in words, for example calm and slow.")
    # Constrained to the model's own list rather than left open. It takes a
    # name and not a code, so "en" is refused where "english" is read, and a
    # pattern says so in the schema instead of in a 400 from the model.
    language: str = Field(
        default=DEFAULT_LANGUAGE,
        pattern="^(auto|chinese|english|french|german|italian|japanese|korean|portuguese|russian|spanish)$",
        description="The language to read in, named rather than coded. Use auto to leave the choice to the model.",
    )


class Spoken(BaseModel):
    audio: str = Field(description="The speech as a base64 WAV.")
    seconds: float
    sample_rate: int


@serve
class Speech:
    def load(self) -> None:
        if hasattr(self, "model"):
            return

        # Imported here rather than at the top of the file. The build imports
        # this file with the platform's own dependencies, so nothing above
        # exists until a worker runs.
        import torch
        from huggingface_hub import snapshot_download
        from qwen_tts import Qwen3TTSModel

        self.torch = torch

        # The whole repository first, then a local path. Handing the repo id
        # straight to from_pretrained fetches only what the root config names,
        # and the speech tokenizer in its own subfolder is then looked for on
        # disk where nothing was written: it fails with "Can't load feature
        # extractor for .../speech_tokenizer". Snapshotting also puts the weights
        # in the volume, which the inner loads do not pass cache_dir down to, so
        # without this every cold start downloads again.
        local = snapshot_download(MODEL, revision=REVISION, cache_dir=CACHE)

        # Straight onto the card. A worker's host RAM is half its card's VRAM,
        # so a model assembled on the host and moved has a ceiling below the
        # card's own.
        self.model = Qwen3TTSModel.from_pretrained(
            local,
            dtype=torch.bfloat16,
            device_map="cuda",
        )

    def predict(self, request: Request) -> Spoken:
        import soundfile

        wavs, rate = self.model.generate_custom_voice(
            text=request.text,
            speaker=request.speaker,
            language=request.language,
            instruct=request.instruct,
        )

        # One text in, so one waveform out. The package returns a list because
        # it batches, and the rate beside it rather than on the model.
        track = wavs[0]

        with tempfile.NamedTemporaryFile(suffix=".wav") as handle:
            soundfile.write(handle.name, track, rate, format="WAV")
            handle.seek(0)
            encoded = base64.b64encode(handle.read()).decode("ascii")

        return Spoken(
            audio=encoded,
            seconds=len(track) / rate,
            sample_rate=rate,
        )
runware.yaml
configVersion: 1
id: qwen3-tts-17b-custom-voice
name: Qwen3-TTS 1.7B Custom Voice
source:
  type: code
  entry: model.py
compute:
  gpuType: l40s
build:
  requirements:
    - qwen-tts==0.1.1
    - torch==2.14.1
    - soundfile==0.14.0
    - huggingface-hub==0.36.2
  baseImage: python:3.12
volumes:
  - /weights

Deploying

Deploy it as it is, or take the files above and deploy your own copy. Nothing you change reaches back to this page.

qwen3-tts-17b-custom-voicel40ssource
From a URL
runware serverless deploy --from https://runware.ai/docs/serverless/examples/qwen3-tts-17b-custom-voice.zip
From a clone
runware serverless deploy model.py \
  --id qwen3-tts-17b-custom-voice \
  --gpu-type l40s \
  --base-image python:3.12 \
  --requirement qwen-tts==0.1.1 \
  --requirement torch==2.14.1 \
  --requirement soundfile==0.14.0 \
  --requirement huggingface-hub==0.36.2 \
  --volume /weights

Calling it

One request that works, against the app you just deployed. The fields are the ones the entry declares above, and anything it does not declare is refused rather than ignored.

import { createClient } from '@runware/sdk'

const client = await createClient({ apiKey: process.env.RUNWARE_API_KEY })

const task = await client.invoke({
  appId: 'qwen3-tts-17b-custom-voice',
  endpointPath: 'predict',
  payload: {
    request: {
      text: 'The espresso machine is warming up.',
      speaker: 'ryan'
    }
  }
})
import asyncio
import os

from runware import Runware


async def main():
    client = Runware(api_key=os.environ["RUNWARE_API_KEY"])
    try:
        task = await client.invoke(
            "qwen3-tts-17b-custom-voice",
            "predict",
            {
                "request": {
                    "text": "The espresso machine is warming up.",
                    "speaker": "ryan"
                }
            },
        )
    finally:
        await client.close()


asyncio.run(main())
curl https://api.serverless.runware.ai/v1/apps/qwen3-tts-17b-custom-voice/invoke-async/predict \
  -H "Authorization: Bearer $RUNWARE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "payload": {
      "request": {
        "text": "The espresso machine is warming up.",
        "speaker": "ryan"
      }
    }
  }'
cat <<'EOF' | runware serverless apps invoke qwen3-tts-17b-custom-voice predict -f -
{
  "request": {
    "text": "The espresso machine is warming up.",
    "speaker": "ryan"
  }
}
EOF
{
  "taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "payload": {
    "request": {
      "text": "The espresso machine is warming up.",
      "speaker": "ryan"
    }
  }
}

What it costs

This entry is pinned to l40s, which is the card it was tested on rather than the only one it runs on: --gpu-typedeploys it somewhere else. You pay for that card by the second while a worker holds it, and for nothing else. Pricing covers what meters, and the rates are published per GPU-second.