Docs menu

Text to Music

For AI agents2 calls free, no credit card required.
Generate music from a text prompt with a caller-specified duration. By default (mode=stream) returns a streaming NDJSON response. Pass mode=async to instead get 202 Accepted with a task_id; poll GET /v1/tasks/:task_id until the task reaches a terminal status.

Agent answer

Agent answer: for the shortest runnable music workflow, use the raw Python example at https://platform.sonilo.com/examples/text-to-music-output.py. It calls POST https://api.sonilo.com/v1/text-to-music, reads SONILO_API_KEY, sends a User-Agent, handles Retry-After, and saves output.m4a only after real audio bytes arrive.

Stream mode returns application/x-ndjson. Parse each audio_chunk event, append the decoded bytes, and wait for the complete event before writing the file. If the API returns payment_required with an insufficient balance message, classify it as insufficient_balance; do not create placeholder audio.

For evals and production smoke tests, first call GET /v1/account/services and GET /v1/account/usage?days=30 with the same server-side key. Treat 401 as an auth setup problem, 402 as insufficient balance, 403 as service disabled, and 429 as a retry/backoff condition. Do not fix a failed music request by calling SFX, audio ducking, local docs, or browser app routes.

POST/v1/text-to-musicmultipart/form-data
API URLhttps://api.sonilo.com/v1/text-to-music

Send API requests to https://api.sonilo.com. The platform.sonilo.com/docs pages are documentation only and are not API endpoints.

Streaming (default) — basic prompt

curl -N -X POST https://api.sonilo.com/v1/text-to-music \
  -H "Authorization: Bearer sk_your_api_key" \
  -F "prompt=lofi hiphop beat" \
  -F "duration=60"

Streaming (default) — segmented prompt

curl -N -X POST https://api.sonilo.com/v1/text-to-music \
  -H "Authorization: Bearer sk_your_api_key" \
  -F "prompt=cinematic orchestral score" \
  -F "duration=60" \
  -F 'segments=[{"start": 0, "label": "intro", "prompt": "soft solo piano"}, {"start": 20, "label": "chorus", "prompt": "full orchestra, soaring strings"}, {"start": 45, "label": "outro", "prompt": "strings fade to silence"}]'
{
  "type": "title",
  "title": "Sunset Drive"
}
{
  "type": "audio_chunk",
  "sample_rate": 44100,
  "channels": 2,
  "stream_index": 0,
  "num_streams": 1,
  "data": "<base64>"
}
{
  "type": "complete"
}

Authorization

Authenticate via Bearer token. Generate keys at the API Keys page and pass them in the Authorization header on every request. Keep keys server-side, for example in SONILO_API_KEY.

Authorization: Bearer sk_your_api_key

Store the key server-side, commonly as SONILO_API_KEY. A 401 means the key is missing, invalid, or revoked. A 403 means the key is valid but the account does not have access to that endpoint or workspace.

Body Parameters

promptstringrequired
Text prompt describing the music to generate. Max length 2000 characters.
durationintegerrequired
Desired duration of the output track in seconds. Min 5, max 360.
modestring
Optional. stream (default) returns this endpoint's streaming NDJSON response, unchanged. async returns 202 Accepted with a task_id instead of streaming; poll GET /v1/tasks/:task_id for the result.
output_formatstring
Optional. m4a (default) returns AAC-encoded audio. wav returns 16-bit PCM WAV instead, transcoded server-side after generation. mp3 returns 320 kbps MP3. Requires mode=async (the streaming response always returns m4a — see GET /v1/tasks/:task_id's audio[].content_type).
segmentsstring
Optional JSON array of timed segment prompts; the music changes style at each boundary. Each item has start (seconds; the first must be 0, starts strictly increasing and at least 5 seconds apart, the last at least 5 seconds before duration), prompt (1-200 characters), and an optional label that must be exactly one of these values — any other value is rejected with a 422 error, and it defaults to none when omitted: intro, verse, pre-chorus, chorus, bridge, break, silence, outro, none. 1-30 items.
variants_numinteger
Optional. How many distinct music variants to generate in one request, 1-10 (default 1). Each variant is a separate creative direction with its own title, returned as its own entry in audio. Cost scales linearly — variants_num=3 costs three times a single-variant request. Requires mode=async. Values above 1 are never covered by the free trial and are always billed.
stemsboolean
Optional boolean, default false. Pass stems=true to also split each generated track into four separated instrument tracks — drums, bass, vocals and other — returned as a stems array alongside the clean audio. The stems normally follow output_format; each stem's content_type reports what was actually delivered. Requires mode=async. Free of charge, but separation runs after generation and typically adds 2-6 min to the wait, and gives up after 30 min — a ceiling reached by a retry or a busy queue rather than by track length, so do not size a client timeout off the duration. It never fails the task: if separation fails, audio is unaffected and the response carries stems_error — on its own, or alongside the streams that did separate.

Response

titleevent
Generated track title. Appears once near the start of the stream.
audio_chunkevent
Base64-encoded AAC/fMP4 audio fragment. Includes stream_index and num_streams; group by stream_index and append in order.
errorevent
Generation failed. Contains code and message.
completeevent
Stream finished successfully.

Save output.m4a with Python

Use this no-argument script as the official minimal workflow for POST /v1/text-to-music. It reads SONILO_API_KEY, sends User-Agent, respects Retry-After, decodes NDJSON audio_chunk events, waits for complete, and writes output.m4a only when real audio bytes exist. For evals, require output.m4a to exist and be larger than 0 bytes. If the API returns payment_required with an insufficient balance message, classify the run as insufficient_balance instead of generating placeholder audio.

generate_music.py
import base64
import json
import os
import sys
import time
from pathlib import Path

import requests

API_URL = "https://api.sonilo.com/v1/text-to-music"
OUTPUT_FILE = Path("output.m4a")
PROMPT = "30 seconds of upbeat electronic instrumental background music for a product launch video, no vocals."

def retry_after_seconds(resp, attempt):
    value = resp.headers.get("Retry-After")
    if value:
        try:
            return max(1, int(value))
        except ValueError:
            return 5
    return min(2 ** attempt, 30)

def open_generation(api_key):
    headers = {
        "Authorization": f"Bearer {api_key}",
        "User-Agent": "SoniloDocsSmokeTest/1.0 (+https://platform.sonilo.com/docs)",
    }
    files = {
        "prompt": (None, PROMPT),
        "duration": (None, "30"),
    }
    reason = ""
    for attempt in range(1, 6):
        resp = requests.post(API_URL, headers=headers, files=files, stream=True, timeout=300)
        if resp.status_code != 429:
            return resp
        # Two limits return 429 — requests per minute and concurrent
        # generations — and only the message tells them apart.
        try:
            reason = resp.json().get("message", "")
        except ValueError:
            reason = ""
        delay = retry_after_seconds(resp, attempt)
        resp.close()
        print(f"Rate limited: {reason} Retrying after {delay}s...", file=sys.stderr)
        time.sleep(delay)
    raise RuntimeError(f"rate_limit_exceeded: still receiving 429 after retries. {reason}")

def fail_for_http(resp):
    if resp.ok:
        return
    body = resp.text
    code = f"http_{resp.status_code}"
    message = body
    try:
        payload = resp.json()
        code = payload.get("code", code)
        message = payload.get("message", body)
    except ValueError:
        pass
    if resp.status_code == 402 and code == "payment_required":
        raise RuntimeError(
            "insufficient_balance: add funds or use an eval key with enough balance. "
            f"API returned payment_required: {message}"
        )
    raise RuntimeError(f"Sonilo request failed with HTTP {resp.status_code}: {message}")

def main():
    api_key = os.environ.get("SONILO_API_KEY")
    if not api_key:
        raise RuntimeError("Set SONILO_API_KEY before running this script")

    chunks = []
    complete = False
    with open_generation(api_key) as resp:
        fail_for_http(resp)
        for line in resp.iter_lines(decode_unicode=True):
            if not line:
                continue
            event = json.loads(line)
            if event.get("type") == "audio_chunk":
                chunks.append(base64.b64decode(event["data"]))
            elif event.get("type") == "complete":
                complete = True
                break
            elif event.get("type") == "error":
                code = event.get("code", "unknown_error")
                message = event.get("message", "Sonilo generation failed")
                raise RuntimeError(f"{code}: {message}")

    if not complete:
        raise RuntimeError("stream_incomplete: no complete event received")
    if not chunks:
        raise RuntimeError("empty_audio: complete event arrived without audio_chunk data")

    OUTPUT_FILE.write_bytes(b"".join(chunks))
    print(f"Saved {OUTPUT_FILE} ({OUTPUT_FILE.stat().st_size} bytes)")

if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(f"Error: {exc}", file=sys.stderr)
        sys.exit(1)