feat: textToSfx, videoToSfx and task polling (v0.2.0) - #4
Merged
Conversation
Document TaskFailedError/TaskTimeoutError in the README error list, add sfx/sound-effects to package keywords, and cover that tasks.get() URL-encodes the taskId path segment.
…tured 4xx detail - download() throws SoniloError instead of a raw TypeError when media (or its url) is absent; widened its param type so `download(result.audio)` typechecks without a `!` assertion. Updated README accordingly. - Added a default 600s request timeout (DEFAULT_TIMEOUT_MS, configurable via SoniloClientOptions.timeout) applied via AbortSignal in SoniloClient.request() and download(), so a stalled connection no longer hangs forever; a caller-supplied AbortSignal is respected, not overwritten. - Tasks.wait's poll deadline now uses performance.now() (monotonic) instead of Date.now(), avoiding premature/late timeouts from clock adjustments. - errorFromResponse now stringifies structured (non-string) `detail` bodies (e.g. FastAPI 422 validation arrays) into the error message instead of discarding them. - Failure messages now fall back to "Generation failed" on an empty string, not just on null/undefined, matching the Python SDK's `or`.
request() now takes an opts.timeout override (null disables the abort signal entirely); textToMusic.stream() and videoToMusic.stream() opt out so a healthy, still-streaming generation is no longer killed at the 10-minute mark by 6c4c106's absolute AbortSignal.timeout(). A caller-supplied init.signal still wins as before. When our own timeout signal does fire (one-shot requests, download()), the rejection is now rethrown as a typed RequestTimeoutError instead of a raw DOMException, matching every other failure mode in the SDK. A caller-supplied signal's abort is never rewrapped. Documents SoniloClientOptions.timeout and RequestTimeoutError in the README, and adds deterministic tests (fetch stub that respects init.signal, tiny timeouts) exercising real timeout firing instead of just asserting a signal object is present.
…ncellation - request()'s ownsSignal check used a strict `=== undefined`, out of sync with the `??` used to attach the client's own timeout signal. An explicit `signal: null` therefore let our own AbortSignal.timeout() fire but never rewrapped its rejection as RequestTimeoutError, leaking a raw DOMException. Switch to the same loose `== null` check. - TextToMusicParams/VideoToMusicParams gained an optional `signal`, threaded into the stream() request as a caller-supplied signal (never rewrapped, since these calls already opt out of the absolute timeout). Updated the README, which previously pointed at tasks.wait()'s timeout and a self-supplied AbortController — neither of which actually bounds a music stream.
- collectTrack 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 generate() return a successful Track with empty/truncated audio and no error. Now it raises GenerationError instead. - toEvent() called `raw.type` on the result of JSON.parse without checking it was an object first, so a bare `null` NDJSON line crashed with a raw TypeError instead of a typed error. It now treats a non-object line as junk to skip, same as an empty line.
atob() in toEvent() was throwing a raw DOMException for malformed audio_chunk data before the event ever reached the collector, so the existing malformed-chunk check in collectTrack never saw it and the error escaped stream()/generate() untyped, breaking the "all errors extend SoniloError" contract.
…eep to deadline - errorFromResponse treated `detail: null` and `detail: ""` as present, producing "HTTP 422: null" instead of falling back to statusText. - tasks.wait() slept the full pollInterval even when it exceeded the remaining timeout, so TaskTimeoutError fired long after the deadline.
A negative delay is clamped to 0 by setTimeout, so wait() busy-looped against the API until the deadline (169 requests in 200ms) instead of failing fast. Mirrors the Python SDK's _validate_wait_args.
…tail
Routes under /v1/* return errors as {"code": "...", "message": "..."}
rather than FastAPI's default {"detail": "..."}, so every APIError message
was silently degrading to statusText. 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.
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 JS/TS SDK, mirroring sonilo-ai/sonilo-python#3, 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:
textToSfx/videoToSfx:submit()(returnsSfxTask) andgenerate()(= submit + wait).tasks:get()(single fetch, never throws on a failed task) andwait()(polls to a terminal state).download(media)fetches the presigned R2 URL; no API key is ever sent to the R2 host.TaskFailedError(with.code,.refunded),TaskTimeoutError(resumable),RequestTimeoutError.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"}(see the backend'sapp/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:
fetchhas no default timeout, so a stalled connection hungwait()/generate()forever. Added a configurable 600s request timeout (SoniloClientOptions.timeout) viaAbortSignal, surfaced as a typedRequestTimeoutErrorrather than a rawDOMException. Streaming music generation deliberately opts out — an absolute signal would kill a healthy long-running stream — andTextToMusicParams/VideoToMusicParamsnow accept asignalso callers can cancel one themselves (the README previously pointed at two APIs that didn't exist).audio_chunkwas silently dropped as an unknown event, sogenerate()returned a "successful" Track with empty/truncated audio and no error. Now throwsGenerationError.nullNDJSON line crashed with an untypedTypeError.download()threw a rawTypeErrorwhen handed the media of a task that hadn't finished.wait()used wall-clockDate.now()for its deadline (an NTP correction or laptop sleep could fire a spurious timeout), slept past its own deadline, and busy-looped against the API on a negativepollInterval(169 requests in 200ms). All fixed.Verification
npm test: 99 passing.tsc --noEmitandtsupclean.End-to-end against production: text-to-sfx and video-to-sfx (multipart upload, including a real 30s video) both generate, poll, download and save real audio/video — downloaded bytes match the reported
file_size, WAV/MP4 headers validate, and the muxed video carries the generated audio track. Error mapping, validation, 404s and path-injection guards verified live, and behaviour matches the Python SDK case-for-case.Version bumped 0.1.0 → 0.2.0.