Text to Speech
Convert text to natural-sounding speech across five endpoints, from low-latency single-voice streaming to long-form async narration.
ListenHub exposes several text-to-speech endpoints, each tuned for a different shape of work. They share the same base URL, authentication, and speaker IDs, but differ in latency, response type, and how many voices they support.
All requests go to https://api.marswave.ai/openapi/v1 and authenticate with an API key:
Authorization: Bearer $LISTENHUB_API_KEYCreate keys at listenhub.ai/settings/api-keys. Every JSON response is wrapped as { "code": 0, "message": "", "data": { ... } }; a non-zero code signals an error. The streaming endpoints (/v1/tts, /v1/audio/speech) return raw binary audio instead of this envelope.
Choosing an endpoint
| Endpoint | Voices | Sync / Async | Response | Best for |
|---|---|---|---|---|
POST /v1/tts | Single | Sync | Binary audio stream | Real-time playback, in-app voice, low latency |
POST /v1/audio/speech | Single | Sync | Binary audio stream | Drop-in replacement for the OpenAI TTS endpoint |
POST /v1/speech | Multiple | Sync | JSON with audioUrl | Dialogue, audiobooks, prepared multi-voice scripts |
POST /v1/flow-speech/episodes | Single | Async | Poll by episodeId | Article and newsletter narration, URL-to-audio |
POST /v1/flow-speech/episodes/tts | Multiple | Async | Poll by episodeId | Long multi-voice scripts converted verbatim |
Rule of thumb: reach for /v1/tts when you need audio bytes back immediately for one voice, /v1/speech when you have a short multi-voice script and want a hosted URL in one call, and the /v1/flow-speech/episodes endpoints when the job is long enough to run in the background.
Credit cost scales with audio length and is returned on the relevant responses (credits) once a job completes. To estimate cost before generating, see the credits estimation endpoints in the API reference.
Streaming TTS
POST /v1/tts
Low-latency single-voice synthesis. The response body is raw binary audio streamed as it is generated, so the first bytes arrive before the full clip is ready. Use this for real-time playback and interactive voice features.
This endpoint accepts the OpenAI text-to-speech request shape (input / voice / response_format), which makes it easy to migrate existing clients.
curl -X POST "https://api.marswave.ai/openapi/v1/tts" \
-H "Authorization: Bearer $LISTENHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input": "Hello, welcome to ListenHub text-to-speech.",
"voice": "EN-Man-General-01",
"response_format": "mp3"
}' \
--output output.mp3const response = await fetch('https://api.marswave.ai/openapi/v1/tts', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LISTENHUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
input: 'Hello, welcome to ListenHub text-to-speech.',
voice: 'EN-Man-General-01',
response_format: 'mp3',
}),
});
const buffer = Buffer.from(await response.arrayBuffer());
// Write `buffer` to a file, or pipe `response.body` to a playerimport os
import requests
response = requests.post(
'https://api.marswave.ai/openapi/v1/tts',
headers={'Authorization': f'Bearer {os.environ["LISTENHUB_API_KEY"]}'},
json={
'input': 'Hello, welcome to ListenHub text-to-speech.',
'voice': 'EN-Man-General-01',
'response_format': 'mp3',
},
stream=True,
)
with open('output.mp3', 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)Request parameters
| Field | Type | Required | Description |
|---|---|---|---|
input | string | Yes | Text to synthesize. Max 20,000 characters. |
voice | string | Yes | Speaker ID (the speakerId value from Speakers). |
response_format | string | No | Requested audio format. One of mp3, opus, aac, flac, wav, pcm. Defaults to mp3. |
The response body is binary audio, not the JSON envelope — read it as a stream or blob. The delivered container is MP3 (Content-Type: audio/mpeg) for every format except opus, which is delivered as OGG/Opus (Content-Type: audio/ogg). If the request fails before audio starts, the response falls back to a JSON error object, so check the Content-Type before treating the body as audio.
OpenAI-compatible TTS
POST /v1/audio/speech
An exact alias of /v1/tts at the path the OpenAI SDK and OpenAI-compatible integrations call by default. The request body, response_format options, and streaming binary response are identical. Point an existing OpenAI TTS client at this URL with your ListenHub API key and a ListenHub voice ID to switch over without code changes.
curl -X POST "https://api.marswave.ai/openapi/v1/audio/speech" \
-H "Authorization: Bearer $LISTENHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input": "This endpoint mirrors the OpenAI speech API.",
"voice": "EN-Woman-General-01",
"response_format": "mp3"
}' \
--output output.mp3const response = await fetch('https://api.marswave.ai/openapi/v1/audio/speech', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LISTENHUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
input: 'This endpoint mirrors the OpenAI speech API.',
voice: 'EN-Woman-General-01',
response_format: 'mp3',
}),
});
const buffer = Buffer.from(await response.arrayBuffer());import os
import requests
response = requests.post(
'https://api.marswave.ai/openapi/v1/audio/speech',
headers={'Authorization': f'Bearer {os.environ["LISTENHUB_API_KEY"]}'},
json={
'input': 'This endpoint mirrors the OpenAI speech API.',
'voice': 'EN-Woman-General-01',
'response_format': 'mp3',
},
stream=True,
)
with open('output.mp3', 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)The request parameters and response behavior are the same as Streaming TTS.
Multi-Speaker Script to Audio
POST /v1/speech
Generate one audio file from a prepared multi-voice script. Each line carries its own speakerId, so you can alternate voices for dialogue. The call is synchronous and returns a hosted audio URL plus subtitles in the response — no polling.
curl -X POST "https://api.marswave.ai/openapi/v1/speech" \
-H "Authorization: Bearer $LISTENHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"scripts": [
{ "content": "Welcome everyone to this episode.", "speakerId": "EN-Man-General-01" },
{ "content": "Today we are discussing an interesting topic.", "speakerId": "EN-Woman-General-01" },
{ "content": "Great, let us begin.", "speakerId": "EN-Man-General-01" }
]
}'const response = await fetch('https://api.marswave.ai/openapi/v1/speech', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LISTENHUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
scripts: [
{ content: 'Welcome everyone to this episode.', speakerId: 'EN-Man-General-01' },
{ content: 'Today we are discussing an interesting topic.', speakerId: 'EN-Woman-General-01' },
{ content: 'Great, let us begin.', speakerId: 'EN-Man-General-01' },
],
}),
});
const { data } = await response.json();
console.log(data.audioUrl);import os
import requests
response = requests.post(
'https://api.marswave.ai/openapi/v1/speech',
headers={'Authorization': f'Bearer {os.environ["LISTENHUB_API_KEY"]}'},
json={
'scripts': [
{'content': 'Welcome everyone to this episode.', 'speakerId': 'EN-Man-General-01'},
{'content': 'Today we are discussing an interesting topic.', 'speakerId': 'EN-Woman-General-01'},
{'content': 'Great, let us begin.', 'speakerId': 'EN-Man-General-01'},
]
},
)
data = response.json()['data']
print(data['audioUrl'])Request parameters
| Field | Type | Required | Description |
|---|---|---|---|
scripts | array | Yes | One or more script lines, synthesized in order. |
scripts[].content | string | Yes | Line text. Must be non-empty; combined length across all lines is capped at 20,000 characters. |
scripts[].speakerId | string | Yes | Speaker ID for this line. Different lines may use different speakers. |
Response
{
"code": 0,
"message": "",
"data": {
"audioUrl": "https://assets.listenhub.ai/listenhub-public-prod/podcast/example.mp3",
"audioDuration": 12500,
"subtitlesUrl": "https://assets.listenhub.ai/listenhub-public-prod/podcast/example.srt",
"taskId": "1eed39d387a046c0a1213e6b8f139d77",
"credits": 12
}
}| Field | Type | Description |
|---|---|---|
audioUrl | string | URL of the generated MP3 file. |
audioDuration | integer | Audio duration in milliseconds. |
subtitlesUrl | string | SRT subtitle file URL. Valid for 24 hours. |
taskId | string | Task ID. Quote it when reporting an issue so support can locate the request. |
credits | integer | Credits consumed by this request. |
Long-Form Text to Speech
POST /v1/flow-speech/episodes
Convert a block of text or the contents of a URL into a single-voice narration. This endpoint runs asynchronously: the request returns an episodeId right away, and you poll for the audio once it finishes. It is built for longer inputs where waiting on a synchronous call would be impractical.
Two modes control how the source text is handled:
smart(default) — cleans up the text first: fixes punctuation, grammar, and formatting so rough or pasted input still reads naturally.direct— synthesizes the text verbatim, with no rewriting. Use this when the script is already final.
Constraints: exactly one sources item, exactly one speaker, and a text source of at least 10 characters (up to 20,000).
Smart mode (AI polish)
curl -X POST "https://api.marswave.ai/openapi/v1/flow-speech/episodes" \
-H "Authorization: Bearer $LISTENHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sources": [
{
"type": "text",
"content": "welcome to listenhub this text is intentionally rough and punctuation will be improved automatically"
}
],
"speakers": [
{ "speakerId": "EN-Woman-General-01" }
],
"language": "en",
"mode": "smart"
}'const response = await fetch('https://api.marswave.ai/openapi/v1/flow-speech/episodes', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LISTENHUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
sources: [
{
type: 'text',
content: 'welcome to listenhub this text is intentionally rough and punctuation will be improved automatically',
},
],
speakers: [{ speakerId: 'EN-Woman-General-01' }],
language: 'en',
mode: 'smart',
}),
});
const { data } = await response.json();
console.log('Episode ID:', data.episodeId);import os
import requests
response = requests.post(
'https://api.marswave.ai/openapi/v1/flow-speech/episodes',
headers={'Authorization': f'Bearer {os.environ["LISTENHUB_API_KEY"]}'},
json={
'sources': [
{
'type': 'text',
'content': 'welcome to listenhub this text is intentionally rough and punctuation will be improved automatically',
}
],
'speakers': [{'speakerId': 'EN-Woman-General-01'}],
'language': 'en',
'mode': 'smart',
},
)
print('Episode ID:', response.json()['data']['episodeId'])Direct mode
curl -X POST "https://api.marswave.ai/openapi/v1/flow-speech/episodes" \
-H "Authorization: Bearer $LISTENHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sources": [
{
"type": "text",
"content": "Welcome to ListenHub. This script is already finalized and should be converted as-is."
}
],
"speakers": [
{ "speakerId": "EN-Man-General-01" }
],
"language": "en",
"mode": "direct"
}'const response = await fetch('https://api.marswave.ai/openapi/v1/flow-speech/episodes', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LISTENHUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
sources: [
{
type: 'text',
content: 'Welcome to ListenHub. This script is already finalized and should be converted as-is.',
},
],
speakers: [{ speakerId: 'EN-Man-General-01' }],
language: 'en',
mode: 'direct',
}),
});
const { data } = await response.json();
console.log('Episode ID:', data.episodeId);import os
import requests
response = requests.post(
'https://api.marswave.ai/openapi/v1/flow-speech/episodes',
headers={'Authorization': f'Bearer {os.environ["LISTENHUB_API_KEY"]}'},
json={
'sources': [
{
'type': 'text',
'content': 'Welcome to ListenHub. This script is already finalized and should be converted as-is.',
}
],
'speakers': [{'speakerId': 'EN-Man-General-01'}],
'language': 'en',
'mode': 'direct',
},
)
print('Episode ID:', response.json()['data']['episodeId'])Read content from a URL
Set type to url and pass the page address in uri. ListenHub fetches and extracts the readable content before synthesis. (content is still accepted in place of uri for backward compatibility.)
curl -X POST "https://api.marswave.ai/openapi/v1/flow-speech/episodes" \
-H "Authorization: Bearer $LISTENHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sources": [
{
"type": "url",
"uri": "https://example.com/article.html"
}
],
"speakers": [
{ "speakerId": "EN-Woman-General-01" }
],
"language": "en",
"mode": "smart"
}'const response = await fetch('https://api.marswave.ai/openapi/v1/flow-speech/episodes', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LISTENHUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
sources: [{ type: 'url', uri: 'https://example.com/article.html' }],
speakers: [{ speakerId: 'EN-Woman-General-01' }],
language: 'en',
mode: 'smart',
}),
});
const { data } = await response.json();
console.log('Episode ID:', data.episodeId);import os
import requests
response = requests.post(
'https://api.marswave.ai/openapi/v1/flow-speech/episodes',
headers={'Authorization': f'Bearer {os.environ["LISTENHUB_API_KEY"]}'},
json={
'sources': [{'type': 'url', 'uri': 'https://example.com/article.html'}],
'speakers': [{'speakerId': 'EN-Woman-General-01'}],
'language': 'en',
'mode': 'smart',
},
)
print('Episode ID:', response.json()['data']['episodeId'])Request parameters
| Field | Type | Required | Description |
|---|---|---|---|
sources | array | Yes | Content source. Exactly one item. |
sources[].type | string | Yes | text or url. |
sources[].content | string | For text | Text to narrate. Minimum 10 characters, maximum 20,000. For very short clips, use /v1/speech instead. |
sources[].uri | string | For url (recommended) | Page URL to read from. Either uri or content must be present for a url source. |
speakers | array | Yes | Speaker list. Exactly one item. |
speakers[].speakerId | string | Yes | Speaker ID. |
language | string | No | Source language: en, zh, or ja. Inferred from the content when omitted. |
mode | string | No | smart (AI polish) or direct (verbatim). Defaults to smart. |
The response contains only the task ID:
{
"code": 0,
"message": "",
"data": {
"episodeId": "665f1c2a9b3e4d0012a4c8e1"
}
}Poll for results
GET /v1/flow-speech/episodes/{episodeId}
Poll with the returned episodeId until processStatus is success.
curl -X GET "https://api.marswave.ai/openapi/v1/flow-speech/episodes/{episodeId}" \
-H "Authorization: Bearer $LISTENHUB_API_KEY"const response = await fetch(
`https://api.marswave.ai/openapi/v1/flow-speech/episodes/${episodeId}`,
{ headers: { Authorization: `Bearer ${process.env.LISTENHUB_API_KEY}` } },
);
const { data } = await response.json();
console.log('Status:', data.processStatus);
console.log('Audio URL:', data.audioUrl);import os
import requests
response = requests.get(
f'https://api.marswave.ai/openapi/v1/flow-speech/episodes/{episode_id}',
headers={'Authorization': f'Bearer {os.environ["LISTENHUB_API_KEY"]}'},
)
data = response.json()['data']
print('Status:', data['processStatus'])
print('Audio URL:', data.get('audioUrl'))When the job finishes (processStatus is success):
{
"code": 0,
"message": "",
"data": {
"episodeId": "665f1c2a9b3e4d0012a4c8e1",
"createdAt": 1717430000000,
"processStatus": "success",
"completedTime": 1717430090000,
"title": "Article Title",
"outline": "...",
"cover": "https://assets.listenhub.ai/.../cover.png",
"audioUrl": "https://assets.listenhub.ai/listenhub-public-prod/podcast/665f1c2a9b3e4d0012a4c8e1.mp3",
"audioStreamUrl": "https://assets.listenhub.ai/listenhub-public-prod/podcast/665f1c2a9b3e4d0012a4c8e1.m3u8",
"subtitlesUrl": "https://assets.listenhub.ai/.../665f1c2a9b3e4d0012a4c8e1.srt",
"scripts": "Full narration script text..."
}
}| Field | Type | Description |
|---|---|---|
episodeId | string | The episode identifier. |
createdAt | integer | Creation timestamp in milliseconds. |
processStatus | string | Current state: pending, success, or fail. Poll until success; fail indicates the job did not complete. |
failCode | integer | Present on failure; identifies the reason. |
completedTime | integer | Completion timestamp in milliseconds. |
title | string | Generated episode title. |
outline | string | Generated outline of the narration. |
cover | string | Cover image URL. |
audioUrl | string | MP3 audio file URL. |
audioStreamUrl | string | HLS streaming URL (.m3u8). |
subtitlesUrl | string | SRT subtitle file URL. |
scripts | string | Full narration script text. |
Long-form jobs typically finish in one to two minutes. A practical polling strategy: wait 30 seconds after creation, then poll every 10 seconds. On failure, processStatus is fail and failCode carries the reason.
Multi-Speaker Direct (async)
POST /v1/flow-speech/episodes/tts
Convert a long, prepared multi-voice script into an episode. This is the asynchronous, multi-speaker counterpart to /v1/speech: every line keeps its own speakerId, the text is synthesized verbatim (direct mode), and the call returns an episodeId to poll. Reach for it when a multi-voice script is too long to handle in a single synchronous /v1/speech request.
curl -X POST "https://api.marswave.ai/openapi/v1/flow-speech/episodes/tts" \
-H "Authorization: Bearer $LISTENHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Roundtable Discussion",
"scripts": [
{ "content": "Thanks for joining the roundtable today.", "speakerId": "EN-Man-General-01" },
{ "content": "Happy to be here. Let us dig into the agenda.", "speakerId": "EN-Woman-General-01" }
]
}'const response = await fetch('https://api.marswave.ai/openapi/v1/flow-speech/episodes/tts', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LISTENHUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: 'Roundtable Discussion',
scripts: [
{ content: 'Thanks for joining the roundtable today.', speakerId: 'EN-Man-General-01' },
{ content: 'Happy to be here. Let us dig into the agenda.', speakerId: 'EN-Woman-General-01' },
],
}),
});
const { data } = await response.json();
console.log('Episode ID:', data.episodeId);import os
import requests
response = requests.post(
'https://api.marswave.ai/openapi/v1/flow-speech/episodes/tts',
headers={'Authorization': f'Bearer {os.environ["LISTENHUB_API_KEY"]}'},
json={
'title': 'Roundtable Discussion',
'scripts': [
{'content': 'Thanks for joining the roundtable today.', 'speakerId': 'EN-Man-General-01'},
{'content': 'Happy to be here. Let us dig into the agenda.', 'speakerId': 'EN-Woman-General-01'},
],
},
)
print('Episode ID:', response.json()['data']['episodeId'])Request parameters
| Field | Type | Required | Description |
|---|---|---|---|
scripts | array | Yes | One or more script lines, synthesized in order. |
scripts[].content | string | Yes | Line text. Must be non-empty; combined length across all lines is capped at 20,000 characters. |
scripts[].speakerId | string | Yes | Speaker ID for this line. Different lines may use different speakers. |
title | string | No | Custom episode title. Auto-generated when omitted. |
The response returns an episodeId. Poll for results with the same status endpoint as long-form jobs, GET /v1/flow-speech/episodes/{episodeId}.
{
"code": 0,
"message": "",
"data": {
"episodeId": "665f1c2a9b3e4d0012a4c8e1"
}
}