feat: text-to-sfx, video-to-sfx and task polling (v0.2.0) - #3
Merged
Conversation
Percent-encode task_id before interpolating it into the /v1/tasks/{id}
path so ids containing path separators or other reserved characters
can't alter the request path.
Document TaskFailedError/TaskTimeoutError in the README error list, add sfx/sound-effects to package keywords, assert no auth header leaks to presigned download URLs, add an async wait() timeout test, and drop a stale review-cycle comment.
- _raise_if_failed no longer crashes when the backend sends `error` as a non-dict (e.g. a bare string). - parse_sfx_result/parse_sfx_task raise SoniloError instead of a bare KeyError on malformed/truncated task bodies, so callers catching SoniloError aren't bypassed. - SfxResult.save()/asave() take a timeout kwarg (default 600s) instead of inheriting httpx's ~5s default, matching the SDK client's own timeout. - Tasks.wait/AsyncTasks.wait now both raise SoniloError on a negative poll_interval or timeout, instead of diverging (sync raises ValueError from time.sleep, async busy-polls silently).
- _TrackBuilder.add() silently dropped any audio_chunk event whose `data` was missing or not a decodable base64 string (it matched no branch, so it fell through as an unknown event), letting collect_track()/ acollect_track() return a successful Track with empty/truncated audio and no error. Now it raises GenerationError instead. - _parse_line() called `.get()` on the result of json.loads() without checking it was a dict first, so a bare `null` NDJSON line crashed with a raw AttributeError instead of a typed error. It now treats a non-dict line as junk to skip, same as an empty line.
base64.b64decode() in _parse_line() was raising a raw binascii.Error for malformed audio_chunk data before the event ever reached the collector, so the existing malformed-chunk check in _TrackBuilder.add never saw it and the error escaped stream()/generate() untyped, breaking the "all errors extend SoniloError" contract.
Tasks.wait/AsyncTasks.wait slept the full poll_interval even when it exceeded the remaining timeout, so TaskTimeoutError fired long after the deadline (e.g. poll_interval=1000, timeout=5 slept ~1000s before raising). Sleep is now clamped to the time remaining until deadline.
base64.b64decode(..., validate=True) required correct `=` padding and stripped Unicode \s whitespace, unlike the JS SDK's atob(). This meant a byte-identical audio_chunk payload (e.g. unpadded base64, or base64 containing NBSP/vertical-tab/U+2028) could decode successfully in JS while spuriously raising GenerationError in Python. Add _forgiving_b64decode(), which implements the WHATWG forgiving-base64-decode algorithm atob() uses: strip only ASCII whitespace, drop trailing padding when present, reject remainder-1 lengths and non-alphabet characters, then pad to a multiple of 4 before decoding with validate=True. Cross-checked all 15 payloads (6 decode-success, 9 raise) against Node's atob() in sonilo-js -- outcomes and decoded bytes agree in every case.
Python used truthiness, so a non-string truthy `message` (e.g. 42) was stringified verbatim while the JS SDK fell back to the default. Both SDKs now use only a non-empty string message and fall back otherwise.
…tail
Routes under /v1/* return errors as {"code": "...", "message": "..."}
rather than FastAPI's default {"detail": "..."}, so every APIError message
was silently degrading to the HTTP reason phrase. Build the message from
`message` (falling back to legacy `detail`), and expose `.code` and
`.errors` on APIError so callers can branch on the API's typed error code
and read 422 validation details.
spencer-zqian
force-pushed
the
feat/sfx
branch
2 times, most recently
from
July 12, 2026 18:20
8821be5 to
de112b1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the three new SFX endpoints to the Python SDK, and fixes a set of bugs found along the way — several of which affect the already-shipped music endpoints too.
What's new
SFX is submit-then-poll (unlike the streaming music endpoints), so the SDK wraps the polling but keeps the primitives exposed:
text_to_sfx/video_to_sfx:submit()(returnsSfxTask) andgenerate()(= submit + wait).tasks:get()(single fetch, never raises on a failed task) andwait()(polls to a terminal state).SfxResult.save()/asave()download the presigned R2 URLs; no API key is ever sent to the R2 host.TaskFailedError(with.code,.refunded) andTaskTimeoutError(resumable — the task keeps running server-side).AsyncSonilomirrors the whole surface.Bug fixes (several pre-date this branch)
The SDK was parsing an error field the API never sends. The public
/v1/*routes return{"code", "message"}(seebackend/app/errors.py), not FastAPI's{"detail"}— so every API error degraded toHTTP 400: Bad Requestand the real cause was discarded. This affected the shipped music endpoints too.APIErrornow exposes.codeand.errors(422 validation details), andBadRequestError.detailworks again.Also fixed, all with regression tests:
base64.b64decode()is lenient by default — it discards invalid characters and re-aligns, so a corruptedaudio_chunkdecoded to wrong, shorter bytes andgenerate()returned a "successful" Track with silently truncated audio. Now decodes strictly, matching the JS SDK'satob()semantics (WHATWG forgiving-base64) on all 21 payloads cross-checked.audio_chunk(missingdata) was silently dropped as an unknown event, likewise yielding empty audio with no error. Now raisesGenerationError.nullNDJSON line crashed with an untypedAttributeError.KeyError, bypassing callers'except SoniloError.SfxResult.save()/asave()used httpx's 5-second default timeout — any real download with a >5s stall failed. Now 600s, overridable.tasks.get()didn't URL-encode the task id.wait()slept the full poll interval past its own deadline, and sync/async disagreed on a negativepoll_interval(one raised, the other busy-polled). Both now validate and clamp.Verification
pytest: 126 passing.End-to-end against production: text-to-sfx and video-to-sfx (multipart upload + segments) both generate, poll, download and save real audio/video — downloaded bytes match the reported
file_size, WAV/MP3/MP4 headers validate, and the muxed video carries the generated audio track. Error mapping, validation, 404s and path-injection guards verified live.Versions bumped 0.1.0 → 0.2.0. The mirrored JS SDK change is sonilo-ai/sonilo-js#1.