Create text-to-speech audio stream over the WebSocket
January 7, 2025 (August 24, 2026)
Table of contents
This endpoint still works and is not going away, but it is no longer the recommended way to stream speech. POST speech/create with
streamreturns the same audio over Server-Sent Events in a single call, needs no WebSocket client, and hands you theaudioIdso the generation stays retrievable afterwards. Use it instead unless you are maintaining an existing WebSocket integration.
Use POST speech/create-stream to obtain token and payload.
To see the provided below code in action use Try It.
wss://api.useapi.net/v1/minimax/speech/wss?token=
token
Query Parameters
tokenis required. Use POST speech/create-stream to obtaintoken.
Responses
-
{ "error": "<Error message>" }
Examples
var player = null;
var ws = null;
const urlCreateStream = 'https://api.useapi.net/v1/minimax/speech/create-stream';
const wssCreateStream = 'wss://api.useapi.net/v1/minimax/speech/wss';
const urlAudio = 'https://api.useapi.net/v1/minimax/speech';
class DynamicAudioPlayer {
constructor() {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.audioQueue = [];
this.isPlaying = false;
this.currentSource = null;
this.onAudioFinishedCallback = null;
}
async loadAudioData(base64Chunk, finishCallback) {
try {
const byteArray = this.hexStringToByteArray('fffbe8c4' + base64Chunk);
this.audioQueue.push(byteArray);
this.onAudioFinishedCallback = finishCallback;
if (!this.isPlaying) {
this.isPlaying = true;
await this.playNextChunk();
}
} catch (e) {
console.error("Error decoding audio data:", e);
}
}
async playNextChunk() {
if (this.audioQueue.length > 0) {
const byteArray = this.audioQueue.shift();
const audioBuffer = await this.audioContext.decodeAudioData(byteArray.buffer);
this.scheduleAudioBuffer(audioBuffer);
} else {
this.isPlaying = false;
if (this.onAudioFinishedCallback) {
this.onAudioFinishedCallback();
this.onAudioFinishedCallback = null;
}
}
}
scheduleAudioBuffer(audioBuffer) {
const source = this.audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.audioContext.destination);
source.start();
this.currentSource = source;
source.onended = () => {
this.playNextChunk();
};
}
stop() {
this.audioQueue = [];
if (this.currentSource) {
this.currentSource.stop();
this.currentSource = null;
}
this.isPlaying = false;
}
hexStringToByteArray(hexString) {
const bytes = new Uint8Array(hexString.length / 2);
for (let i = 0; i < hexString.length; i += 2) {
bytes[i / 2] = parseInt(hexString.substring(i, i + 2), 16);
}
return bytes;
}
}
async function streamAudio(data, callback, finishCallback) {
const parseData = async (wssData) => {
try {
const json = JSON.parse(wssData);
// Added March 17, 2025
// Error occurred.
if(json.data?.status === undefined && json.statusInfo?.code !== 0) {
callback({ status: json.statusInfo.code, json, text: '🛑 ' + json.statusInfo?.message });
ws.close();
ws = null;
finishCallback();
return;
}
let audio;
if (json.data?.audio) {
audio = json.data.audio;
json.data.audio = `…omitted ${audio.length} bytes of raw audio…`;
}
if (json.data?.status == 1 && audio)
player.loadAudioData(audio, finishCallback);
if (callback)
callback({ status: 200, json });
if (json.data?.status == 2) {
const { headers, body } = data;
const { account } = JSON.parse(body);
callback({ text: `⌛ GET ${urlAudio} ⁝ looking for generated mp3…` });
const response = await fetch(`${urlAudio}${account ? '/?account=' + account : ''}`, { headers });
const text = await response.text();
if (!response.ok) {
callback({ status: response.status, text });
return;
}
const { audio_list } = JSON.parse(text);
const item = audio_list?.at(0);
const { audio_url } = item ?? {};
callback({ status: response.status, json: item, text: '👉🏻 ' + audio_url });
}
} catch (error) {
console.error(`Failed to parse JSON: ${error}`, wssData);
}
};
if (player)
player.stop();
else
player = new DynamicAudioPlayer();
if (ws) {
ws.close();
ws = null;
}
callback({ text: `⏳ Requesting WebSocket token and payload from ${urlCreateStream}…` });
const response = await fetch(urlCreateStream, data);
const text = await response.text();
callback({ status: response.status, text });
if (!response.ok)
return;
const { token, payload } = JSON.parse(text);
callback({ text: `⌛ Establishing WebSocket connection to ${wssCreateStream}…` });
ws = new WebSocket(`${wssCreateStream}/?token=${token}`);
ws.addEventListener('open', () => {
callback({ text: `🚀 Sending payload over WebSocket connection` });
// Updated March 17, 2025
ws.send(JSON.stringify(payload));
});
ws.addEventListener('message', async event => {
await parseData(event.data);
});
ws.addEventListener('error', event => {
const text = `🛑 WebSocket error: ${JSON.stringify(event)}`;
console.error(text);
callback({ text });
});
ws.addEventListener('close', event => {
console.log('WebSocket close', event);
});
}
// Here's how you call above functions
const data = {
method: 'POST',
headers: {
'Authorization': `Bearer ${api_token_value}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: 'your text goes here',
voice_id: 'desired voice'
})
};
await streamAudio(
data,
// optional progress callback
(status, json, text) => {
console.log(`callback`, { status, json, text });
},
// optional playback completed callback
() => {
console.log(`playback completed`);
}
);
Model
The below model represent WebSocket message payload object.
The value of data.status can be either 1 (progress) or 2 (completed). Once generation is completed, you can locate the generated mp3 file in audio_list[] returned by the GET speech endpoint. It will be the first returned item, the list is newest-first — or alternatively, you can match on text.
{ // TypeScript, all fields are optional
data: {
audio: string // Hex. status 1 carries MP3 pieces, status 2 the same speech as a WAV
status: number
ced: string
subtitles?: { // status 2 frame only, which a long generation may never send
text: string
time_begin: number // Milliseconds into the recording
time_end: number
text_begin: number
text_end: number
timestamped_words: []
}[]
}
extra_info?: { // status 2 frame only, and only usage_characters is populated
audio_length: number
audio_sample_rate: number
audio_size: number
bitrate: number
word_count: number
invisible_character_ratio: number
usage_characters: number
}
input_sensitive: boolean
trace_id?: string // Sent on the status 1 frame, absent from the final frame
base_resp?: { // Same — present on status 1, absent on the final frame
status_code: number
status_msg: string
}
}
status 1 frames carry MP3 audio and status 2 carries a WAV. They are not two halves of one file — the WAV is the same speech again in another container.
How many status 1 frames arrive depends on length. Short text sends one complete MP3 and then the WAV. A 10,000-character generation sends around 40 MP3 pieces over a minute or two, and may end there without a status 2 frame at all. So join the MP3 pieces in arrival order rather than assuming a single frame, and do not wait indefinitely for the WAV.
subtitles gives per-phrase timings for captions. Use time_begin and time_end — text_begin and text_end are reported by MiniMax but do not reliably index into either the full text or the phrase. Because subtitles and extra_info ride on the status 2 frame, a long generation may deliver neither.
Only usage_characters is filled in on extra_info. audio_length, audio_sample_rate, audio_size and bitrate all arrive as 0.
Try It
See above code in action at Try It POST speech/create-stream.