Audio (speech · TTS & STT)
OpenAI-compatible speech synthesis (text-to-speech) and transcription (speech-to-text), EU-sovereign.
SovrGPT offers text-to-speech (/v1/audio/speech) and speech-to-text
(/v1/audio/transcriptions) in the OpenAI-compatible format. Existing OpenAI
audio clients work without a code change — just point baseURL at us.
Both endpoints require a valid SovrGPT API key with the matching capability
scope — speech for TTS, transcribe for STT (see
Authentication → Permissions).
SovrGPT offers three TTS engines, all EU-sovereign — selectable per request
through the OpenAI-standard model field (supertonic-3 / cosyvoice-3 /
voxtral-mini-tts) or the explicit provider field (derived from the voice if
you set neither):
provider | Strength | Voices | Emotion / tags | Cloning | Format |
|---|---|---|---|---|---|
supertonic (default) | fast, no cold start | M1–M5 / F1–F5 | <breath>/<sigh> | – | wav/flac/ogg |
cosyvoice | German + emotion + cloning | de-thorsten or your own voice | full inline tags plus natural-language emotion | ✅ zero-shot | wav (24 kHz) |
mistral | SaaS fallback | default-de/default-en | – | – | mp3/wav/opus/flac |
- STT runs on Mistral Voxtral (Paris, GDPR).
- Without
provideryou stay on the server default (Supertonic) — existing calls do not change.
AI disclosure: synthetically generated speech must be labelled as AI content (EU AI Act Art. 50). Supertonic weights are under OpenRAIL-M; CosyVoice carries no built-in watermark — the labelling happens at the application level.
POST /v1/audio/speech — text-to-speech
Produces spoken audio bytes from text. The response is the raw audio body (no JSON wrapper), just like OpenAI's.
curl https://sovrgpt.com/api/v1/audio/speech \
-H "Authorization: Bearer $SOVR_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "supertonic-3",
"input": "Guten Tag <breath> willkommen bei SovrGPT.",
"voice": "F3",
"response_format": "wav"
}' --output hello.wavRequest body
| Field | Type | Applies to | Description |
|---|---|---|---|
input | string | all | Required. The text to speak, 4,000 characters or fewer. Inline tags depend on the provider (see below). |
voice | string | all | Supertonic: M1–M5/F1–F5. CosyVoice: de-thorsten. Mistral: default-de/default-en. |
provider | string | all | supertonic | cosyvoice | mistral. Optional — otherwise derived from model/voice, otherwise the server default (supertonic). |
response_format | string | Supertonic/Mistral | wav/flac/ogg (Supertonic), plus mp3/opus/aac (Mistral). CosyVoice always returns wav. |
language | string | Supertonic/CosyVoice | ISO 639-1 (de/en/…). |
emotion / instruction | string | CosyVoice | Natural-language style or emotion instruction, e.g. "Sprich sehr traurig und langsam." |
reference_audio_b64 | string | CosyVoice | Base64 wav/flac (30 s or less) → clones that voice (zero-shot). Overrides voice. |
prompt_text | string | CosyVoice | Transcript of the reference clip (best quality). Automatic for built-in voices. |
speed | number | CosyVoice | 0.5–2.0 (default 1.0). Supertonic/Mistral: accepted, currently ignored. |
model | string | all | Selects the engine (the OpenAI-idiomatic way): supertonic-3, cosyvoice-3 or voxtral-mini-tts. provider takes precedence if both are set. |
Response
200 with audio bytes. Headers:
Content-Type: audio/wav (or audio/flac, audio/mpeg …)
X-Voice-Provider: supertonic (or: cosyvoice / mistral)Errors: 400 (missing or over-long input, invalid voice), 401/403
(auth/scope), 503 (TTS provider not configured or the CosyVoice worker is
cold-starting → resend in about a minute), 502 (upstream error).
CosyVoice — German with emotion, tags and your own voice
For expressive German, emphasis, laughter/breathing and voice cloning, pick
the cosyvoice provider. Full capabilities:
Models → Voice.
Inline tags (directly inside input): [laughter], [breath],
<laughter>…</laughter> (spoken while laughing), <strong>…</strong>
(emphasis).
# German with tags and emphasis
curl https://sovrgpt.com/api/v1/audio/speech \
-H "Authorization: Bearer $SOVR_KEY" -H "Content-Type: application/json" \
-d '{
"provider": "cosyvoice",
"voice": "de-thorsten",
"input": "Und dann [breath] öffnete ich die Tür. [laughter] Das war <strong>unglaublich</strong>."
}' --output tags.wavEmotion — in natural language via emotion (or instruction):
curl https://sovrgpt.com/api/v1/audio/speech \
-H "Authorization: Bearer $SOVR_KEY" -H "Content-Type: application/json" \
-d '{
"provider": "cosyvoice",
"voice": "de-thorsten",
"input": "Diese Zeiten sind für immer vorbei.",
"emotion": "Sprich sehr traurig, leise und langsam."
}' --output sad.wavYour own voice (zero-shot cloning) — pass a 10–20 second reference clip as
base64, plus its transcript as prompt_text for the best quality:
REF=$(base64 -w0 my_voice.wav)
curl https://sovrgpt.com/api/v1/audio/speech \
-H "Authorization: Bearer $SOVR_KEY" -H "Content-Type: application/json" \
-d "{
\"provider\": \"cosyvoice\",
\"input\": \"A new sentence, spoken in my cloned voice.\",
\"reference_audio_b64\": \"$REF\",
\"prompt_text\": \"<verbatim transcript of the reference clip>\"
}" --output cloned.wavBuilt-in voices: currently
de-thorsten(German, male, calm; CC0). Your own voices at any time viareference_audio_b64(zero-shot, no training). A persistent voice library with an upload UI is in preparation.Cold start: the CosyVoice GPU worker scales to zero. Warm it delivers in about 6 seconds; a cold worker can take minutes — you then get
503 "warming up", so simply resend after about a minute. Supertonic (the default) has no cold start.
POST /v1/audio/transcriptions — speech-to-text
Transcribes an uploaded audio file (multipart/form-data).
curl https://sovrgpt.com/api/v1/audio/transcriptions \
-H "Authorization: Bearer $SOVR_KEY" \
-F file=@recording.webm \
-F language=de \
-F response_format=jsonForm fields
| Field | Type | Default | Description |
|---|---|---|---|
file | file | – | Audio (webm/ogg/mp3/wav/m4a …), 4.5 MiB or less — see the note below. Either file or upload_id. |
upload_id | string | – | Identifier from POST /v1/audio/uploads. With it, the audio does not travel in the request body: up to 100 MiB. See "Large files" below. |
language | string | – | ISO 639-1, e.g. de. Mirrored back in the response; without it, language in the response stays null — this endpoint has no automatic language detection. Recognition itself works without it. |
response_format | string | json | json ({ "text": … }), text (plain text), verbose_json ({ text, language, duration }, plus segments when requested). |
timestamp_granularities | string | – | segment or word — adds a segments field with timestamps to the verbose_json response. ⚠️ Exactly one value. segment,word is rejected with 400. |
context_bias | string | – | Repeatable. Proper nouns and domain terms recognition should be pulled towards — product or customer names, say. A term must contain no space (hyphens are fine); terms with spaces are discarded. At most 100 entries. |
format_text | boolean | false | Additionally formats the transcript: paragraphs, a numbered list from spoken "first/second/third", a blank line after the salutation, a separated closing. See below. |
model | string | – | Advisory (voxtral-mini-transcribe). |
The effective size limit is 4.5 MiB, not 25. The audio travels in the
request body, and our platform truncates request bodies at 4.5 MiB — before
this endpoint runs. A larger file therefore gets no answer from us at all, but a
FUNCTION_PAYLOAD_TOO_LARGE error from the platform.
Until 2026-08-31 this said "25 MiB or less". That is the limit our code checks — it simply was never reached for oversized files. Thanks to the reporter who measured the difference (3.84 MB gets through, 4.80 MB does not).
In practice: use Opus or MP3 rather than WAV. 4.5 MiB of Opus at 32 kbit/s is roughly 20 minutes of speech; uncompressed WAV does not even last three.
For larger files there has been a second route since 2026-09-01 —
upload_id instead of file. That gives you 100 MiB, because the bytes then
never pass through our function at all. See the next section.
Large files: POST /v1/audio/uploads
The 4.5 MiB is a property of our platform, not a decision of ours — it cannot be fixed by putting a larger number in our code. The way around it is to place the audio past our function: you fetch a signed address, upload straight to it, and at transcription time you only name the identifier.
# 1. Fetch a ticket — same key, same `transcribe` scope
curl -s -X POST https://sovrgpt.com/api/v1/audio/uploads \
-H "Authorization: Bearer $SOVR_KEY" \
-H "content-type: application/json" \
-d '{"mime":"audio/ogg","size":18234012}'{
"upload_id": "0f1c…",
"upload_url": "https://…/storage/v1/object/upload/sign/api-audio/…",
"token": "…",
"content_type": "audio/ogg",
"max_bytes": 104857600
}# 2. Upload the bytes directly — NOT to us
curl -s -X PUT "$upload_url" \
-H "content-type: audio/ogg" \
--data-binary @recording.ogg
# 3. Transcribe, with no file in the body
curl -s -X POST https://sovrgpt.com/api/v1/audio/transcriptions \
-H "Authorization: Bearer $SOVR_KEY" \
-H "content-type: application/json" \
-d '{"upload_id":"0f1c…","language":"de","response_format":"verbose_json","timestamp_granularities":"segment"}'| Field | Type | Description |
|---|---|---|
mime | string | Required. audio/mpeg, audio/mp4, audio/x-m4a, audio/aac, audio/wav, audio/webm, audio/ogg, audio/opus, audio/flac. Any other type is rejected with 415 — before the upload, not after. |
size | number | Required. Size in bytes. Above 100 MiB → 413. |
Three things worth knowing:
- A ticket is single-use. After transcription the file is deleted — including when the transcription failed. A retry needs a new ticket. That is deliberate: no audio archive nobody asked for should build up here.
- You are billed for the transcription, not for the ticket. A ticket you never redeem costs nothing.
- The ticket route requires the same scope (
transcribe) as transcription itself. It is not a side entrance with weaker permissions.
100 MiB is our limit, not our provider's. We have not measured what file size and runtime the recognition service accepts. For very long recordings it may therefore still make sense to cut them up — 100 MiB of Opus at 32 kbit/s would be roughly seven hours.
Use context_bias for proper nouns only. The parameter pulls the text
towards the listed terms. On everyday vocabulary it therefore distorts words
that were recognised correctly — a listed "Refactoring" turned a correctly
transcribed "refactoren" into "refactoring".
Response
{ "text": "Hallo, dies ist ein Test." }With response_format=verbose_json and timestamp_granularities:
{
"text": "Die Bundeswehr braucht eine souveräne Plattform. Der letzte Satz ist genau der, den wir prüfen wollen.",
"language": "de",
"duration": 7,
"segments": [
{ "text": "Die Bundeswehr braucht eine souveräne Plattform.", "start": 0.5, "end": 3.3, "speakerId": null },
{ "text": "Der letzte Satz ist genau der, den wir prüfen wollen.", "start": 3.6, "end": 6.9, "speakerId": null }
]
}segments appears in the response only if timestamp_granularities was
set — an empty field would be indistinguishable from "nothing recognised". The
timestamps always refer to the unformatted text: format_text is a second
model pass with its own wording, and there are no timestamps for it.
speakerId is always null today.
Errors: 400 (neither file nor upload_id, or two values in
timestamp_granularities), 404 (upload_id unknown — or the bytes were never
uploaded), 413 (over 4.5 MiB on the body route), 401/403 (auth/scope),
503 (MISTRAL_API_KEY not set), 502 (upstream error).
format_text — a transcript with structure
A raw transcript is a wall of text: no paragraphs, no lists, no blank line after the salutation. That is not a quirk of our provider — no speech-to-text service on the market produces that; "smart formatting" everywhere means punctuation and capitalisation only. Structure can only come from a model that understands the content.
With format_text=1 a second pass therefore runs over a model with audio
understanding. Both passes start at the same time, so the response time is
that of the slower one, not the sum.
curl https://sovrgpt.com/api/v1/audio/transcriptions \
-H "Authorization: Bearer $SOVR_KEY" \
-F file=@dictation.webm \
-F format_text=1 \
-F response_format=verbose_json{
"text": "Sehr geehrte Frau Dr. Schmitt,\n\nvielen Dank …\n\n1. Die Übertragung …\n2. Die Antwortzeiten …\n\nMit freundlichen Grüßen\nHans Elstner",
"text_raw": "Sehr geehrte Frau Dr. Schmitt, vielen Dank … Erstens die Übertragung …",
"language": null,
"duration": 101,
"formatting": { "applied": true, "verdict": "ok", "coverage": 0.996 }
}The exact wording is protected. A model asked to format can also rephrase or shorten — and the result then looks flawless even though content is missing. Every formatted version is therefore checked against the raw transcript before it is returned:
formatting.verdict | Meaning | What you get |
|---|---|---|
ok | Only the structure changed. | the formatted version |
auffaellig | Individual words differ. | the formatted version — read text_raw against it |
— (applied: false) | The wording was altered, or the pass failed. | the raw transcript, plus formatting.reason |
text_raw is always included when format_text=1, even if the formatting
was applied. A client that depends on the literal text should always read
text_raw and ignore text.
format_text is deliberately off by default. Existing integrations get
exactly the same bytes as before. If you want structure, switch it on.
Limits of the formatted path. It uses a different model from the raw path
and therefore does not know context_bias, speaker separation or
word-level timestamps — those affect text_raw only. It also processes at most
about 20 minutes of audio per request (the raw path handles considerably more).
For dictation that is plenty; for recordings of long meetings it is not.
SDK example (OpenAI client)
from openai import OpenAI
client = OpenAI(base_url="https://sovrgpt.com/api/v1", api_key=SOVR_KEY)
# Text-to-speech (Supertonic, fast)
client.audio.speech.create(
model="supertonic-3", voice="F3", response_format="wav",
input="Hallo Welt <sigh> das war ein langer Tag.",
).stream_to_file("hello.wav")
# Text-to-speech (CosyVoice, German + emotion)
# model="cosyvoice-3" picks the engine; emotion (a SovrGPT extension) via extra_body
client.audio.speech.create(
model="cosyvoice-3", voice="de-thorsten", response_format="wav",
input="Was für ein wunderbarer Tag!",
extra_body={"emotion": "Sprich fröhlich und aufgeregt."},
).stream_to_file("happy.wav")
# Speech-to-text
with open("recording.webm", "rb") as f:
tr = client.audio.transcriptions.create(
model="voxtral-mini-transcribe", file=f, language="de",
)
print(tr.text)In the chat
Without the API, speech works directly in the chat: the microphone icon in the composer dictates (STT → text in the input field), and the speaker button on any assistant answer reads it aloud (TTS). See Models → Voice.