sonilo 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sonilo-0.1.0/.github/workflows/ci.yml +20 -0
- sonilo-0.1.0/.github/workflows/publish.yml +41 -0
- sonilo-0.1.0/.gitignore +7 -0
- sonilo-0.1.0/LICENSE +21 -0
- sonilo-0.1.0/PKG-INFO +104 -0
- sonilo-0.1.0/README.md +87 -0
- sonilo-0.1.0/examples/generate.py +14 -0
- sonilo-0.1.0/pyproject.toml +27 -0
- sonilo-0.1.0/src/sonilo/__init__.py +29 -0
- sonilo-0.1.0/src/sonilo/_async_client.py +69 -0
- sonilo-0.1.0/src/sonilo/_client.py +90 -0
- sonilo-0.1.0/src/sonilo/_requests.py +66 -0
- sonilo-0.1.0/src/sonilo/_streaming.py +106 -0
- sonilo-0.1.0/src/sonilo/_version.py +1 -0
- sonilo-0.1.0/src/sonilo/errors.py +85 -0
- sonilo-0.1.0/src/sonilo/py.typed +0 -0
- sonilo-0.1.0/src/sonilo/resources/__init__.py +0 -0
- sonilo-0.1.0/src/sonilo/resources/account.py +31 -0
- sonilo-0.1.0/src/sonilo/resources/text_to_music.py +63 -0
- sonilo-0.1.0/src/sonilo/resources/video_to_music.py +71 -0
- sonilo-0.1.0/src/sonilo/types.py +24 -0
- sonilo-0.1.0/tests/__init__.py +0 -0
- sonilo-0.1.0/tests/test_async_client.py +87 -0
- sonilo-0.1.0/tests/test_errors.py +65 -0
- sonilo-0.1.0/tests/test_requests.py +85 -0
- sonilo-0.1.0/tests/test_streaming.py +166 -0
- sonilo-0.1.0/tests/test_sync_client.py +154 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.9", "3.12"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: pip install -e ".[dev]"
|
|
20
|
+
- run: pytest
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
push:
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
# PyPI Trusted Publishing (OIDC). No PYPI_TOKEN secret needed.
|
|
14
|
+
id-token: write
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.12"
|
|
20
|
+
- run: pip install -e ".[dev]" build
|
|
21
|
+
- run: pytest
|
|
22
|
+
- name: Verify tag matches pyproject version
|
|
23
|
+
if: github.ref_type == 'tag'
|
|
24
|
+
run: |
|
|
25
|
+
TAG_VERSION="${GITHUB_REF_NAME#v}"
|
|
26
|
+
PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
27
|
+
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
|
|
28
|
+
echo "Tag version ($TAG_VERSION) does not match pyproject version ($PKG_VERSION)"
|
|
29
|
+
exit 1
|
|
30
|
+
fi
|
|
31
|
+
- run: python -m build
|
|
32
|
+
- name: Publish to PyPI
|
|
33
|
+
# Authenticates via OIDC Trusted Publishing — configured as a pending
|
|
34
|
+
# publisher for project `sonilo` (repo sonilo-python, workflow publish.yml).
|
|
35
|
+
# Tag refs only: a workflow_dispatch on a branch runs tests/build but
|
|
36
|
+
# never publishes. To retry a failed publish, dispatch on the tag:
|
|
37
|
+
# gh workflow run publish.yml --ref vX.Y.Z
|
|
38
|
+
if: github.ref_type == 'tag'
|
|
39
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
40
|
+
with:
|
|
41
|
+
skip-existing: true
|
sonilo-0.1.0/.gitignore
ADDED
sonilo-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sonilo AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sonilo-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sonilo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the Sonilo API
|
|
5
|
+
Project-URL: Repository, https://github.com/sonilo-ai/sonilo-python
|
|
6
|
+
Author: Sonilo AI
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: ai,generation,music,sonilo,text-to-music,video-to-music
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
15
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# sonilo
|
|
19
|
+
|
|
20
|
+
Official Python client for the [Sonilo](https://sonilo.com) API.
|
|
21
|
+
Python ≥ 3.9. Sync and async clients included.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install sonilo
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quickstart
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from sonilo import Sonilo
|
|
33
|
+
|
|
34
|
+
client = Sonilo() # reads SONILO_API_KEY
|
|
35
|
+
|
|
36
|
+
track = client.text_to_music.generate(
|
|
37
|
+
prompt="cinematic orchestral score",
|
|
38
|
+
duration=60,
|
|
39
|
+
)
|
|
40
|
+
track.save("output.mp3")
|
|
41
|
+
print(track.title)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Video to music
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
track = client.video_to_music.generate(video="my_video.mp4", prompt="upbeat")
|
|
48
|
+
# or bytes / an open binary file, or a hosted URL:
|
|
49
|
+
track = client.video_to_music.generate(video_url="https://example.com/clip.mp4")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Streaming
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
for event in client.text_to_music.stream(prompt="lofi", duration=30):
|
|
56
|
+
if event["type"] == "audio_chunk":
|
|
57
|
+
handle(event["data"]) # bytes, as they arrive
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Async
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from sonilo import AsyncSonilo
|
|
64
|
+
|
|
65
|
+
async with AsyncSonilo() as client:
|
|
66
|
+
track = await client.text_to_music.generate(prompt="lofi", duration=30)
|
|
67
|
+
async for event in client.text_to_music.stream(prompt="lofi", duration=30):
|
|
68
|
+
...
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Segments
|
|
72
|
+
|
|
73
|
+
Shape the composition with start-only contiguous segments (each ends where
|
|
74
|
+
the next begins):
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
client.text_to_music.generate(
|
|
78
|
+
prompt="epic trailer",
|
|
79
|
+
duration=60,
|
|
80
|
+
segments=[
|
|
81
|
+
{"start": 0, "prompt": "soft intro", "label": "intro"},
|
|
82
|
+
{"start": 20, "prompt": "building tension", "label": "verse"},
|
|
83
|
+
{"start": 40, "prompt": "full orchestra", "label": "chorus"},
|
|
84
|
+
],
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Account
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
client.account.services()
|
|
92
|
+
client.account.usage(days=7)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Errors
|
|
96
|
+
|
|
97
|
+
All errors extend `SoniloError`: `AuthenticationError` (401),
|
|
98
|
+
`PaymentRequiredError` (402), `RateLimitError` (429, `.retry_after`),
|
|
99
|
+
`BadRequestError` (400/413/422, `.detail`), `APIError` (anything else),
|
|
100
|
+
and `GenerationError` for failures mid-stream.
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
sonilo-0.1.0/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# sonilo
|
|
2
|
+
|
|
3
|
+
Official Python client for the [Sonilo](https://sonilo.com) API.
|
|
4
|
+
Python ≥ 3.9. Sync and async clients included.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install sonilo
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quickstart
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from sonilo import Sonilo
|
|
16
|
+
|
|
17
|
+
client = Sonilo() # reads SONILO_API_KEY
|
|
18
|
+
|
|
19
|
+
track = client.text_to_music.generate(
|
|
20
|
+
prompt="cinematic orchestral score",
|
|
21
|
+
duration=60,
|
|
22
|
+
)
|
|
23
|
+
track.save("output.mp3")
|
|
24
|
+
print(track.title)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Video to music
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
track = client.video_to_music.generate(video="my_video.mp4", prompt="upbeat")
|
|
31
|
+
# or bytes / an open binary file, or a hosted URL:
|
|
32
|
+
track = client.video_to_music.generate(video_url="https://example.com/clip.mp4")
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Streaming
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
for event in client.text_to_music.stream(prompt="lofi", duration=30):
|
|
39
|
+
if event["type"] == "audio_chunk":
|
|
40
|
+
handle(event["data"]) # bytes, as they arrive
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Async
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from sonilo import AsyncSonilo
|
|
47
|
+
|
|
48
|
+
async with AsyncSonilo() as client:
|
|
49
|
+
track = await client.text_to_music.generate(prompt="lofi", duration=30)
|
|
50
|
+
async for event in client.text_to_music.stream(prompt="lofi", duration=30):
|
|
51
|
+
...
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Segments
|
|
55
|
+
|
|
56
|
+
Shape the composition with start-only contiguous segments (each ends where
|
|
57
|
+
the next begins):
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
client.text_to_music.generate(
|
|
61
|
+
prompt="epic trailer",
|
|
62
|
+
duration=60,
|
|
63
|
+
segments=[
|
|
64
|
+
{"start": 0, "prompt": "soft intro", "label": "intro"},
|
|
65
|
+
{"start": 20, "prompt": "building tension", "label": "verse"},
|
|
66
|
+
{"start": 40, "prompt": "full orchestra", "label": "chorus"},
|
|
67
|
+
],
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Account
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
client.account.services()
|
|
75
|
+
client.account.usage(days=7)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Errors
|
|
79
|
+
|
|
80
|
+
All errors extend `SoniloError`: `AuthenticationError` (401),
|
|
81
|
+
`PaymentRequiredError` (402), `RateLimitError` (429, `.retry_after`),
|
|
82
|
+
`BadRequestError` (400/413/422, `.detail`), `APIError` (anything else),
|
|
83
|
+
and `GenerationError` for failures mid-stream.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Usage: SONILO_API_KEY=sk_... python examples/generate.py "lofi beat" 30"""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from sonilo import Sonilo
|
|
5
|
+
|
|
6
|
+
prompt = sys.argv[1] if len(sys.argv) > 1 else "cinematic orchestral score"
|
|
7
|
+
duration = int(sys.argv[2]) if len(sys.argv) > 2 else 60
|
|
8
|
+
|
|
9
|
+
with Sonilo() as client:
|
|
10
|
+
track = client.text_to_music.generate(prompt=prompt, duration=duration)
|
|
11
|
+
|
|
12
|
+
out = track.save("output.mp3")
|
|
13
|
+
title = f' — "{track.title}"' if track.title else ""
|
|
14
|
+
print(f"Saved {out} ({len(track.audio)} bytes){title}")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sonilo"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the Sonilo API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Sonilo AI" }]
|
|
13
|
+
dependencies = ["httpx>=0.27"]
|
|
14
|
+
keywords = ["sonilo", "music", "generation", "text-to-music", "video-to-music", "ai"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Repository = "https://github.com/sonilo-ai/sonilo-python"
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "respx>=0.21"]
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/sonilo"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
asyncio_mode = "auto"
|
|
27
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from sonilo._async_client import AsyncSonilo
|
|
2
|
+
from sonilo._client import Sonilo
|
|
3
|
+
from sonilo._version import __version__
|
|
4
|
+
from sonilo.errors import (
|
|
5
|
+
APIError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
BadRequestError,
|
|
8
|
+
GenerationError,
|
|
9
|
+
PaymentRequiredError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
SoniloError,
|
|
12
|
+
)
|
|
13
|
+
from sonilo.types import Segment, StreamEvent, Track
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"APIError",
|
|
17
|
+
"AsyncSonilo",
|
|
18
|
+
"AuthenticationError",
|
|
19
|
+
"BadRequestError",
|
|
20
|
+
"GenerationError",
|
|
21
|
+
"PaymentRequiredError",
|
|
22
|
+
"RateLimitError",
|
|
23
|
+
"Segment",
|
|
24
|
+
"Sonilo",
|
|
25
|
+
"SoniloError",
|
|
26
|
+
"StreamEvent",
|
|
27
|
+
"Track",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, AsyncIterator, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from sonilo._client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, _default_headers, _resolve_api_key
|
|
8
|
+
from sonilo._streaming import aiter_events
|
|
9
|
+
from sonilo.errors import error_from_response
|
|
10
|
+
from sonilo.resources.account import AsyncAccount
|
|
11
|
+
from sonilo.resources.text_to_music import AsyncTextToMusic
|
|
12
|
+
from sonilo.resources.video_to_music import AsyncVideoToMusic
|
|
13
|
+
from sonilo.types import StreamEvent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AsyncSonilo:
|
|
17
|
+
"""Asynchronous Sonilo API client."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
api_key: Optional[str] = None,
|
|
22
|
+
base_url: Optional[str] = None,
|
|
23
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
24
|
+
) -> None:
|
|
25
|
+
key = _resolve_api_key(api_key)
|
|
26
|
+
self._http = httpx.AsyncClient(
|
|
27
|
+
base_url=(base_url or DEFAULT_BASE_URL).rstrip("/"),
|
|
28
|
+
headers=_default_headers(key, "sdk-python"),
|
|
29
|
+
timeout=timeout,
|
|
30
|
+
)
|
|
31
|
+
self.text_to_music = AsyncTextToMusic(self)
|
|
32
|
+
self.video_to_music = AsyncVideoToMusic(self)
|
|
33
|
+
self.account = AsyncAccount(self)
|
|
34
|
+
|
|
35
|
+
async def close(self) -> None:
|
|
36
|
+
await self._http.aclose()
|
|
37
|
+
|
|
38
|
+
async def __aenter__(self) -> "AsyncSonilo":
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
42
|
+
await self.close()
|
|
43
|
+
|
|
44
|
+
# -- internal transport -------------------------------------------------
|
|
45
|
+
|
|
46
|
+
async def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
47
|
+
response = await self._http.get(path, params=params)
|
|
48
|
+
if response.status_code >= 400:
|
|
49
|
+
raise error_from_response(response)
|
|
50
|
+
return response.json()
|
|
51
|
+
|
|
52
|
+
async def _stream_events(
|
|
53
|
+
self,
|
|
54
|
+
path: str,
|
|
55
|
+
*,
|
|
56
|
+
data: Dict[str, str],
|
|
57
|
+
files: Optional[Dict[str, tuple]] = None,
|
|
58
|
+
close_after: Any = None,
|
|
59
|
+
) -> AsyncIterator[StreamEvent]:
|
|
60
|
+
try:
|
|
61
|
+
async with self._http.stream("POST", path, data=data, files=files) as response:
|
|
62
|
+
if response.status_code >= 400:
|
|
63
|
+
await response.aread()
|
|
64
|
+
raise error_from_response(response)
|
|
65
|
+
async for event in aiter_events(response.aiter_text()):
|
|
66
|
+
yield event
|
|
67
|
+
finally:
|
|
68
|
+
if close_after is not None:
|
|
69
|
+
close_after.close()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Dict, Iterator, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from sonilo._streaming import iter_events
|
|
9
|
+
from sonilo._version import __version__
|
|
10
|
+
from sonilo.errors import SoniloError, error_from_response
|
|
11
|
+
from sonilo.resources.account import Account
|
|
12
|
+
from sonilo.resources.text_to_music import TextToMusic
|
|
13
|
+
from sonilo.resources.video_to_music import VideoToMusic
|
|
14
|
+
from sonilo.types import StreamEvent
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.sonilo.com"
|
|
17
|
+
DEFAULT_TIMEOUT = 600.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _resolve_api_key(api_key: Optional[str]) -> str:
|
|
21
|
+
key = api_key or os.environ.get("SONILO_API_KEY")
|
|
22
|
+
if not key:
|
|
23
|
+
raise SoniloError(
|
|
24
|
+
"Missing API key: pass api_key= or set the SONILO_API_KEY environment variable"
|
|
25
|
+
)
|
|
26
|
+
return key
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_headers(api_key: str, client_name: str) -> Dict[str, str]:
|
|
30
|
+
return {
|
|
31
|
+
"Authorization": f"Bearer {api_key}",
|
|
32
|
+
"X-Sonilo-Client": client_name,
|
|
33
|
+
"X-Sonilo-Client-Version": __version__,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Sonilo:
|
|
38
|
+
"""Synchronous Sonilo API client."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
api_key: Optional[str] = None,
|
|
43
|
+
base_url: Optional[str] = None,
|
|
44
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
45
|
+
) -> None:
|
|
46
|
+
key = _resolve_api_key(api_key)
|
|
47
|
+
self._http = httpx.Client(
|
|
48
|
+
base_url=(base_url or DEFAULT_BASE_URL).rstrip("/"),
|
|
49
|
+
headers=_default_headers(key, "sdk-python"),
|
|
50
|
+
timeout=timeout,
|
|
51
|
+
)
|
|
52
|
+
self.text_to_music = TextToMusic(self)
|
|
53
|
+
self.video_to_music = VideoToMusic(self)
|
|
54
|
+
self.account = Account(self)
|
|
55
|
+
|
|
56
|
+
def close(self) -> None:
|
|
57
|
+
self._http.close()
|
|
58
|
+
|
|
59
|
+
def __enter__(self) -> "Sonilo":
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
63
|
+
self.close()
|
|
64
|
+
|
|
65
|
+
# -- internal transport -------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
68
|
+
response = self._http.get(path, params=params)
|
|
69
|
+
if response.status_code >= 400:
|
|
70
|
+
raise error_from_response(response)
|
|
71
|
+
return response.json()
|
|
72
|
+
|
|
73
|
+
def _stream_events(
|
|
74
|
+
self,
|
|
75
|
+
path: str,
|
|
76
|
+
*,
|
|
77
|
+
data: Dict[str, str],
|
|
78
|
+
files: Optional[Dict[str, tuple]] = None,
|
|
79
|
+
close_after: Any = None,
|
|
80
|
+
) -> Iterator[StreamEvent]:
|
|
81
|
+
try:
|
|
82
|
+
with self._http.stream("POST", path, data=data, files=files) as response:
|
|
83
|
+
if response.status_code >= 400:
|
|
84
|
+
response.read()
|
|
85
|
+
raise error_from_response(response)
|
|
86
|
+
for event in iter_events(response.iter_text()):
|
|
87
|
+
yield event
|
|
88
|
+
finally:
|
|
89
|
+
if close_after is not None:
|
|
90
|
+
close_after.close()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from sonilo.errors import SoniloError
|
|
8
|
+
from sonilo.types import Segment
|
|
9
|
+
|
|
10
|
+
DEFAULT_FILENAME = "video.mp4"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_t2m_data(
|
|
14
|
+
prompt: str, duration: int, segments: Optional[List[Segment]]
|
|
15
|
+
) -> Dict[str, str]:
|
|
16
|
+
data = {"prompt": prompt, "duration": str(duration)}
|
|
17
|
+
if segments is not None:
|
|
18
|
+
data["segments"] = json.dumps(segments)
|
|
19
|
+
return data
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_video(video: Any) -> Tuple[str, Any, bool]:
|
|
23
|
+
"""Normalize a video input into (filename, httpx-uploadable, opened_here).
|
|
24
|
+
|
|
25
|
+
Accepts a filesystem path (str/Path — opened for streaming upload; the
|
|
26
|
+
caller must close it, signalled by opened_here=True), raw bytes, or a
|
|
27
|
+
binary file-like object.
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(video, (str, Path)):
|
|
30
|
+
path = Path(video)
|
|
31
|
+
return path.name or DEFAULT_FILENAME, path.open("rb"), True
|
|
32
|
+
if isinstance(video, bytes):
|
|
33
|
+
return DEFAULT_FILENAME, video, False
|
|
34
|
+
if hasattr(video, "read"):
|
|
35
|
+
raw_name = getattr(video, "name", None)
|
|
36
|
+
filename = Path(raw_name).name if isinstance(raw_name, str) and raw_name else DEFAULT_FILENAME
|
|
37
|
+
return filename, video, False
|
|
38
|
+
raise SoniloError("Unsupported video input: pass a path, bytes, or a binary file object")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_v2m_parts(
|
|
42
|
+
video: Any,
|
|
43
|
+
video_url: Optional[str],
|
|
44
|
+
prompt: Optional[str],
|
|
45
|
+
segments: Optional[List[Segment]],
|
|
46
|
+
) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]:
|
|
47
|
+
if (video is None) == (video_url is None):
|
|
48
|
+
raise SoniloError("Provide exactly one of video or video_url")
|
|
49
|
+
|
|
50
|
+
# Assemble data dict completely before opening any files
|
|
51
|
+
data: Dict[str, str] = {}
|
|
52
|
+
if video_url is not None:
|
|
53
|
+
data["video_url"] = video_url # type: ignore[assignment]
|
|
54
|
+
if prompt is not None:
|
|
55
|
+
data["prompt"] = prompt
|
|
56
|
+
if segments is not None:
|
|
57
|
+
data["segments"] = json.dumps(segments)
|
|
58
|
+
|
|
59
|
+
# Now open files (only after data is fully assembled)
|
|
60
|
+
files: Optional[Dict[str, tuple]] = None
|
|
61
|
+
opened = False
|
|
62
|
+
if video is not None:
|
|
63
|
+
filename, fileobj, opened = normalize_video(video)
|
|
64
|
+
files = {"video": (filename, fileobj, "video/mp4")}
|
|
65
|
+
|
|
66
|
+
return data, files, opened
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from typing import AsyncIterable, AsyncIterator, Dict, Iterable, Iterator, List, Optional
|
|
6
|
+
|
|
7
|
+
from sonilo.errors import GenerationError
|
|
8
|
+
from sonilo.types import StreamEvent, Track
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_line(line: str) -> StreamEvent:
|
|
12
|
+
event = json.loads(line)
|
|
13
|
+
if event.get("type") == "audio_chunk" and isinstance(event.get("data"), str):
|
|
14
|
+
event = {**event, "data": base64.b64decode(event["data"])}
|
|
15
|
+
return event
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _LineBuffer:
|
|
19
|
+
"""Accumulates text chunks and yields complete NDJSON lines."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._buf = ""
|
|
23
|
+
|
|
24
|
+
def feed(self, text: str) -> Iterator[StreamEvent]:
|
|
25
|
+
self._buf += text
|
|
26
|
+
while True:
|
|
27
|
+
idx = self._buf.find("\n")
|
|
28
|
+
if idx == -1:
|
|
29
|
+
return
|
|
30
|
+
line, self._buf = self._buf[:idx].strip(), self._buf[idx + 1 :]
|
|
31
|
+
if line:
|
|
32
|
+
yield _parse_line(line)
|
|
33
|
+
|
|
34
|
+
def flush(self) -> Iterator[StreamEvent]:
|
|
35
|
+
line, self._buf = self._buf.strip(), ""
|
|
36
|
+
if line:
|
|
37
|
+
yield _parse_line(line)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def iter_events(text_chunks: Iterable[str]) -> Iterator[StreamEvent]:
|
|
41
|
+
buf = _LineBuffer()
|
|
42
|
+
for chunk in text_chunks:
|
|
43
|
+
yield from buf.feed(chunk)
|
|
44
|
+
yield from buf.flush()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def aiter_events(text_chunks: AsyncIterable[str]) -> AsyncIterator[StreamEvent]:
|
|
48
|
+
buf = _LineBuffer()
|
|
49
|
+
async for chunk in text_chunks:
|
|
50
|
+
for event in buf.feed(chunk):
|
|
51
|
+
yield event
|
|
52
|
+
for event in buf.flush():
|
|
53
|
+
yield event
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _TrackBuilder:
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self._chunks: List[bytes] = []
|
|
59
|
+
self._title: Optional[str] = None
|
|
60
|
+
self._cost: Optional[Dict[str, str]] = None
|
|
61
|
+
self._complete = False
|
|
62
|
+
|
|
63
|
+
def add(self, event: StreamEvent) -> None:
|
|
64
|
+
event_type = event.get("type")
|
|
65
|
+
if event_type == "audio_chunk" and isinstance(event.get("data"), bytes):
|
|
66
|
+
self._chunks.append(event["data"])
|
|
67
|
+
elif event_type == "title" and isinstance(event.get("title"), str):
|
|
68
|
+
self._title = event["title"]
|
|
69
|
+
elif event_type == "cost":
|
|
70
|
+
self._cost = {k: v for k, v in event.items() if k != "type"}
|
|
71
|
+
elif event_type == "error":
|
|
72
|
+
message = event.get("message") or "generation failed"
|
|
73
|
+
code = event.get("code")
|
|
74
|
+
raise GenerationError(str(message), code=code if isinstance(code, str) else None)
|
|
75
|
+
elif event_type == "complete":
|
|
76
|
+
self._complete = True
|
|
77
|
+
# unknown event types: ignored
|
|
78
|
+
|
|
79
|
+
def build(self) -> Track:
|
|
80
|
+
if not self._complete:
|
|
81
|
+
raise GenerationError("stream ended before a 'complete' event (truncated response)")
|
|
82
|
+
return Track(audio=b"".join(self._chunks), title=self._title, cost=self._cost)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def collect_track(events: Iterable[StreamEvent]) -> Track:
|
|
86
|
+
builder = _TrackBuilder()
|
|
87
|
+
try:
|
|
88
|
+
for event in events:
|
|
89
|
+
builder.add(event)
|
|
90
|
+
finally:
|
|
91
|
+
close = getattr(events, "close", None)
|
|
92
|
+
if close is not None:
|
|
93
|
+
close()
|
|
94
|
+
return builder.build()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def acollect_track(events: AsyncIterable[StreamEvent]) -> Track:
|
|
98
|
+
builder = _TrackBuilder()
|
|
99
|
+
try:
|
|
100
|
+
async for event in events:
|
|
101
|
+
builder.add(event)
|
|
102
|
+
finally:
|
|
103
|
+
aclose = getattr(events, "aclose", None)
|
|
104
|
+
if aclose is not None:
|
|
105
|
+
await aclose()
|
|
106
|
+
return builder.build()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|