Tutorial - Tonn API: Stem Separation & Recombine¶
The Tonn API's stem separation endpoint splits a finished stereo mix into four stems — vocals, drums, bass, and other — using a GPU-accelerated source-separation model. The recombine endpoint then rebuilds a two-part mix from those stems (or from your own stem URLs), rebalances vocal against backing, and masters the result to a target loudness. Together they let you turn a flat, already-mixed track back into controllable parts and a fresh master, without hosting a GPU or a mastering chain yourself.
What You Can Build¶
- Catalog remastering — revive older masters that have no surviving multitrack session by separating and rebalancing the existing stereo file.
- Vocal-up / vocal-down remixes — pull the lead vocal forward (or push it back) relative to the instrumental without re-recording anything.
- Karaoke and practice tools — isolate or mute the vocal stem for backing tracks and play-along apps.
- Remix and sample prep — hand off clean drum, bass, vocal, and "other" stems to a producer or DJ tool.
- Restoration workflows — recover usable stems for a track whose original session files are lost.
How It Works¶
POST /separate → 4 stems (vocals, drums, bass, other)
GET /separatestatus/{id} → poll until "complete"
POST /retrievestems → download the 4 stem URLs (free)
POST /recombine → rebalance vocal + backing, master to target LUFS
GET /recombinestatus/{id} → poll until "complete"
POST /retrieverecombinepreview → 30s MP3 preview (free)
POST /retrieverecombine → full mastered WAV (charged once)
Separation and recombine are independent services connected by one field: pass the stemflowTaskId you get back from /separate as stemJobId to /recombine, and the vocal stem plus the summed drums+bass+other stems are resolved automatically. If you already have your own vocal and backing stem files, you can call /recombine directly with vocalStemURL and backingStemURL and skip separation entirely.
Pricing at a Glance¶
| Step | Endpoint | Cost |
|---|---|---|
| Submit separation | POST /separate |
Reserved at submission; 75 credits captured only if separation succeeds |
| Poll / retrieve stems | GET /separatestatus/{id}, POST /retrievestems |
Free |
| Submit recombine | POST /recombine |
Free |
| Poll / preview | GET /recombinestatus/{id}, POST /retrieverecombinepreview |
Free |
| Final master | POST /retrieverecombine |
250 credits, charged once on first successful retrieval |
A full separate-then-recombine pipeline costs 325 credits total ($3.25 on the Small plan, $2.60 on the Large plan) — see Pricing for package rates. If separation fails, the 75-credit reservation is released automatically and you are not charged. Repeated calls to /retrieverecombine for the same task return the same master URL without charging again.
Note on
webhookURL: both/separateand/recombineaccept an optionalwebhookURLfield and store it against the task, but callback delivery is not yet implemented for these two endpoints (unlike the post-production endpoints). Poll the status endpoints described below instead.
Prerequisites¶
- A valid API key (see Getting Started)
- An audio file uploaded via
/upload, or any publicly reachable WAV/FLAC/MP3 URL - Python with
requestsinstalled, or cURL
Base URL:
https://tonn.roexaudio.com
All requests require the X-API-Key header for authentication.
Step 1: Upload Your Audio¶
import requests
API_KEY = "your-api-key"
BASE_URL = "https://tonn.roexaudio.com"
def upload_track(filename, content_type="audio/wav"):
response = requests.post(
f"{BASE_URL}/upload",
headers={"X-API-Key": API_KEY},
json={"filename": filename, "contentType": content_type},
timeout=30,
)
response.raise_for_status()
result = response.json()
with open(filename, "rb") as f:
upload_response = requests.put(
result["signed_url"],
data=f,
headers={"Content-Type": content_type},
timeout=120,
)
upload_response.raise_for_status()
return result["readable_url"]
audio_url = upload_track("finished_mix.wav")
Step 2: Submit a Separation Job¶
response = requests.post(
f"{BASE_URL}/separate",
headers={"X-API-Key": API_KEY},
json={"stemflowData": {"audioFileLocation": audio_url}},
timeout=30,
)
response.raise_for_status()
stemflow_task_id = response.json()["stemflowTaskId"]
print(f"Separation task created: {stemflow_task_id}")
Response:
{
"stemflowTaskId": "sf_5c1e2b7a-...",
"error": false,
"message": "Successfully created stem separation task."
}
A 402 response means your account does not have 75 available credits (credits - reserved_credits) and auto top-up either is not enabled or failed; a 400 means audioFileLocation is missing or malformed.
Step 3: Poll Separation Status¶
import time
def wait_for_separation(stemflow_task_id, timeout_seconds=1200, poll_interval=15):
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
response = requests.get(
f"{BASE_URL}/separatestatus/{stemflow_task_id}",
headers={"X-API-Key": API_KEY},
timeout=30,
)
response.raise_for_status()
status = response.json()["status"]
if status == "complete":
return
if status == "failed":
raise RuntimeError(f"Separation failed: {response.json().get('info')}")
time.sleep(poll_interval)
raise TimeoutError("Separation did not complete in time")
wait_for_separation(stemflow_task_id)
Separation is GPU-bound; a typical track completes in well under a minute once a GPU instance is warm, but a cold start can occasionally take several minutes. Always poll with a bounded timeout — never loop forever on processing.
Step 4: Retrieve the Stems (Free)¶
response = requests.post(
f"{BASE_URL}/retrievestems",
headers={"X-API-Key": API_KEY},
json={"stemflowData": {"stemflowTaskId": stemflow_task_id}},
timeout=30,
)
response.raise_for_status()
stems = response.json()["stems"]
print(stems["vocals"], stems["drums"], stems["bass"], stems["other"])
Response:
{
"stemflowTaskId": "sf_5c1e2b7a-...",
"stems": {
"vocals": "https://storage.googleapis.com/.../vocals.wav?X-Goog-Signature=...",
"bass": "https://storage.googleapis.com/.../bass.wav?X-Goog-Signature=...",
"drums": "https://storage.googleapis.com/.../drums.wav?X-Goog-Signature=...",
"other": "https://storage.googleapis.com/.../other.wav?X-Goog-Signature=..."
},
"error": false,
"message": "Successfully retrieved separated stems."
}
Each stem is a 44.1kHz stereo WAV. Stem URLs are signed and expire after 24 hours — download or re-request before then.
Step 5: Submit a Recombine Job¶
Reference the separation job directly with stemJobId — the vocal stem and a summed backing (drums + bass + other) are resolved for you:
response = requests.post(
f"{BASE_URL}/recombine",
headers={"X-API-Key": API_KEY},
json={
"recombineData": {
"stemJobId": stemflow_task_id,
"musicalStyle": "POP",
"loudnessTarget": -14
}
},
timeout=30,
)
response.raise_for_status()
recombine_task_id = response.json()["recombineTaskId"]
Alternatively, recombine your own stem files without ever calling /separate:
response = requests.post(
f"{BASE_URL}/recombine",
headers={"X-API-Key": API_KEY},
json={
"recombineData": {
"vocalStemURL": "https://your-storage.com/vocal.wav",
"backingStemURL": "https://your-storage.com/backing.wav",
"musicalStyle": "POP",
"loudnessTarget": -14
}
},
timeout=30,
)
Provide either stemJobId or vocalStemURL + backingStemURL — not both. musicalStyle and loudnessTarget are both optional; loudnessTarget defaults to -14 LUFS if omitted.
Musical styles: ROCK_INDIE, POP, ACOUSTIC, HIPHOP_GRIME, ELECTRONIC, REGGAE_DUB, ORCHESTRAL, METAL, OTHER, JAZZ, LO_FI, LATIN, TECHNO, HOUSE, TRAP, COUNTRY_ACOUSTIC, CINEMATIC, AFROBEAT, K_POP, REGGAETON
A 404 on stemJobId means the ID does not exist, does not belong to your account, or belongs to a different key on your account — this endpoint never reveals whether a task exists for someone else. A 400 means the referenced separation job has not finished yet.
Step 6: Poll Recombine Status¶
def wait_for_recombine(recombine_task_id, timeout_seconds=1200, poll_interval=15):
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
response = requests.get(
f"{BASE_URL}/recombinestatus/{recombine_task_id}",
headers={"X-API-Key": API_KEY},
timeout=30,
)
response.raise_for_status()
status = response.json()["status"]
if status == "complete":
return
if status == "failed":
raise RuntimeError(f"Recombine failed: {response.json().get('info')}")
time.sleep(poll_interval)
raise TimeoutError("Recombine did not complete in time")
wait_for_recombine(recombine_task_id)
Step 7: Preview the Result (Free)¶
response = requests.post(
f"{BASE_URL}/retrieverecombinepreview",
headers={"X-API-Key": API_KEY},
json={"recombineData": {"recombineTaskId": recombine_task_id}},
timeout=30,
)
response.raise_for_status()
preview = response.json()["preview"]
print(preview["preview_url"], preview["measured_lufs"])
The preview is a 30-second MP3 rendered at the requested loudness target — free for accounts with purchased credits, and safe to call as many times as you like.
Step 8: Retrieve the Final Master¶
This is the only endpoint in the whole workflow that charges credits (250, on the first successful call for a given task):
response = requests.post(
f"{BASE_URL}/retrieverecombine",
headers={"X-API-Key": API_KEY},
json={"recombineData": {"recombineTaskId": recombine_task_id}},
timeout=30,
)
if response.status_code == 402:
raise RuntimeError("Insufficient credits for the final master")
response.raise_for_status()
result = response.json()["result"]
print(result["master_url"], result["measured_lufs"], result["peak_level"])
Response:
{
"recombineTaskId": "rc_9f3d1a44-...",
"result": {
"master_url": "https://storage.googleapis.com/.../recombined_master.wav?X-Goog-Signature=...",
"measured_lufs": -14.0,
"peak_level": -1.2,
"track_length_seconds": 187.4
},
"error": false,
"message": "Successfully retrieved recombined master."
}
Calling /retrieverecombine again for the same recombineTaskId returns the same master_url without a second charge — safe to retry after a network error.
Full Pipeline (cURL)¶
export BASE="https://tonn.roexaudio.com"
export KEY="your-api-key"
# 1. Separate
SF_ID=$(curl -s -X POST "$BASE/separate" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"stemflowData": {"audioFileLocation": "https://your-storage.com/track.wav"}}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['stemflowTaskId'])")
# 2. Poll until complete
until [ "$(curl -s "$BASE/separatestatus/$SF_ID" -H "X-API-Key: $KEY" | python3 -c 'import sys,json; print(json.load(sys.stdin)["status"])')" = "complete" ]; do sleep 15; done
# 3. Get stems (free)
curl -s -X POST "$BASE/retrievestems" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d "{\"stemflowData\": {\"stemflowTaskId\": \"$SF_ID\"}}"
# 4. Recombine from the separation job
RC_ID=$(curl -s -X POST "$BASE/recombine" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d "{\"recombineData\": {\"stemJobId\": \"$SF_ID\", \"musicalStyle\": \"POP\", \"loudnessTarget\": -14}}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['recombineTaskId'])")
# 5. Poll until complete
until [ "$(curl -s "$BASE/recombinestatus/$RC_ID" -H "X-API-Key: $KEY" | python3 -c 'import sys,json; print(json.load(sys.stdin)["status"])')" = "complete" ]; do sleep 15; done
# 6. Preview (free)
curl -s -X POST "$BASE/retrieverecombinepreview" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d "{\"recombineData\": {\"recombineTaskId\": \"$RC_ID\"}}"
# 7. Final master (charges 250 credits once)
curl -s -X POST "$BASE/retrieverecombine" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d "{\"recombineData\": {\"recombineTaskId\": \"$RC_ID\"}}"
Response Codes¶
| Code | Meaning |
|---|---|
| 200 | Success — task created, or results ready (a failed job also returns 200 with "status": "failed") |
| 202 | Still processing — try again shortly |
| 400 | Bad request — missing field, invalid combination of recombine inputs, or referenced separation job not yet complete |
| 401 | Invalid API key |
| 402 | Insufficient credits — separation reservation or final recombine charge could not be placed |
| 404 | Task or stemJobId not found, or not owned by your account |
| 500 | Pricing configuration missing, or the job could not be enqueued (any reserved credits are released automatically) |
Limitations¶
- Separation output is stereo only; sources with more than two channels are rejected.
- Near-silent input audio is rejected before it reaches the model.
- All output audio (stems, preview, and master) is 44.1kHz.
/recombinemixes exactly two parts — a vocal and a backing stem. For rebalancing three or more individually-mixed tracks, use the Multitrack Mixing endpoint instead.- All returned URLs are signed and expire after 24 hours.
Next Steps¶
- See the API Reference for full endpoint schemas
- See the Pricing page for package rates
- See the FAQ for supported formats and general account questions
- See Mix Enhance if you want enhancement and optional stem processing applied to a stereo mix in a single call instead of separate/recombine