Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0cccbf5
feat: SFX types (SfxTask/SfxMedia/SfxResult) and task errors
spencer-zqian Jul 12, 2026
df40bc9
feat: SFX request builders
spencer-zqian Jul 12, 2026
d015b00
feat: tasks resource with get/wait polling
spencer-zqian Jul 12, 2026
a54638a
feat: text-to-sfx and video-to-sfx resources with generate()
spencer-zqian Jul 12, 2026
9155da6
test: tighten text-to-sfx prompt assertion
spencer-zqian Jul 12, 2026
957f842
test: async client coverage for SFX endpoints
spencer-zqian Jul 12, 2026
bea6b23
docs: SFX usage, example, bump to 0.2.0
spencer-zqian Jul 12, 2026
37ba353
fix: URL-encode task_id in tasks.get
spencer-zqian Jul 12, 2026
94f93fb
docs: list task errors in README, sfx keywords, test polish
spencer-zqian Jul 12, 2026
017a1ed
fix: harden SFX task parsing, download timeout, and poll-arg validation
spencer-zqian Jul 12, 2026
b113dc7
fix: fail loudly on malformed audio_chunk and non-object stream lines
spencer-zqian Jul 12, 2026
fbacdb6
fix: raise GenerationError on undecodable audio_chunk data
spencer-zqian Jul 12, 2026
69ab41a
fix: decode audio_chunk base64 strictly to prevent silent corruption
spencer-zqian Jul 12, 2026
eac2550
fix: clamp poll sleep to the remaining deadline
spencer-zqian Jul 12, 2026
ed8d657
fix: align audio_chunk base64 decoding with WHATWG forgiving-base64
spencer-zqian Jul 12, 2026
655a929
fix: fall back to default stream error message for any non-string value
spencer-zqian Jul 12, 2026
de112b1
fix: parse the API's {code, message} error envelope, not FastAPI's de…
spencer-zqian Jul 12, 2026
6976078
docs: drop the License section from the README
spencer-zqian Jul 12, 2026
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
45 changes: 40 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,38 @@ client.text_to_music.generate(
)
```

## Sound effects (async tasks)

SFX endpoints are asynchronous: submitting returns a `task_id`, and the result
is fetched by polling. `generate()` wraps submit + poll:

```python
from sonilo import Sonilo

with Sonilo() as client:
result = client.text_to_sfx.generate(prompt="glass shattering", duration=5)
result.save("sfx.m4a")
```

Or control polling yourself:

```python
task = client.video_to_sfx.submit(
video="clip.mp4",
segments=[{"start": 0, "end": 2.5, "prompt": "footsteps on gravel"}],
audio_format="wav",
)
result = client.tasks.wait(task.task_id, poll_interval=2.0, timeout=600.0)
result.save("audio.wav")
result.save("with_audio.mp4", which="video") # video-to-sfx also returns the muxed video
```

`tasks.get(task_id)` fetches state once and never raises on a failed task;
`tasks.wait()` / `generate()` raise `TaskFailedError` (with `.code`,
`.refunded`) on failure and `TaskTimeoutError` if the deadline passes — the
task keeps running server-side and can still be polled afterwards. Result URLs
are presigned and expire; download promptly or re-fetch via `tasks.get`.

## Account

```python
Expand All @@ -80,8 +112,11 @@ client.account.usage(days=7)
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
`GenerationError` for failures mid-stream, `TaskFailedError` (`.code`,
`.task_id`, `.refunded`) for a failed SFX task, and `TaskTimeoutError`
(`.task_id`) when `tasks.wait()` / `generate()` hits its deadline.

Every `APIError` also carries `.status_code`, `.body` (the parsed response),
`.code` (the API's error code, e.g. `"rate_limit_exceeded"`), and `.errors`
(the validation detail list on a 422), in addition to any subclass-specific
attributes above.
12 changes: 12 additions & 0 deletions examples/sfx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Generate a sound effect from text and save it locally.

Usage: SONILO_API_KEY=sk_... python examples/sfx.py
"""
from sonilo import Sonilo

with Sonilo() as client:
result = client.text_to_sfx.generate(
prompt="glass shattering on a stone floor", duration=5
)
path = result.save("sfx.m4a")
print(f"Saved {path}")
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ build-backend = "hatchling.build"

[project]
name = "sonilo"
version = "0.1.0"
version = "0.2.0"
description = "Official Python client for the Sonilo API"
readme = "README.md"
license = "MIT"
requires-python = ">=3.9"
authors = [{ name = "Sonilo AI" }]
dependencies = ["httpx>=0.27"]
keywords = ["sonilo", "music", "generation", "text-to-music", "video-to-music", "ai"]
keywords = ["sonilo", "music", "generation", "text-to-music", "video-to-music", "ai", "sfx", "sound-effects"]

[project.urls]
Repository = "https://github.com/sonilo-ai/sonilo-python"
Expand Down
18 changes: 17 additions & 1 deletion src/sonilo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,18 @@
PaymentRequiredError,
RateLimitError,
SoniloError,
TaskFailedError,
TaskTimeoutError,
)
from sonilo.types import (
Segment,
SfxMedia,
SfxResult,
SfxSegment,
SfxTask,
StreamEvent,
Track,
)
from sonilo.types import Segment, StreamEvent, Track

__all__ = [
"APIError",
Expand All @@ -21,9 +31,15 @@
"PaymentRequiredError",
"RateLimitError",
"Segment",
"SfxMedia",
"SfxResult",
"SfxSegment",
"SfxTask",
"Sonilo",
"SoniloError",
"StreamEvent",
"TaskFailedError",
"TaskTimeoutError",
"Track",
"__version__",
]
23 changes: 23 additions & 0 deletions src/sonilo/_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
from sonilo._streaming import aiter_events
from sonilo.errors import error_from_response
from sonilo.resources.account import AsyncAccount
from sonilo.resources.tasks import AsyncTasks
from sonilo.resources.text_to_music import AsyncTextToMusic
from sonilo.resources.text_to_sfx import AsyncTextToSfx
from sonilo.resources.video_to_music import AsyncVideoToMusic
from sonilo.resources.video_to_sfx import AsyncVideoToSfx
from sonilo.types import StreamEvent


Expand All @@ -30,7 +33,10 @@ def __init__(
)
self.text_to_music = AsyncTextToMusic(self)
self.video_to_music = AsyncVideoToMusic(self)
self.text_to_sfx = AsyncTextToSfx(self)
self.video_to_sfx = AsyncVideoToSfx(self)
self.account = AsyncAccount(self)
self.tasks = AsyncTasks(self)

async def close(self) -> None:
await self._http.aclose()
Expand All @@ -49,6 +55,23 @@ async def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) ->
raise error_from_response(response)
return response.json()

async def _post_json(
self,
path: str,
*,
data: Dict[str, str],
files: Optional[Dict[str, tuple]] = None,
close_after: Any = None,
) -> Any:
try:
response = await self._http.post(path, data=data, files=files)
finally:
if close_after is not None:
close_after.close()
if response.status_code >= 400:
raise error_from_response(response)
return response.json()

async def _stream_events(
self,
path: str,
Expand Down
23 changes: 23 additions & 0 deletions src/sonilo/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@
from sonilo._version import __version__
from sonilo.errors import SoniloError, error_from_response
from sonilo.resources.account import Account
from sonilo.resources.tasks import Tasks
from sonilo.resources.text_to_music import TextToMusic
from sonilo.resources.text_to_sfx import TextToSfx
from sonilo.resources.video_to_music import VideoToMusic
from sonilo.resources.video_to_sfx import VideoToSfx
from sonilo.types import StreamEvent

DEFAULT_BASE_URL = "https://api.sonilo.com"
Expand Down Expand Up @@ -51,7 +54,10 @@ def __init__(
)
self.text_to_music = TextToMusic(self)
self.video_to_music = VideoToMusic(self)
self.text_to_sfx = TextToSfx(self)
self.video_to_sfx = VideoToSfx(self)
self.account = Account(self)
self.tasks = Tasks(self)

def close(self) -> None:
self._http.close()
Expand All @@ -70,6 +76,23 @@ def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
raise error_from_response(response)
return response.json()

def _post_json(
self,
path: str,
*,
data: Dict[str, str],
files: Optional[Dict[str, tuple]] = None,
close_after: Any = None,
) -> Any:
try:
response = self._http.post(path, data=data, files=files)
finally:
if close_after is not None:
close_after.close()
if response.status_code >= 400:
raise error_from_response(response)
return response.json()

def _stream_events(
self,
path: str,
Expand Down
22 changes: 22 additions & 0 deletions src/sonilo/_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,25 @@ def build_v2m_parts(
files = {"video": (filename, fileobj, "video/mp4")}

return data, files, opened


def build_sfx_t2s_data(
prompt: str, duration: int, audio_format: Optional[str]
) -> Dict[str, str]:
data = {"prompt": prompt, "duration": str(duration)}
if audio_format is not None:
data["audio_format"] = audio_format
return data


def build_sfx_v2s_parts(
video: Any,
video_url: Optional[str],
prompt: Optional[str],
segments: Optional[List[Segment]],
audio_format: Optional[str],
) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]:
data, files, opened = build_v2m_parts(video, video_url, prompt, segments)
if audio_format is not None:
data["audio_format"] = audio_format
return data, files, opened
84 changes: 76 additions & 8 deletions src/sonilo/_streaming.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,68 @@
from __future__ import annotations

import base64
import binascii
import json
import re
from typing import AsyncIterable, AsyncIterator, Dict, Iterable, Iterator, List, Optional

from sonilo.errors import GenerationError
from sonilo.types import StreamEvent, Track


def _parse_line(line: str) -> StreamEvent:
event = json.loads(line)
def _forgiving_b64decode(data: str) -> bytes:
"""Decode base64 the way the JS SDK's `atob()` does: WHATWG's
"forgiving-base64 decode" algorithm (https://infra.spec.whatwg.org/#forgiving-base64-decode).

This differs from `base64.b64decode` in two ways that matter for
cross-SDK parity on the same wire payload:
- Padding is optional. `atob()` does not require `=` padding, so an
upstream re-encoding that omits it must still decode instead of
spuriously failing a generation the JS SDK handles fine.
- Only ASCII whitespace (space, tab, LF, FF, CR) is stripped before
decoding, not all Unicode whitespace (`atob()` throws on NBSP,
vertical tab, U+2028, etc., so we must reject those too, not
silently strip them).
"""
cleaned = re.sub(r"[ \t\n\f\r]", "", data)
if len(cleaned) % 4 == 0 and cleaned.endswith("="):
cleaned = cleaned[:-2] if cleaned.endswith("==") else cleaned[:-1]
if len(cleaned) % 4 == 1:
raise binascii.Error(
"Invalid base64-encoded string: number of data characters cannot be 1 more "
"than a multiple of 4"
)
if not re.fullmatch(r"[A-Za-z0-9+/]*", cleaned):
raise binascii.Error("Non-base64 digit found")
padded = cleaned + "=" * (-len(cleaned) % 4)
return base64.b64decode(padded, validate=True)


def _parse_line(line: str) -> Optional[StreamEvent]:
"""Returns `None` for a valid-JSON-but-non-dict line (e.g. a bare `null`
or a number/string), which carries no event `type` and is skipped like
any other junk line rather than crashing on a `.get()` off `None`."""
parsed = json.loads(line)
if not isinstance(parsed, dict):
return None
event = parsed
if event.get("type") == "audio_chunk" and isinstance(event.get("data"), str):
event = {**event, "data": base64.b64decode(event["data"])}
try:
# Mirror the JS SDK's `atob()` (WHATWG forgiving-base64) exactly,
# via _forgiving_b64decode: unpadded base64 must decode (not
# raise), only ASCII whitespace is stripped (not all Unicode
# whitespace), and an invalid-alphabet or misaligned-length
# payload must still raise so it doesn't silently decode to
# fewer, wrong bytes.
decoded = _forgiving_b64decode(event["data"])
event = {**event, "data": decoded}
except (binascii.Error, ValueError):
# Don't raise here: this must reach _TrackBuilder.add, whose
# malformed-chunk check turns undecodable data into a typed
# GenerationError. Raising in place would let a raw
# binascii.Error/ValueError escape stream()/generate(), breaking
# the SDK's "all errors extend SoniloError" contract.
pass
return event


Expand All @@ -29,12 +80,16 @@ def feed(self, text: str) -> Iterator[StreamEvent]:
return
line, self._buf = self._buf[:idx].strip(), self._buf[idx + 1 :]
if line:
yield _parse_line(line)
event = _parse_line(line)
if event is not None:
yield event

def flush(self) -> Iterator[StreamEvent]:
line, self._buf = self._buf.strip(), ""
if line:
yield _parse_line(line)
event = _parse_line(line)
if event is not None:
yield event


def iter_events(text_chunks: Iterable[str]) -> Iterator[StreamEvent]:
Expand Down Expand Up @@ -62,16 +117,29 @@ def __init__(self) -> None:

def add(self, event: StreamEvent) -> None:
event_type = event.get("type")
if event_type == "audio_chunk" and isinstance(event.get("data"), bytes):
if event_type == "audio_chunk":
# A malformed chunk (missing/non-decodable `data`) must not be
# silently dropped: that would hand back a "successful" Track
# with empty or truncated audio and no indication anything went
# wrong.
if not isinstance(event.get("data"), bytes):
raise GenerationError(
"received a malformed audio_chunk event (missing or non-decodable data)"
)
self._chunks.append(event["data"])
elif event_type == "title" and isinstance(event.get("title"), str):
self._title = event["title"]
elif event_type == "cost":
self._cost = {k: v for k, v in event.items() if k != "type"}
elif event_type == "error":
message = event.get("message") or "generation failed"
raw_message = event.get("message")
message = (
raw_message
if isinstance(raw_message, str) and raw_message
else "generation failed"
)
code = event.get("code")
raise GenerationError(str(message), code=code if isinstance(code, str) else None)
raise GenerationError(message, code=code if isinstance(code, str) else None)
elif event_type == "complete":
self._complete = True
# unknown event types: ignored
Expand Down
2 changes: 1 addition & 1 deletion src/sonilo/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.0"
__version__ = "0.2.0"
Loading
Loading