From d483100edbc629843853068b60da7935d669e1f2 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Fri, 31 Jul 2026 12:14:39 -0700 Subject: [PATCH 1/2] feat: add optional ducking param to dubbing (default off) --- README.md | 10 +++++++--- src/sonilo/_requests.py | 5 +++++ src/sonilo/resources/dubbing.py | 21 ++++++++++++++++----- tests/test_dubbing.py | 16 ++++++++++++++++ 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9729e7b..7d036a6 100644 --- a/README.md +++ b/README.md @@ -252,9 +252,13 @@ poll it yourself with `client.tasks.wait(task_id, parser=parse_sound_result)`. async call. Pass exactly one of `video` / `video_url` (`video_url` must be **https**), plus optional `languages` — it defaults server-side to `["zh_cn", "es", "fr"]`; supported codes are `en, zh_cn, ja, ko, pt, es, de, -fr, it, ru`. Source videos may be at most 180 seconds long, and billing is -per language: a 3-language call costs three times as much as one. Dubbing has -no free trial allowance — see [Free trial](#free-trial). +fr, it, ru`. The optional `ducking` boolean (default off, free) ducks the +background music/effects bed under the dubbed voice while it speaks; when off +the bed is kept at a constant level. (Note this default is the opposite of +the music endpoints' `ducking`, which is on by default.) Source videos may be +at most 180 seconds long, and billing is per language: a 3-language call +costs three times as much as one. Dubbing has no free trial allowance — see +[Free trial](#free-trial). The SDK's default wait is `DEFAULT_WAIT_TIMEOUT` (600 seconds), but the dubbing pipeline can take much longer than that — especially with several diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index 525d668..f1b83e4 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -90,6 +90,7 @@ def build_dubbing_parts( video: Any, video_url: Optional[str], languages: Optional[List[str]], + ducking: Optional[bool] = None, ) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: """Build the multipart parts for POST /v1/dubbing. @@ -118,6 +119,10 @@ def build_dubbing_parts( data["video_url"] = video_url if languages is not None: data["languages"] = json.dumps(languages) + # Default-OFF server-side (the opposite of the music endpoints' ducking): + # omitted when unset so the server default applies. + if ducking is not None: + data["ducking"] = "true" if ducking else "false" # Now open files (only after data is fully assembled) files: Optional[Dict[str, tuple]] = None diff --git a/src/sonilo/resources/dubbing.py b/src/sonilo/resources/dubbing.py index d15076e..d687f94 100644 --- a/src/sonilo/resources/dubbing.py +++ b/src/sonilo/resources/dubbing.py @@ -20,7 +20,12 @@ class Dubbing: """Dub one video into several target languages. Async only; the result - carries a language → dubbed-video-URL map under `outputs`.""" + carries a language → dubbed-video-URL map under `outputs`. + + `ducking` (default off, free) ducks the background music/effects bed + under the dubbed voice while it speaks; when off the bed is kept at a + constant level. The default is the opposite of the music endpoints' + ducking, which is on by default.""" def __init__(self, client: "Sonilo") -> None: self._client = client @@ -31,8 +36,9 @@ def submit( video: Any = None, video_url: Optional[str] = None, languages: Optional[List[str]] = None, + ducking: Optional[bool] = None, ) -> SfxTask: - data, files, opened = build_dubbing_parts(video, video_url, languages) + data, files, opened = build_dubbing_parts(video, video_url, languages, ducking) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( self._client._post_json(PATH, data=data, files=files, close_after=close_after) @@ -44,10 +50,13 @@ def generate( video: Any = None, video_url: Optional[str] = None, languages: Optional[List[str]] = None, + ducking: Optional[bool] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> DubbingResult: - task = self.submit(video=video, video_url=video_url, languages=languages) + task = self.submit( + video=video, video_url=video_url, languages=languages, ducking=ducking + ) return self._client.tasks.wait( task.task_id, poll_interval=poll_interval, @@ -66,8 +75,9 @@ async def submit( video: Any = None, video_url: Optional[str] = None, languages: Optional[List[str]] = None, + ducking: Optional[bool] = None, ) -> SfxTask: - data, files, opened = build_dubbing_parts(video, video_url, languages) + data, files, opened = build_dubbing_parts(video, video_url, languages, ducking) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( await self._client._post_json( @@ -81,11 +91,12 @@ async def generate( video: Any = None, video_url: Optional[str] = None, languages: Optional[List[str]] = None, + ducking: Optional[bool] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> DubbingResult: task = await self.submit( - video=video, video_url=video_url, languages=languages + video=video, video_url=video_url, languages=languages, ducking=ducking ) return await self._client.tasks.wait( task.task_id, diff --git a/tests/test_dubbing.py b/tests/test_dubbing.py index b7f52ce..ddd5706 100644 --- a/tests/test_dubbing.py +++ b/tests/test_dubbing.py @@ -103,6 +103,22 @@ def test_submit_posts_to_v1_dubbing(): assert '["es", "fr"]' in sent +@respx.mock +def test_submit_sends_ducking_only_when_set(): + route = respx.post("https://api.sonilo.com/v1/dubbing").mock( + return_value=httpx.Response(202, json=ACK) + ) + with Sonilo(api_key="sk-test") as client: + client.dubbing.submit(video_url="https://x/v.mp4", ducking=True) + client.dubbing.submit(video_url="https://x/v.mp4", ducking=False) + client.dubbing.submit(video_url="https://x/v.mp4") + bodies = [unquote_plus(c.request.content.decode()) for c in route.calls] + assert "ducking=true" in bodies[0] + assert "ducking=false" in bodies[1] + # Unset → omitted entirely so the server default (off) applies. + assert "ducking" not in bodies[2] + + @respx.mock def test_generate_polls_to_a_dubbing_result(): respx.post("https://api.sonilo.com/v1/dubbing").mock( From a5181eb10da06e38fdc84b407f4e7190176cd6e9 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Fri, 31 Jul 2026 12:15:03 -0700 Subject: [PATCH 2/2] chore: bump sonilo to 0.9.0 --- pyproject.toml | 2 +- src/sonilo/_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4ae45ba..16b884e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.8.0" +version = "0.9.0" description = "Official Python client for the Sonilo API" readme = "README.md" license = "MIT" diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index 777f190..3e2f46a 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.8.0" +__version__ = "0.9.0"