Text to Music
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.
/v1/text-to-musicmultipart/form-datahttps://api.sonilo.com/v1/text-to-musicSend 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_keyStore 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
promptstringrequireddurationintegerrequiredmodestringstream (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_formatstringm4a (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).segmentsstringstart (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_numinteger1). 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.stemsbooleanfalse. 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
titleeventaudio_chunkeventstream_index and num_streams; group by stream_index and append in order.erroreventcode and message.completeeventStreaming (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"
}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.
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)