Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]"
- run: pytest
22 changes: 22 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Publish to PyPI

on:
workflow_dispatch:
push:
tags: ["v*"]

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]" build twine
- run: pytest
- run: python -m build
- run: twine upload dist/*
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
86 changes: 84 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,87 @@
# sonilo

Official Python client for the Sonilo API.
Official Python client for the [Sonilo](https://sonilo.com) API.
Python ≥ 3.9. Sync and async clients included.

Work in progress — see docs coming with the first release.
## Installation

```bash
pip install sonilo
```

## Quickstart

```python
from sonilo import Sonilo

client = Sonilo() # reads SONILO_API_KEY

track = client.text_to_music.generate(
prompt="cinematic orchestral score",
duration=60,
)
track.save("output.mp3")
print(track.title)
```

## Video to music

```python
track = client.video_to_music.generate(video="my_video.mp4", prompt="upbeat")
# or bytes / an open binary file, or a hosted URL:
track = client.video_to_music.generate(video_url="https://example.com/clip.mp4")
```

## Streaming

```python
for event in client.text_to_music.stream(prompt="lofi", duration=30):
if event["type"] == "audio_chunk":
handle(event["data"]) # bytes, as they arrive
```

## Async

```python
from sonilo import AsyncSonilo

async with AsyncSonilo() as client:
track = await client.text_to_music.generate(prompt="lofi", duration=30)
async for event in client.text_to_music.stream(prompt="lofi", duration=30):
...
```

## Segments

Shape the composition with start-only contiguous segments (each ends where
the next begins):

```python
client.text_to_music.generate(
prompt="epic trailer",
duration=60,
segments=[
{"start": 0, "prompt": "soft intro", "label": "intro"},
{"start": 20, "prompt": "building tension", "label": "verse"},
{"start": 40, "prompt": "full orchestra", "label": "chorus"},
],
)
```

## Account

```python
client.account.services()
client.account.usage(days=7)
```

## Errors

All errors extend `SoniloError`: `AuthenticationError` (401),
`PaymentRequiredError` (402), `RateLimitError` (429, `.retry_after`),
`BadRequestError` (400/413/422, `.detail`), `APIError` (anything else),
and `GenerationError` for failures mid-stream.

## License

MIT
14 changes: 14 additions & 0 deletions examples/generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Usage: SONILO_API_KEY=sk_... python examples/generate.py "lofi beat" 30"""
import sys

from sonilo import Sonilo

prompt = sys.argv[1] if len(sys.argv) > 1 else "cinematic orchestral score"
duration = int(sys.argv[2]) if len(sys.argv) > 2 else 60

with Sonilo() as client:
track = client.text_to_music.generate(prompt=prompt, duration=duration)

out = track.save("output.mp3")
title = f' — "{track.title}"' if track.title else ""
print(f"Saved {out} ({len(track.audio)} bytes){title}")
28 changes: 27 additions & 1 deletion src/sonilo/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
from sonilo._async_client import AsyncSonilo
from sonilo._client import Sonilo
from sonilo._version import __version__
from sonilo.errors import (
APIError,
AuthenticationError,
BadRequestError,
GenerationError,
PaymentRequiredError,
RateLimitError,
SoniloError,
)
from sonilo.types import Segment, StreamEvent, Track

__all__ = ["__version__"]
__all__ = [
"APIError",
"AsyncSonilo",
"AuthenticationError",
"BadRequestError",
"GenerationError",
"PaymentRequiredError",
"RateLimitError",
"Segment",
"Sonilo",
"SoniloError",
"StreamEvent",
"Track",
"__version__",
]
69 changes: 69 additions & 0 deletions src/sonilo/_async_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from __future__ import annotations

from typing import Any, AsyncIterator, Dict, Optional

import httpx

from sonilo._client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, _default_headers, _resolve_api_key
from sonilo._streaming import aiter_events
from sonilo.errors import error_from_response
from sonilo.resources.account import AsyncAccount
from sonilo.resources.text_to_music import AsyncTextToMusic
from sonilo.resources.video_to_music import AsyncVideoToMusic
from sonilo.types import StreamEvent


class AsyncSonilo:
"""Asynchronous Sonilo API client."""

def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
timeout: float = DEFAULT_TIMEOUT,
) -> None:
key = _resolve_api_key(api_key)
self._http = httpx.AsyncClient(
base_url=(base_url or DEFAULT_BASE_URL).rstrip("/"),
headers=_default_headers(key, "sdk-python"),
timeout=timeout,
)
self.text_to_music = AsyncTextToMusic(self)
self.video_to_music = AsyncVideoToMusic(self)
self.account = AsyncAccount(self)

async def close(self) -> None:
await self._http.aclose()

async def __aenter__(self) -> "AsyncSonilo":
return self

async def __aexit__(self, *exc_info: Any) -> None:
await self.close()

# -- internal transport -------------------------------------------------

async def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
response = await self._http.get(path, params=params)
if response.status_code >= 400:
raise error_from_response(response)
return response.json()

async def _stream_events(
self,
path: str,
*,
data: Dict[str, str],
files: Optional[Dict[str, tuple]] = None,
close_after: Any = None,
) -> AsyncIterator[StreamEvent]:
try:
async with self._http.stream("POST", path, data=data, files=files) as response:
if response.status_code >= 400:
await response.aread()
raise error_from_response(response)
async for event in aiter_events(response.aiter_text()):
yield event
finally:
if close_after is not None:
close_after.close()
90 changes: 90 additions & 0 deletions src/sonilo/_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from __future__ import annotations

import os
from typing import Any, Dict, Iterator, Optional

import httpx

from sonilo._streaming import iter_events
from sonilo._version import __version__
from sonilo.errors import SoniloError, error_from_response
from sonilo.resources.account import Account
from sonilo.resources.text_to_music import TextToMusic
from sonilo.resources.video_to_music import VideoToMusic
from sonilo.types import StreamEvent

DEFAULT_BASE_URL = "https://api.sonilo.com"
DEFAULT_TIMEOUT = 600.0


def _resolve_api_key(api_key: Optional[str]) -> str:
key = api_key or os.environ.get("SONILO_API_KEY")
if not key:
raise SoniloError(
"Missing API key: pass api_key= or set the SONILO_API_KEY environment variable"
)
return key


def _default_headers(api_key: str, client_name: str) -> Dict[str, str]:
return {
"Authorization": f"Bearer {api_key}",
"X-Sonilo-Client": client_name,
"X-Sonilo-Client-Version": __version__,
}


class Sonilo:
"""Synchronous Sonilo API client."""

def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
timeout: float = DEFAULT_TIMEOUT,
) -> None:
key = _resolve_api_key(api_key)
self._http = httpx.Client(
base_url=(base_url or DEFAULT_BASE_URL).rstrip("/"),
headers=_default_headers(key, "sdk-python"),
timeout=timeout,
)
self.text_to_music = TextToMusic(self)
self.video_to_music = VideoToMusic(self)
self.account = Account(self)

def close(self) -> None:
self._http.close()

def __enter__(self) -> "Sonilo":
return self

def __exit__(self, *exc_info: Any) -> None:
self.close()

# -- internal transport -------------------------------------------------

def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
response = self._http.get(path, params=params)
if response.status_code >= 400:
raise error_from_response(response)
return response.json()

def _stream_events(
self,
path: str,
*,
data: Dict[str, str],
files: Optional[Dict[str, tuple]] = None,
close_after: Any = None,
) -> Iterator[StreamEvent]:
try:
with self._http.stream("POST", path, data=data, files=files) as response:
if response.status_code >= 400:
response.read()
raise error_from_response(response)
for event in iter_events(response.iter_text()):
yield event
finally:
if close_after is not None:
close_after.close()
Loading
Loading