Voice Cloning
Upload reference audio to create a reusable private voice, confirm it, and use its speaker ID with the speech and TTS endpoints.
The Voice Cloning API turns a short recording into a reusable private voice. Upload reference audio, poll until cloning finishes, confirm the result, and you get a speakerId that works on /v1/speech, /v1/tts and /v1/audio/speech like any other voice. Cloned voices belong to the account behind your API key and show up in GET /v1/speakers/list.
All endpoints live under https://api.marswave.ai/openapi/v1/voice-clone and authenticate with Authorization: Bearer $LISTENHUB_API_KEY.
Cloning a voice requires the consent of the person being cloned. Every create request must carry consentConfirmed=true, which is your declaration that you hold that consent — the request is rejected without it, and the declaration is stored with the task. You remain responsible for obtaining and honoring that consent.
Every response is wrapped in { "code": 0, "message": "", "data": { ... } }. A non-zero code means an error — see Error Handling. The examples below read fields from data.
Limits and Cost
| Item | Value |
|---|---|
| Reference audio | 1–6 files, single file ≤5MB, ≤20MB total |
| Languages | zh, en, ja |
| Rate limit | 5 create requests per minute, per user |
| Plan | Paid plans only — a free-tier confirmation returns NEED_UPGRADE |
| Confirmations | Free within your plan's per-period quota, then 300 credits each |
| Stored voices | Capped per plan (maxSpeakers); deleting a voice frees a slot |
| Unconfirmed tasks | Expire after 7 days — confirm the voice to keep it |
Beyond the quota, a confirmation only charges when you pass useCredits=true. Without it the request returns NEED_CREDIT and nothing is charged.
Two Ways to Clone
Two-step (default) — upload, listen to the preview, then decide:
POST /v1/voice-clone/clonereturns ataskId.- Poll
GET /v1/voice-clone/clone/{taskId}untilstatusiscompleted; the response carriesdemoAudioUrl, a preview of the temporary voice. POST /v1/voice-clone/confirmwith a name and gender turns the task into a permanent private voice and returns itsspeakerId.
One-shot — set autoConfirm=true (plus name and gender) on the create call. The poll that first sees cloning finish also confirms the voice and returns speakerId in that same response. No second request.
With autoConfirm=true, the polling request is what charges credits. Repeated or concurrent polls never double-charge — confirmation is guarded by an atomic lock, and a second attempt is rejected as already confirmed.
Reading the Poll Response
GET /v1/voice-clone/clone/{taskId} has three terminal shapes. Check them in this order:
| Outcome | How to detect | What you get |
|---|---|---|
| Cloning failed | status: "failed" | errorCode and errorMessage |
| Cloned, not confirmed | status: "completed" and no speakerId | demoAudioUrl; confirmError when auto-confirm failed |
| Confirmed | speakerId present | speakerId, ready for the speech endpoints |
The middle row is the one that is easy to miss with autoConfirm=true: cloning succeeded but saving the voice did not — out of credits, quota full, or the voice limit reached. confirmError says which. The clone is still there; fix the cause and call POST /v1/voice-clone/confirm explicitly.
| Status | Meaning |
|---|---|
pending | Task created, waiting to be processed |
processing | Cloning in progress |
completed | Cloning finished — preview available, may not be confirmed |
failed | Cloning failed; errorMessage explains why |
Retrying
| Status | When | What to do |
|---|---|---|
429 | Another confirmation for the same account is in flight, or you exceeded 5 creates per minute | Wait for Retry-After (2s by default) and retry |
503 | The confirmation dependency is temporarily unavailable | Wait for Retry-After (5s by default) and retry |
Both are safe to retry — neither charges credits.
Create a Clone Task
POST /v1/voice-clone/clone
Sends multipart/form-data, not JSON. Repeat the audioFiles field once per file.
# Two-step
curl -X POST "https://api.marswave.ai/openapi/v1/voice-clone/clone" \
-H "Authorization: Bearer $LISTENHUB_API_KEY" \
-F "audioFiles=@reference.mp3" \
-F "language=en" \
-F "consentConfirmed=true"
# One-shot: clone and confirm in the same flow
curl -X POST "https://api.marswave.ai/openapi/v1/voice-clone/clone" \
-H "Authorization: Bearer $LISTENHUB_API_KEY" \
-F "audioFiles=@part-1.mp3" \
-F "audioFiles=@part-2.mp3" \
-F "language=ja" \
-F "consentConfirmed=true" \
-F "autoConfirm=true" \
-F "name=My API Voice" \
-F "gender=female" \
-F "useCredits=true"import { readFile } from 'node:fs/promises'
const form = new FormData()
form.append('audioFiles', new Blob([await readFile('reference.mp3')]), 'reference.mp3')
form.append('language', 'en')
form.append('consentConfirmed', 'true')
const response = await fetch('https://api.marswave.ai/openapi/v1/voice-clone/clone', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.LISTENHUB_API_KEY}` },
body: form,
})
const { data } = await response.json()
console.log('Task:', data.taskId)import os
import requests
with open('reference.mp3', 'rb') as audio:
response = requests.post(
'https://api.marswave.ai/openapi/v1/voice-clone/clone',
headers={'Authorization': f'Bearer {os.environ["LISTENHUB_API_KEY"]}'},
files=[('audioFiles', ('reference.mp3', audio, 'audio/mpeg'))],
data={'language': 'en', 'consentConfirmed': 'true'},
)
data = response.json()['data']
print('Task:', data['taskId'])Request parameters:
| Field | Type | Required | Description |
|---|---|---|---|
audioFiles | file | Yes | 1–6 reference audio files. Repeat the field for multiple files |
language | string | Yes | zh, en, or ja |
consentConfirmed | boolean | Yes | Must be true — your declaration that you hold the cloned person's consent |
mode | string | No | upload (default and only accepted value) |
autoConfirm | boolean | No | Confirm the voice inside the poll that finds cloning finished. Defaults to false |
name | string | With autoConfirm | Voice name, up to 50 characters |
gender | string | With autoConfirm | male, female, or other |
useCredits | boolean | No | Authorizes the 300-credit charge once your quota is used up. Defaults to false |
Returns:
{
"code": 0,
"message": "",
"data": {
"taskId": "6915bde9cca4d3c8ecb3eaf5",
"status": "pending"
}
}Poll a Clone Task
GET /v1/voice-clone/clone/{taskId}
curl -X GET "https://api.marswave.ai/openapi/v1/voice-clone/clone/{taskId}" \
-H "Authorization: Bearer $LISTENHUB_API_KEY"const response = await fetch(
`https://api.marswave.ai/openapi/v1/voice-clone/clone/${taskId}`,
{ headers: { Authorization: `Bearer ${process.env.LISTENHUB_API_KEY}` } },
)
const { data } = await response.json()
if (data.status === 'failed') throw new Error(data.errorMessage)
if (data.speakerId) console.log('Ready to speak with:', data.speakerId)
else if (data.confirmError) console.warn('Cloned but not saved:', data.confirmError)
else if (data.demoAudioUrl) console.log('Preview:', data.demoAudioUrl)import os
import requests
response = requests.get(
f'https://api.marswave.ai/openapi/v1/voice-clone/clone/{task_id}',
headers={'Authorization': f'Bearer {os.environ["LISTENHUB_API_KEY"]}'},
)
data = response.json()['data']
if data['status'] == 'failed':
raise RuntimeError(data['errorMessage'])
if data.get('speakerId'):
print('Ready to speak with:', data['speakerId'])
elif data.get('confirmError'):
print('Cloned but not saved:', data['confirmError'])
elif data.get('demoAudioUrl'):
print('Preview:', data['demoAudioUrl'])Cloned, waiting for confirmation:
{
"code": 0,
"message": "",
"data": {
"status": "completed",
"demoAudioUrl": "https://assets.listenhub.ai/voice-clone/demo-6915bde9.mp3"
}
}Confirmed:
{
"code": 0,
"message": "",
"data": {
"status": "completed",
"demoAudioUrl": "https://assets.listenhub.ai/voice-clone/demo-6915bde9.mp3",
"speakerId": "voice-clone-6915bde9cca4d3c8ecb3eaf5"
}
}Confirm a Clone Task
POST /v1/voice-clone/confirm
Turns a completed task into a permanent private voice. Repeating it for the same task returns ALREADY_CONFIRMED and charges nothing.
curl -X POST "https://api.marswave.ai/openapi/v1/voice-clone/confirm" \
-H "Authorization: Bearer $LISTENHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"taskId": "6915bde9cca4d3c8ecb3eaf5",
"name": "My API Voice",
"gender": "female",
"useCredits": true
}'const response = await fetch('https://api.marswave.ai/openapi/v1/voice-clone/confirm', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LISTENHUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ taskId, name: 'My API Voice', gender: 'female', useCredits: true }),
})
const { data } = await response.json()
console.log('Speaker:', data.speakerId)| Field | Type | Required | Description |
|---|---|---|---|
taskId | string | Yes | A completed clone task |
name | string | Yes | Voice name, up to 50 characters |
gender | string | Yes | male, female, or other |
useCredits | boolean | No | Authorizes the 300-credit charge beyond your quota. Defaults to false |
Returns:
{
"code": 0,
"message": "",
"data": { "speakerId": "voice-clone-6915bde9cca4d3c8ecb3eaf5" }
}Speak with a Cloned Voice
Pass the speakerId wherever a voice is expected:
curl -X POST "https://api.marswave.ai/openapi/v1/speech" \
-H "Authorization: Bearer $LISTENHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"scripts": [
{
"content": "This sentence is spoken by my own cloned voice.",
"speakerId": "voice-clone-6915bde9cca4d3c8ecb3eaf5"
}
]
}'List Private Voices
GET /v1/voice-clone/speakers
curl -X GET "https://api.marswave.ai/openapi/v1/voice-clone/speakers" \
-H "Authorization: Bearer $LISTENHUB_API_KEY"{
"code": 0,
"message": "",
"data": {
"speakers": [
{
"id": "6915c0a2cca4d3c8ecb3eb01",
"name": "My API Voice",
"speakerInnerId": "voice-clone-6915bde9cca4d3c8ecb3eaf5",
"language": "en",
"gender": "female",
"demoAudioUrl": "https://assets.listenhub.ai/voice-clone/demo-6915bde9.mp3",
"createdAt": "2026-07-30T09:10:11.000Z"
}
],
"quota": 2,
"isLimitReached": false,
"maxSpeakers": 2,
"remainingConfirmations": 1
}
}| Field | Description |
|---|---|
speakers[].speakerInnerId | The ID to pass to the speech and TTS endpoints |
quota | Confirmations included per subscription period |
remainingConfirmations | Confirmations left in the current period |
maxSpeakers | How many private voices your plan may keep at once |
isLimitReached | true once this period's confirmations are used up |
Get, Rename, or Delete a Voice
| Method | Path | Description |
|---|---|---|
GET | /v1/voice-clone/speakers/{speakerId} | One private voice |
PUT | /v1/voice-clone/speakers/{speakerId} | Update name and/or gender (send at least one) |
DELETE | /v1/voice-clone/speakers/{speakerId} | Delete the voice |
# Rename
curl -X PUT "https://api.marswave.ai/openapi/v1/voice-clone/speakers/{speakerId}" \
-H "Authorization: Bearer $LISTENHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Narrator (EN)" }'
# Delete — frees one slot against maxSpeakers
curl -X DELETE "https://api.marswave.ai/openapi/v1/voice-clone/speakers/{speakerId}" \
-H "Authorization: Bearer $LISTENHUB_API_KEY"GET and PUT return the voice:
{
"code": 0,
"message": "",
"data": {
"id": "6915c0a2cca4d3c8ecb3eb01",
"speakerInnerId": "voice-clone-6915bde9cca4d3c8ecb3eaf5",
"name": "Narrator (EN)",
"language": "en",
"gender": "female",
"demoAudioUrl": "https://assets.listenhub.ai/voice-clone/demo-6915bde9.mp3",
"createdAt": "2026-07-30T09:10:11.000Z",
"updatedAt": "2026-07-30T10:02:44.000Z"
}
}DELETE returns { "speakerId": "..." }. Deleting frees a slot; the confirmations already spent this period are not returned.
Errors
| Error | Meaning |
|---|---|
NEED_UPGRADE | Voice cloning requires a paid plan |
NEED_CREDIT | Quota used up and useCredits was not set — nothing was charged |
SPEAKER_LIMIT_REACHED | You already hold the maximum number of private voices; delete one first |
ALREADY_CONFIRMED | This task was already confirmed; no second charge |
AUDIO_DURATION_INVALID | The reference audio is too short or too long |
NO_VALID_SPEECH | No speech detected in the reference audio |
TASK_FAILED | Cloning failed; errorMessage carries the detail |
See Error Handling for the full error envelope.