Create text-to-speech audio stream over the WebSocket

January 7, 2025 (August 24, 2026)

Table of contents

  1. Query Parameters
  2. Responses
  3. Examples
  4. Model
  5. Try It

This endpoint still works and is not going away, but it is no longer the recommended way to stream speech. POST speech/create with stream returns the same audio over Server-Sent Events in a single call, needs no WebSocket client, and hands you the audioId so 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
Responses
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_endtext_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.