Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 4 additions & 2 deletions sonilo-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion sonilo-cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion sonilo-cli/src/sonilo_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__version__ = "0.8.0"
__version__ = "0.9.0"

__all__ = ["__version__"]
61 changes: 48 additions & 13 deletions sonilo-cli/src/sonilo_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.")
Expand All @@ -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
Expand Down Expand Up @@ -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.")
Expand Down
28 changes: 22 additions & 6 deletions sonilo-cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
)
Expand All @@ -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
Expand Down
24 changes: 13 additions & 11 deletions src/sonilo/_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
"""
Expand Down
2 changes: 1 addition & 1 deletion src/sonilo/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.11.0"
__version__ = "0.11.3"
4 changes: 2 additions & 2 deletions src/sonilo/resources/dubbing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading