From 8733b221370fef754bff71b8f16c90e33e6bfa02 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Wed, 5 Aug 2026 14:32:52 -0700 Subject: [PATCH] feat: follow the API's ducking flip to default-off (0.11.3 / cli 0.9.0) The server now treats an unset `ducking` as off on /v1/video-to-music, /v1/video-to-sound, /v1/video-to-video-music and /v1/video-to-video-sound. Both packages already omitted the field when unset, so nothing changes on the wire -- but every comment and doc that explained the old default was now wrong, and the CLI could only express the direction that had become the default. sonilo (0.11.2 -> 0.11.3, docs only): * The request builders no longer justify omit-when-unset by naming which way each default points -- that reasoning is what went stale. The rule is that the server decides, and neither `ducking` nor `keep_original_sound` is pinned here. * build_v2s_parts' docstring now states what is specific to /v1/video-to-sound: with no keep_original_sound field there, `ducking` picks the voice source as well as the mix, so leaving it unset keeps the source's own speech out of the deliverable entirely. * dubbing's docstring no longer calls its default "the opposite of the music endpoints'" -- every endpoint now defaults off. * README: the video_to_music ducking bullet and example, the video_to_video_music example, the sound-endpoint paragraph, and the dubbing paragraph. sonilo-cli (0.8.2 -> 0.9.0): * --ducking added to video-to-sound, video-to-video-sound and video-to-video-music. Without it the CLI cannot request ducking at all now. * --no-ducking is kept: dropping it would turn every script that passes it into a hard argparse failure, and it now sends the explicit False the server would have applied anyway. Passing both exits rather than silently picking a winner. Version drift fixed while bumping, pre-existing and unrelated to ducking: `_version.py` was still 0.11.0 against pyproject's 0.11.2, and `sonilo_cli/__init__.py` still 0.8.0 against 0.8.2. Those are the values sent as `x-sonilo-client-version`, so 0.11.1, 0.11.2, 0.8.1 and 0.8.2 all reported themselves as the older release. All four strings now agree. The existing test only compares the header against `_version.py`, so it could not catch the drift. sonilo-cli's `sonilo>=0.11.0,<0.12` pin still holds -- the core stays on 0.11.x, so no widening is needed this round. 237 core tests and 88 CLI tests pass, run per package as CI does. (Running both directories in one pytest invocation fails at collection because the two packages both have a top-level `tests` package; that predates this change and reproduces on a pristine main.) --- README.md | 27 +++++++----- pyproject.toml | 2 +- sonilo-cli/README.md | 6 ++- sonilo-cli/pyproject.toml | 2 +- sonilo-cli/src/sonilo_cli/__init__.py | 2 +- sonilo-cli/src/sonilo_cli/__main__.py | 61 +++++++++++++++++++++------ sonilo-cli/tests/test_cli.py | 28 +++++++++--- src/sonilo/_requests.py | 24 ++++++----- src/sonilo/_version.py | 2 +- src/sonilo/resources/dubbing.py | 4 +- 10 files changed, 109 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index ce3ecfe..62aa619 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,10 @@ locally before any request is sent. - `preserve_speech` — keep the source speech/vocals in the result (see [Preserve speech](#preserve-speech-async) above). -- `ducking` — duck the generated music under the source voice. It is **on by - default** in async mode; pass `ducking=False` to opt out. When it runs, the - result gains a `ducked` list alongside `audio`. +- `ducking` — duck the generated music under the source voice. It is **off by + default**; pass `ducking=True` to run it. When it runs, the result gains a + `ducked` list alongside `audio` — the `audio` track itself is the same + either way. - `output_format` — `"m4a"` (default), `"wav"`, or `"mp3"` (320 kbps). Anything but `m4a` is a finalize-time transcode and requires async mode. @@ -112,7 +113,7 @@ result = client.video_to_music.generate_async( video="my_video.mp4", preserve_speech=True, output_format="wav", - # ducking defaults on in async — pass ducking=False to disable + ducking=True, # off by default — opt in to also get the `ducked` track ) result.save("track.wav") if result.ducked: @@ -157,8 +158,8 @@ submits and polls to a `VideoResult`: # By default the returned video's audio is the generated music ALONE — the # source's own audio is removed. keep_original_sound=True keeps the whole # source track with the music under it; preserve_speech=True keeps only the -# isolated speech. Add ducking=False to either for a static mix instead of a -# dynamic duck. +# isolated speech. Either way the music is mixed in at a static level — add +# ducking=True to dip it under the voice instead. music = client.video_to_video_music.generate( video="my_video.mp4", # path, bytes, open file, or use video_url= prompt="cinematic orchestral swell", @@ -236,9 +237,13 @@ source track, or `preserve_speech=True` to keep only the isolated speech. Both default to off, so `video_to_video_sound` by default returns the generated music and effects **alone** — and with no voice source there is no processed track, so the default result carries no `music_processed` stem. -**`ducking`** (on by default) picks how that voice and the generated bed are -combined: leave it for the dynamic duck, or pass `ducking=False` for a static -voice-forward mix. It has no effect when neither voice flag is set. +**`ducking`** (off by default) picks how that voice and the generated bed are +combined: leave it for a static voice-forward mix, or pass `ducking=True` for +the dynamic duck. On `video_to_video_sound` it has no effect when neither voice +flag is set. On `video_to_sound` it decides more than the mix: that endpoint +has no `keep_original_sound`, so `ducking=True` is what pulls the source's own +track into the result at all — leave it off and the result is the generated +music and effects alone, with no `music_processed` stem. `keep_original_sound` supersedes `preserve_speech`, and is accepted only by `video_to_video_sound` — `video_to_sound` returns generated audio, so there is no source picture whose audio could be preserved. @@ -281,8 +286,8 @@ async call. Pass exactly one of `video` / `video_url` (`video_url` must be `["zh_cn", "es", "fr"]`; supported codes are `en, zh_cn, ja, ko, pt, es, de, 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 +the bed is kept at a constant level. (Every endpoint's `ducking` defaults off, +so this one is no exception.) 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). diff --git a/pyproject.toml b/pyproject.toml index 2b6d57e..010fefd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.11.2" +version = "0.11.3" description = "Official Python client for the Sonilo API" readme = "README.md" license = "MIT" diff --git a/sonilo-cli/README.md b/sonilo-cli/README.md index 5494a15..15fa5f5 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -130,8 +130,10 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio** - `--segments` places individual effects on the timeline, in the SFX shape `{start, end, prompt}` — see [Segments](#segments). - `--preserve-speech` keeps speech from the source video in the mix. -- **Ducking is on by default** (music dips under speech). Pass `--no-ducking` to opt out — omitting - the flag leaves the server default untouched. +- **Ducking is off by default.** Pass `--ducking` to bring the source video's own speech into the + mix with the generated bed dipped under it — without it the result is the generated music and + effects alone. Omitting both flags leaves the server default untouched; `--no-ducking` still + works and now just states that default explicitly. - `--stem` is repeatable (`music`, `music_processed`, `sfx`) and saves the individual layers next to the combined output, so you can re-balance the mix yourself. With `--output soundtrack.wav`, the music stem lands at `soundtrack.music.m4a`. `music_processed` exists only when `--preserve-speech` diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index 29d4d70..6b0a822 100644 --- a/sonilo-cli/pyproject.toml +++ b/sonilo-cli/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo-cli" -version = "0.8.2" +version = "0.9.0" description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video" readme = "README.md" license = "MIT" diff --git a/sonilo-cli/src/sonilo_cli/__init__.py b/sonilo-cli/src/sonilo_cli/__init__.py index 32480aa..465a999 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.8.0" +__version__ = "0.9.0" __all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index e9efbed..b66355a 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -167,6 +167,30 @@ def parse_segments( return value +def _ducking(args: argparse.Namespace) -> Optional[bool]: + """Resolve --ducking / --no-ducking, or None to let the server default win. + + `ducking` used to be default-ON server-side, so the only direction worth + expressing was turning it off and --no-ducking was the only flag. It is now + default-OFF, which makes --ducking the useful one. --no-ducking is kept + because dropping it would turn every script that passes it into a hard + argparse error; it now sends the explicit False the server would have + applied anyway. + + Passing both is a contradiction with no sensible winner, so it exits rather + than silently picking one. + """ + on = getattr(args, "ducking", False) + off = getattr(args, "no_ducking", False) + if on and off: + raise SystemExit("pass at most one of --ducking or --no-ducking") + if on: + return True + if off: + return False + return None + + def _segments(args: argparse.Namespace) -> Optional[List[Dict[str, Any]]]: """parse_segments() for whichever subcommand is running.""" return parse_segments(args.segments, args.segments_shape, args.command) @@ -345,7 +369,7 @@ def _run_sound(client: Sonilo, args: argparse.Namespace, resource: Any, default_ sfx_prompt=args.sfx_prompt, segments=_segments(args), preserve_speech=True if args.preserve_speech else None, - ducking=False if args.no_ducking else None, + ducking=_ducking(args), variants_num=args.variants, **extra, ) @@ -403,12 +427,11 @@ def cmd_video_to_video_music(client: Sonilo, args: argparse.Namespace) -> None: client.video_to_video_music, prompt=args.prompt, # Unset flags forward None, not False, so the server default stands — - # same reasoning as --no-ducking on the sound commands. The two - # defaults run opposite ways: ducking is default-ON server-side and - # keep_original_sound default-OFF, so each is only ever sent to change - # the default, never to restate it. + # same reasoning as --ducking on the sound commands. Both ducking and + # keep_original_sound are default-OFF server-side, so each is only ever + # sent to change the default, never to restate it. keep_original_sound=True if args.keep_original_sound else None, - ducking=False if args.no_ducking else None, + ducking=_ducking(args), preserve_speech=True if args.preserve_speech else None, isolate_vocals=True if args.isolate_vocals else None, variants_num=args.variants, @@ -611,8 +634,14 @@ def build_parser() -> argparse.ArgumentParser: _add_segments(p_v2sd, SFX_SHAPE) p_v2sd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true", help="Keep source speech in the mix.") + p_v2sd.add_argument("--ducking", dest="ducking", action="store_true", + help="Bring the source video's own speech into the mix and duck " + "the generated bed under it. Off by default, so by default " + "the result is the generated music and effects alone and " + "there is no music_processed stem.") p_v2sd.add_argument("--no-ducking", dest="no_ducking", action="store_true", - help="Disable automatic ducking (on by default server-side).") + help="Explicit opt-out. Same as the default; kept so existing " + "scripts keep working.") p_v2sd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS, default=None, help="Also save an individual stem. Repeatable.") p_v2sd.add_argument("--output", default=None, help="Where to save the combined audio.") @@ -631,10 +660,13 @@ def build_parser() -> argparse.ArgumentParser: "generated music under it. Off by default, so by default " "the result's audio is the generated music alone. " "Supersedes --preserve-speech.") + p_v2vm.add_argument("--ducking", dest="ducking", action="store_true", + help="Duck the music under the voice instead of mixing it in at " + "a static level. No effect without --keep-original-sound " + "or --preserve-speech.") p_v2vm.add_argument("--no-ducking", dest="no_ducking", action="store_true", - help="Combine the voice and the music as a static mix instead " - "of a dynamic duck. No effect without " - "--keep-original-sound or --preserve-speech.") + help="Explicit opt-out. Same as the default; kept so existing " + "scripts keep working.") p_v2vm.add_argument("--preserve-speech", dest="preserve_speech", action="store_true", help="Keep only the source's isolated speech in the mix.") # Same aliasing as video-to-music, and here the endpoint collapses the two @@ -676,10 +708,13 @@ def build_parser() -> argparse.ArgumentParser: "--preserve-speech. This command only.") p_v2vsd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true", help="Keep only the source's isolated speech in the mix.") - p_v2vsd.add_argument("--no-ducking", dest="no_ducking", action="store_true", - help="Combine the voice and the generated bed as a static mix " - "instead of a dynamic duck. No effect without " + p_v2vsd.add_argument("--ducking", dest="ducking", action="store_true", + help="Duck the generated bed under the voice instead of mixing " + "it in at a static level. No effect without " "--keep-original-sound or --preserve-speech.") + p_v2vsd.add_argument("--no-ducking", dest="no_ducking", action="store_true", + help="Explicit opt-out. Same as the default; kept so existing " + "scripts keep working.") p_v2vsd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS, default=None, help="Also save an individual stem. Repeatable.") p_v2vsd.add_argument("--output", default=None, help="Where to save the combined video.") diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index 942fe29..7758e3d 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -369,15 +369,25 @@ def test_video_to_sound_ducking_absent_omits_field(tmp_path): ) run(["video-to-sound", "--video-url", "http://x/y.mp4", "--output", str(tmp_path / "s.wav")]) - # ducking is default-ON server-side: an unset --no-ducking must forward - # `None`, not `False`, so the field must be entirely absent from the - # form-encoded body (per build_v2s_parts). + # ducking is default-OFF server-side, and neither flag was passed, so the + # CLI must forward `None`, not `False` — the field has to be absent from + # the form-encoded body (per build_v2s_parts) and let the server decide. body = route.calls.last.request.content.decode() assert "ducking=" not in body @respx.mock -def test_video_to_sound_no_ducking_sets_false(tmp_path): +@pytest.mark.parametrize( + "flag, wire", + [ + # --ducking is the direction that does something now that the server + # default is off; --no-ducking predates the flip, still parses so + # existing scripts do not break, and states the default explicitly. + ("--ducking", "ducking=true"), + ("--no-ducking", "ducking=false"), + ], +) +def test_video_to_sound_ducking_flags(tmp_path, flag, wire): route = respx.post(f"{BASE}/v1/video-to-sound").mock( return_value=httpx.Response(200, json={"task_id": "sd4", "status": "processing"}) ) @@ -388,9 +398,15 @@ def test_video_to_sound_no_ducking_sets_false(tmp_path): return_value=httpx.Response(200, content=b"MIXED") ) run(["video-to-sound", "--video-url", "http://x/y.mp4", - "--output", str(tmp_path / "s.wav"), "--no-ducking"]) + "--output", str(tmp_path / "s.wav"), flag]) body = route.calls.last.request.content.decode() - assert "ducking=false" in body + assert wire in body + + +def test_video_to_sound_rejects_both_ducking_flags(tmp_path): + with pytest.raises(SystemExit): + run(["video-to-sound", "--video-url", "http://x/y.mp4", + "--output", str(tmp_path / "s.wav"), "--ducking", "--no-ducking"]) @respx.mock diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index b639298..34eec9a 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -119,8 +119,8 @@ 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. + # Default-OFF server-side, like every other endpoint's ducking: omitted + # when unset so the server default applies. if ducking is not None: data["ducking"] = "true" if ducking else "false" @@ -215,11 +215,10 @@ def build_v2v_music_parts( # mode to fall back to), so variants_num travels straight through with no # mode guard — unlike text-to-music/video-to-music. data, files, opened = build_v2m_parts(video, video_url, prompt, segments) - # Every boolean is emitted only when explicitly passed, so each server - # default stands on its own — and they point in opposite directions: - # `ducking` is default-ON so an unset value must not go out as "false", - # while `keep_original_sound` is default-OFF so an unset value must not go - # out as "true". + # Every boolean is emitted only when explicitly passed, so the server's own + # default stands. `ducking` and `keep_original_sound` are both default-OFF + # today, but neither is pinned here — hardcoding either is what would have + # to change the next time a server default moves. if keep_original_sound is not None: data["keep_original_sound"] = "true" if keep_original_sound else "false" if ducking is not None: @@ -270,10 +269,13 @@ def build_v2s_parts( These endpoints take `music_prompt`/`sfx_prompt` instead of a single `prompt`, so build_v2m_parts is called with prompt=None. Booleans are only - emitted when explicitly passed, and the two defaults run opposite ways: - `ducking` is default-ON server-side so an unset value must not go out as - "false", while `keep_original_sound` is default-OFF so an unset value must - not go out as "true". Both endpoints are async-only (202 + poll), so — like + emitted when explicitly passed, so the server's own default stands; + `ducking` and `keep_original_sound` are both default-OFF today and neither + is pinned here. Note `ducking` means more on /v1/video-to-sound than on the + video endpoint: with no `keep_original_sound` field there, it picks the + voice source as well as the mix, so leaving it unset keeps the source's own + speech out of the deliverable entirely. Both endpoints are async-only (202 + + poll), so — like video-to-video-music — variants_num travels straight through with no mode guard. """ diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index ae6db5f..1bebb74 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.11.0" +__version__ = "0.11.3" diff --git a/src/sonilo/resources/dubbing.py b/src/sonilo/resources/dubbing.py index d687f94..d5b5adf 100644 --- a/src/sonilo/resources/dubbing.py +++ b/src/sonilo/resources/dubbing.py @@ -24,8 +24,8 @@ class Dubbing: `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.""" + constant level. Every endpoint's `ducking` defaults off, so this one is + no exception.""" def __init__(self, client: "Sonilo") -> None: self._client = client