Skip to content

feat: textToSfx, videoToSfx and task polling (v0.2.0) - #4

Merged
spencer-zqian merged 15 commits into
mainfrom
feat/sfx
Jul 12, 2026
Merged

feat: textToSfx, videoToSfx and task polling (v0.2.0)#4
spencer-zqian merged 15 commits into
mainfrom
feat/sfx

Conversation

@spencer-zqian

@spencer-zqian spencer-zqian commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

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:

import { SoniloClient, download } from "sonilo";

const client = new SoniloClient();
const result = await client.textToSfx.generate({ prompt: "glass shattering", duration: 5 });
await writeFile("sfx.m4a", await download(result.audio));
const task = await client.videoToSfx.submit({
  video: "clip.mp4",                        // or videoUrl: "..."
  segments: [{ start: 0, end: 2.5, prompt: "footsteps on gravel" }],
  audioFormat: "wav",
});
const result = await client.tasks.wait(task.task_id);   // or tasks.get() for a single poll
  • textToSfx / videoToSfx: submit() (returns SfxTask) and generate() (= submit + wait).
  • tasks: get() (single fetch, never throws on a failed task) and wait() (polls to a terminal state).
  • download(media) fetches the presigned R2 URL; no API key is ever sent to the R2 host.
  • New errors: TaskFailedError (with .code, .refunded), TaskTimeoutError (resumable), RequestTimeoutError.
  • Params are camelCase; response fields stay snake_case, matching the existing convention.

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's app/errors.py), not FastAPI's {"detail"} — so every API error degraded to HTTP 400: Bad Request and the real cause was discarded. This affected the shipped music endpoints too. APIError now exposes .code and .errors (422 validation details), and BadRequestError.detail works again.

Also fixed, all with regression tests:

  • No timeout anywhere: fetch has no default timeout, so a stalled connection hung wait()/generate() forever. Added a configurable 600s request timeout (SoniloClientOptions.timeout) via AbortSignal, surfaced as a typed RequestTimeoutError rather than a raw DOMException. Streaming music generation deliberately opts out — an absolute signal would kill a healthy long-running stream — and TextToMusicParams/VideoToMusicParams now accept a signal so callers can cancel one themselves (the README previously pointed at two APIs that didn't exist).
  • A malformed audio_chunk was silently dropped as an unknown event, so generate() returned a "successful" Track with empty/truncated audio and no error. Now throws GenerationError.
  • A bare null NDJSON line crashed with an untyped TypeError.
  • download() threw a raw TypeError when handed the media of a task that hadn't finished.
  • wait() used wall-clock Date.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 negative pollInterval (169 requests in 200ms). All fixed.

Verification

npm test: 99 passing. tsc --noEmit and tsup clean.

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.

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.
@spencer-zqian
spencer-zqian merged commit 712df35 into main Jul 12, 2026
1 check passed
@spencer-zqian
spencer-zqian deleted the feat/sfx branch July 12, 2026 18:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant