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 /separate and /recombine accept an optional webhookURL field 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 requests installed, 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.
  • /recombine mixes 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