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