Skip to main content

Text-to-speech with Qwen3-TTS

Qwen3-TTS-12Hz-1.7B-CustomVoice is accessible through the same /v1/audio/speech endpoint as the OpenAI speech API, so code written for OpenAI works by changing base_url. See the Qwen3-TTS model page for the full list of voices, languages, formats, and measured latencies.

This is the opposite direction from speech-to-text with Whisper: text goes in, audio comes out.

Setup

user@local $ pip install openai
user@local $ export OPENAI_API_KEY="sk-…"

Basic synthesis

import os
from openai import OpenAI

client = OpenAI(
base_url="https://llm.aihosting.mittwald.de/v1",
api_key=os.environ["OPENAI_API_KEY"],
)

response = client.audio.speech.create(
model="Qwen3-TTS-12Hz-1.7B-CustomVoice",
voice="ryan",
input="Hallo und herzlich willkommen bei mittwald.",
response_format="mp3",
)

response.write_to_file("willkommen.mp3")
print("written to willkommen.mp3")

Streaming

Streaming gets you the audio while the model is still generating it. The first chunk arrives in under 150 ms, where the buffered request takes around a second for a 100-character text and over three seconds for 270 characters. Use it whenever somebody is waiting for the audio, and split long texts anyway, see Long texts.

Set stream to true to enable it, and pick stream_format="audio" for raw audio chunks in the format you asked for, which is what you want when you write to a file or pipe into a player. Streaming works with response_format="wav" and "pcm" only; every other format returns HTTP 400.

import os
from openai import OpenAI

client = OpenAI(
base_url="https://llm.aihosting.mittwald.de/v1",
api_key=os.environ["OPENAI_API_KEY"],
)

with client.audio.speech.with_streaming_response.create(
model="Qwen3-TTS-12Hz-1.7B-CustomVoice",
voice="ryan",
input=(
"This text is long enough for waiting on the complete file to become "
"noticeable. With streaming, playback can start immediately."
),
response_format="wav",
stream_format="audio",
extra_body={"stream": True},
) as response:
response.stream_to_file("ausgabe.wav")

stream_format="sse" gives you the OpenAI event framing instead: speech.audio.delta events carrying the audio base64-encoded, closed by one speech.audio.done. On a 100-character text that was 7 delta events and 1 done event. Use it when your client already speaks SSE, and "audio" when it does not.

Choosing a voice

Nine voices ship with the model. Generate a sample of each and listen, since the right one depends on your content:

import os
from openai import OpenAI

client = OpenAI(
base_url="https://llm.aihosting.mittwald.de/v1",
api_key=os.environ["OPENAI_API_KEY"],
)

VOICES = ["aiden", "dylan", "eric", "ono_anna", "ryan",
"serena", "sohee", "uncle_fu", "vivian"]

SAMPLE = "Guten Tag. Dies ist eine Hörprobe für die Sprachausgabe."

for voice in VOICES:
response = client.audio.speech.create(
model="Qwen3-TTS-12Hz-1.7B-CustomVoice",
voice=voice,
input=SAMPLE,
response_format="mp3",
)
response.write_to_file(f"sample_{voice}.mp3")
print(f"sample_{voice}.mp3")

Voice cloning from your own recording is not available on this endpoint. The nine built-in voices are the complete set.

Picking an output format

All five formats hold the same audio. They differ in size, which matters if you send the result over the network. The model samples, so two runs on the same text differ in length; the sizes below are per second of audio for that reason:

response_formatSize per second of audioUse it for
wav48.0 kBFurther processing, the default
pcm48.0 kBFeeding a raw audio pipeline
flacabout 23 kBLossless archiving
mp37.0 kBBroad compatibility
opus4.6 kBDelivery to a browser or phone

aac is not supported. It returns HTTP 400 with Input should be 'wav', 'pcm', 'flac', 'mp3' or 'opus'.

The response header identifies the format: audio/wav, audio/pcm, audio/flac, audio/mpeg and audio/ogg respectively. Per second of audio, wav and pcm both cost 48.0 kB, flac about 23 kB, mp3 7.0 kB and opus 4.6 kB.

Long texts

Generation time scales with the length of the text, so a very long input means a long single request. Splitting on sentence boundaries lets you start playing the first part while the rest is still being generated, and it keeps any one failure small.

import os
import re
from openai import OpenAI

client = OpenAI(
base_url="https://llm.aihosting.mittwald.de/v1",
api_key=os.environ["OPENAI_API_KEY"],
)


def split_sentences(text, max_chars=350):
"""Group sentences into chunks of at most max_chars."""
sentences = re.split(r"(?<=[.!?])\s+", text.strip())
chunks, current = [], ""
for sentence in sentences:
if current and len(current) + len(sentence) + 1 > max_chars:
chunks.append(current)
current = sentence
else:
current = f"{current} {sentence}".strip()
if current:
chunks.append(current)
return chunks


LONG_TEXT = (
"Der erste Satz eröffnet den Text. "
"Der zweite Satz führt den Gedanken weiter und wird etwas länger. "
"Ein dritter Satz schließt den Absatz ab."
)

for index, chunk in enumerate(split_sentences(LONG_TEXT)):
response = client.audio.speech.create(
model="Qwen3-TTS-12Hz-1.7B-CustomVoice",
voice="ryan",
input=chunk,
response_format="mp3",
)
response.write_to_file(f"part_{index:03d}.mp3")
print(f"part_{index:03d}.mp3 ({len(chunk)} characters)")

Splitting mid-sentence makes the seam audible, because the model chooses intonation per request. Cut on sentence boundaries, as above.

Adjusting the speaking rate

speed scales duration close to inversely. Measured on the same German sentence:

speedDuration
0.59.28 s
1.04.85 s
2.02.23 s
response = client.audio.speech.create(
model="Qwen3-TTS-12Hz-1.7B-CustomVoice",
voice="ryan",
input="Langsam und deutlich gesprochen.",
response_format="mp3",
speed=0.8,
)

Mixed-language text

The model detects the language itself, and it handles German and English reliably, including a German sentence with English terms in it:

response = client.audio.speech.create(
model="Qwen3-TTS-12Hz-1.7B-CustomVoice",
voice="serena",
input="Wir deployen das Feature heute, das Rollback-Skript liegt bereit.",
response_format="mp3",
)

The language parameter takes the language name, German for example, and rejects ISO codes such as de with HTTP 400. It buys you little: German text came back equally accurate with and without it in our runs. If a passage comes out in the wrong language, split it into its own request with more surrounding context in the target language, which is the same trick that fixes short German lines.

Reading a model answer out loud

A common pipeline: generate an answer with a chat model, then speak it.

import os
from openai import OpenAI

client = OpenAI(
base_url="https://llm.aihosting.mittwald.de/v1",
api_key=os.environ["OPENAI_API_KEY"],
)

completion = client.chat.completions.create(
model="Qwen3.5-0.8B",
messages=[
{"role": "system", "content": "Antworte in höchstens drei Sätzen."},
{"role": "user", "content": "Was ist ein Reverse Proxy?"},
],
)

answer = completion.choices[0].message.content
print(answer)

with client.audio.speech.with_streaming_response.create(
model="Qwen3-TTS-12Hz-1.7B-CustomVoice",
voice="serena",
input=answer,
response_format="wav",
stream_format="audio",
extra_body={"stream": True},
) as speech:
speech.stream_to_file("antwort.wav")

Cap the answer length in the system prompt. Generation time follows the length of the text, and an unbounded model answer turns into an unbounded wait.

Drop-in replacement for OpenAI

Only base_url, api_key, and the model name change:

client = OpenAI(
base_url="https://llm.aihosting.mittwald.de/v1", # instead of api.openai.com
api_key=os.environ["OPENAI_API_KEY"],
)

response = client.audio.speech.create(
model="Qwen3-TTS-12Hz-1.7B-CustomVoice", # instead of tts-1 or gpt-4o-mini-tts
voice="ryan", # different voice names
input="Hello and welcome.",
response_format="mp3",
)