Skip to content

Commit b669cac

Browse files
Merge pull request #3 from sonilo-ai/feat/sfx
feat: text-to-sfx, video-to-sfx and task polling (v0.2.0)
2 parents 96e3ea0 + 6976078 commit b669cac

20 files changed

Lines changed: 1571 additions & 27 deletions

‎README.md‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,38 @@ client.text_to_music.generate(
6868
)
6969
```
7070

71+
## Sound effects (async tasks)
72+
73+
SFX endpoints are asynchronous: submitting returns a `task_id`, and the result
74+
is fetched by polling. `generate()` wraps submit + poll:
75+
76+
```python
77+
from sonilo import Sonilo
78+
79+
with Sonilo() as client:
80+
result = client.text_to_sfx.generate(prompt="glass shattering", duration=5)
81+
result.save("sfx.m4a")
82+
```
83+
84+
Or control polling yourself:
85+
86+
```python
87+
task = client.video_to_sfx.submit(
88+
video="clip.mp4",
89+
segments=[{"start": 0, "end": 2.5, "prompt": "footsteps on gravel"}],
90+
audio_format="wav",
91+
)
92+
result = client.tasks.wait(task.task_id, poll_interval=2.0, timeout=600.0)
93+
result.save("audio.wav")
94+
result.save("with_audio.mp4", which="video") # video-to-sfx also returns the muxed video
95+
```
96+
97+
`tasks.get(task_id)` fetches state once and never raises on a failed task;
98+
`tasks.wait()` / `generate()` raise `TaskFailedError` (with `.code`,
99+
`.refunded`) on failure and `TaskTimeoutError` if the deadline passes — the
100+
task keeps running server-side and can still be polled afterwards. Result URLs
101+
are presigned and expire; download promptly or re-fetch via `tasks.get`.
102+
71103
## Account
72104

73105
```python
@@ -80,8 +112,11 @@ client.account.usage(days=7)
80112
All errors extend `SoniloError`: `AuthenticationError` (401),
81113
`PaymentRequiredError` (402), `RateLimitError` (429, `.retry_after`),
82114
`BadRequestError` (400/413/422, `.detail`), `APIError` (anything else),
83-
and `GenerationError` for failures mid-stream.
84-
85-
## License
86-
87-
MIT
115+
`GenerationError` for failures mid-stream, `TaskFailedError` (`.code`,
116+
`.task_id`, `.refunded`) for a failed SFX task, and `TaskTimeoutError`
117+
(`.task_id`) when `tasks.wait()` / `generate()` hits its deadline.
118+
119+
Every `APIError` also carries `.status_code`, `.body` (the parsed response),
120+
`.code` (the API's error code, e.g. `"rate_limit_exceeded"`), and `.errors`
121+
(the validation detail list on a 422), in addition to any subclass-specific
122+
attributes above.

‎examples/sfx.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Generate a sound effect from text and save it locally.
2+
3+
Usage: SONILO_API_KEY=sk_... python examples/sfx.py
4+
"""
5+
from sonilo import Sonilo
6+
7+
with Sonilo() as client:
8+
result = client.text_to_sfx.generate(
9+
prompt="glass shattering on a stone floor", duration=5
10+
)
11+
path = result.save("sfx.m4a")
12+
print(f"Saved {path}")

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "sonilo"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "Official Python client for the Sonilo API"
99
readme = "README.md"
1010
license = "MIT"
1111
requires-python = ">=3.9"
1212
authors = [{ name = "Sonilo AI" }]
1313
dependencies = ["httpx>=0.27"]
14-
keywords = ["sonilo", "music", "generation", "text-to-music", "video-to-music", "ai"]
14+
keywords = ["sonilo", "music", "generation", "text-to-music", "video-to-music", "ai", "sfx", "sound-effects"]
1515

1616
[project.urls]
1717
Repository = "https://github.com/sonilo-ai/sonilo-python"

‎src/sonilo/__init__.py‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,18 @@
99
PaymentRequiredError,
1010
RateLimitError,
1111
SoniloError,
12+
TaskFailedError,
13+
TaskTimeoutError,
14+
)
15+
from sonilo.types import (
16+
Segment,
17+
SfxMedia,
18+
SfxResult,
19+
SfxSegment,
20+
SfxTask,
21+
StreamEvent,
22+
Track,
1223
)
13-
from sonilo.types import Segment, StreamEvent, Track
1424

1525
__all__ = [
1626
"APIError",
@@ -21,9 +31,15 @@
2131
"PaymentRequiredError",
2232
"RateLimitError",
2333
"Segment",
34+
"SfxMedia",
35+
"SfxResult",
36+
"SfxSegment",
37+
"SfxTask",
2438
"Sonilo",
2539
"SoniloError",
2640
"StreamEvent",
41+
"TaskFailedError",
42+
"TaskTimeoutError",
2743
"Track",
2844
"__version__",
2945
]

‎src/sonilo/_async_client.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
from sonilo._streaming import aiter_events
99
from sonilo.errors import error_from_response
1010
from sonilo.resources.account import AsyncAccount
11+
from sonilo.resources.tasks import AsyncTasks
1112
from sonilo.resources.text_to_music import AsyncTextToMusic
13+
from sonilo.resources.text_to_sfx import AsyncTextToSfx
1214
from sonilo.resources.video_to_music import AsyncVideoToMusic
15+
from sonilo.resources.video_to_sfx import AsyncVideoToSfx
1316
from sonilo.types import StreamEvent
1417

1518

@@ -30,7 +33,10 @@ def __init__(
3033
)
3134
self.text_to_music = AsyncTextToMusic(self)
3235
self.video_to_music = AsyncVideoToMusic(self)
36+
self.text_to_sfx = AsyncTextToSfx(self)
37+
self.video_to_sfx = AsyncVideoToSfx(self)
3338
self.account = AsyncAccount(self)
39+
self.tasks = AsyncTasks(self)
3440

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

58+
async def _post_json(
59+
self,
60+
path: str,
61+
*,
62+
data: Dict[str, str],
63+
files: Optional[Dict[str, tuple]] = None,
64+
close_after: Any = None,
65+
) -> Any:
66+
try:
67+
response = await self._http.post(path, data=data, files=files)
68+
finally:
69+
if close_after is not None:
70+
close_after.close()
71+
if response.status_code >= 400:
72+
raise error_from_response(response)
73+
return response.json()
74+
5275
async def _stream_events(
5376
self,
5477
path: str,

‎src/sonilo/_client.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99
from sonilo._version import __version__
1010
from sonilo.errors import SoniloError, error_from_response
1111
from sonilo.resources.account import Account
12+
from sonilo.resources.tasks import Tasks
1213
from sonilo.resources.text_to_music import TextToMusic
14+
from sonilo.resources.text_to_sfx import TextToSfx
1315
from sonilo.resources.video_to_music import VideoToMusic
16+
from sonilo.resources.video_to_sfx import VideoToSfx
1417
from sonilo.types import StreamEvent
1518

1619
DEFAULT_BASE_URL = "https://api.sonilo.com"
@@ -51,7 +54,10 @@ def __init__(
5154
)
5255
self.text_to_music = TextToMusic(self)
5356
self.video_to_music = VideoToMusic(self)
57+
self.text_to_sfx = TextToSfx(self)
58+
self.video_to_sfx = VideoToSfx(self)
5459
self.account = Account(self)
60+
self.tasks = Tasks(self)
5561

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

79+
def _post_json(
80+
self,
81+
path: str,
82+
*,
83+
data: Dict[str, str],
84+
files: Optional[Dict[str, tuple]] = None,
85+
close_after: Any = None,
86+
) -> Any:
87+
try:
88+
response = self._http.post(path, data=data, files=files)
89+
finally:
90+
if close_after is not None:
91+
close_after.close()
92+
if response.status_code >= 400:
93+
raise error_from_response(response)
94+
return response.json()
95+
7396
def _stream_events(
7497
self,
7598
path: str,

‎src/sonilo/_requests.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,25 @@ def build_v2m_parts(
6464
files = {"video": (filename, fileobj, "video/mp4")}
6565

6666
return data, files, opened
67+
68+
69+
def build_sfx_t2s_data(
70+
prompt: str, duration: int, audio_format: Optional[str]
71+
) -> Dict[str, str]:
72+
data = {"prompt": prompt, "duration": str(duration)}
73+
if audio_format is not None:
74+
data["audio_format"] = audio_format
75+
return data
76+
77+
78+
def build_sfx_v2s_parts(
79+
video: Any,
80+
video_url: Optional[str],
81+
prompt: Optional[str],
82+
segments: Optional[List[Segment]],
83+
audio_format: Optional[str],
84+
) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]:
85+
data, files, opened = build_v2m_parts(video, video_url, prompt, segments)
86+
if audio_format is not None:
87+
data["audio_format"] = audio_format
88+
return data, files, opened

‎src/sonilo/_streaming.py‎

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,68 @@
11
from __future__ import annotations
22

33
import base64
4+
import binascii
45
import json
6+
import re
57
from typing import AsyncIterable, AsyncIterator, Dict, Iterable, Iterator, List, Optional
68

79
from sonilo.errors import GenerationError
810
from sonilo.types import StreamEvent, Track
911

1012

11-
def _parse_line(line: str) -> StreamEvent:
12-
event = json.loads(line)
13+
def _forgiving_b64decode(data: str) -> bytes:
14+
"""Decode base64 the way the JS SDK's `atob()` does: WHATWG's
15+
"forgiving-base64 decode" algorithm (https://infra.spec.whatwg.org/#forgiving-base64-decode).
16+
17+
This differs from `base64.b64decode` in two ways that matter for
18+
cross-SDK parity on the same wire payload:
19+
- Padding is optional. `atob()` does not require `=` padding, so an
20+
upstream re-encoding that omits it must still decode instead of
21+
spuriously failing a generation the JS SDK handles fine.
22+
- Only ASCII whitespace (space, tab, LF, FF, CR) is stripped before
23+
decoding, not all Unicode whitespace (`atob()` throws on NBSP,
24+
vertical tab, U+2028, etc., so we must reject those too, not
25+
silently strip them).
26+
"""
27+
cleaned = re.sub(r"[ \t\n\f\r]", "", data)
28+
if len(cleaned) % 4 == 0 and cleaned.endswith("="):
29+
cleaned = cleaned[:-2] if cleaned.endswith("==") else cleaned[:-1]
30+
if len(cleaned) % 4 == 1:
31+
raise binascii.Error(
32+
"Invalid base64-encoded string: number of data characters cannot be 1 more "
33+
"than a multiple of 4"
34+
)
35+
if not re.fullmatch(r"[A-Za-z0-9+/]*", cleaned):
36+
raise binascii.Error("Non-base64 digit found")
37+
padded = cleaned + "=" * (-len(cleaned) % 4)
38+
return base64.b64decode(padded, validate=True)
39+
40+
41+
def _parse_line(line: str) -> Optional[StreamEvent]:
42+
"""Returns `None` for a valid-JSON-but-non-dict line (e.g. a bare `null`
43+
or a number/string), which carries no event `type` and is skipped like
44+
any other junk line rather than crashing on a `.get()` off `None`."""
45+
parsed = json.loads(line)
46+
if not isinstance(parsed, dict):
47+
return None
48+
event = parsed
1349
if event.get("type") == "audio_chunk" and isinstance(event.get("data"), str):
14-
event = {**event, "data": base64.b64decode(event["data"])}
50+
try:
51+
# Mirror the JS SDK's `atob()` (WHATWG forgiving-base64) exactly,
52+
# via _forgiving_b64decode: unpadded base64 must decode (not
53+
# raise), only ASCII whitespace is stripped (not all Unicode
54+
# whitespace), and an invalid-alphabet or misaligned-length
55+
# payload must still raise so it doesn't silently decode to
56+
# fewer, wrong bytes.
57+
decoded = _forgiving_b64decode(event["data"])
58+
event = {**event, "data": decoded}
59+
except (binascii.Error, ValueError):
60+
# Don't raise here: this must reach _TrackBuilder.add, whose
61+
# malformed-chunk check turns undecodable data into a typed
62+
# GenerationError. Raising in place would let a raw
63+
# binascii.Error/ValueError escape stream()/generate(), breaking
64+
# the SDK's "all errors extend SoniloError" contract.
65+
pass
1566
return event
1667

1768

@@ -29,12 +80,16 @@ def feed(self, text: str) -> Iterator[StreamEvent]:
2980
return
3081
line, self._buf = self._buf[:idx].strip(), self._buf[idx + 1 :]
3182
if line:
32-
yield _parse_line(line)
83+
event = _parse_line(line)
84+
if event is not None:
85+
yield event
3386

3487
def flush(self) -> Iterator[StreamEvent]:
3588
line, self._buf = self._buf.strip(), ""
3689
if line:
37-
yield _parse_line(line)
90+
event = _parse_line(line)
91+
if event is not None:
92+
yield event
3893

3994

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

63118
def add(self, event: StreamEvent) -> None:
64119
event_type = event.get("type")
65-
if event_type == "audio_chunk" and isinstance(event.get("data"), bytes):
120+
if event_type == "audio_chunk":
121+
# A malformed chunk (missing/non-decodable `data`) must not be
122+
# silently dropped: that would hand back a "successful" Track
123+
# with empty or truncated audio and no indication anything went
124+
# wrong.
125+
if not isinstance(event.get("data"), bytes):
126+
raise GenerationError(
127+
"received a malformed audio_chunk event (missing or non-decodable data)"
128+
)
66129
self._chunks.append(event["data"])
67130
elif event_type == "title" and isinstance(event.get("title"), str):
68131
self._title = event["title"]
69132
elif event_type == "cost":
70133
self._cost = {k: v for k, v in event.items() if k != "type"}
71134
elif event_type == "error":
72-
message = event.get("message") or "generation failed"
135+
raw_message = event.get("message")
136+
message = (
137+
raw_message
138+
if isinstance(raw_message, str) and raw_message
139+
else "generation failed"
140+
)
73141
code = event.get("code")
74-
raise GenerationError(str(message), code=code if isinstance(code, str) else None)
142+
raise GenerationError(message, code=code if isinstance(code, str) else None)
75143
elif event_type == "complete":
76144
self._complete = True
77145
# unknown event types: ignored

‎src/sonilo/_version.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.1.0"
1+
__version__ = "0.2.0"

0 commit comments

Comments
 (0)