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
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,15 @@ audio muxed in — not just an audio file. Both endpoints are async; `generate()
submits and polls to a `VideoResult`:

```python
# By default the returned video keeps the source's speech with the music
# ducked under it — pass ducking=False for music-only audio.
# 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.
music = client.video_to_video_music.generate(
video="my_video.mp4", # path, bytes, open file, or use video_url=
prompt="cinematic orchestral swell",
preserve_speech=True,
keep_original_sound=True,
# segments=[{"start": 0, "prompt": "sparse pads"},
# {"start": 30, "prompt": "add drums"}],
)
Expand Down Expand Up @@ -227,8 +230,19 @@ result.save_stem("music.m4a", which="music")
result.save_stem("sfx.wav", which="sfx")
```

`preserve_speech=True` keeps the speech from the source video, and `ducking`
(on by default) dips the music under it — pass `ducking=False` to opt out.
Two independent knobs decide what happens to the source's own audio.
**`keep_original_sound`** picks the voice source: pass `True` to keep the whole
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.
`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.

`segments` takes the same `{"start", "end", "prompt"}` list as `video_to_sfx`.
Input videos may be at most 180 seconds long.

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.10.0"
version = "0.11.0"
description = "Official Python client for the Sonilo API"
readme = "README.md"
license = "MIT"
Expand Down
4 changes: 2 additions & 2 deletions sonilo-cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ build-backend = "hatchling.build"

[project]
name = "sonilo-cli"
version = "0.7.0"
version = "0.8.0"
description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video"
readme = "README.md"
license = "MIT"
requires-python = ">=3.9"
authors = [{ name = "Sonilo AI" }]
dependencies = ["sonilo>=0.10.0,<0.11"]
dependencies = ["sonilo>=0.11.0,<0.12"]
keywords = ["sonilo", "cli", "music", "sfx", "text-to-music", "video-to-music", "ai"]

[project.urls]
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.7.0"
__version__ = "0.8.0"

__all__ = ["__version__"]
40 changes: 36 additions & 4 deletions sonilo-cli/src/sonilo_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,13 @@ def _stem_path(out: str, stem: str, media: Any) -> str:

def _run_sound(client: Sonilo, args: argparse.Namespace, resource: Any, default_ext: str) -> None:
out = args.output if args.output is not None else f"output.{default_ext}"
# video-to-video-sound only. Its parser is the only one that defines the
# flag, and video_to_sound.generate() does not accept the keyword at all,
# so it has to be omitted here rather than forwarded as None — the same
# split the SDK enforces by never passing it from the audio resource.
extra: Dict[str, Any] = {}
if getattr(args, "keep_original_sound", False):
extra["keep_original_sound"] = True
result = resource.generate(
video=args.video,
video_url=args.video_url,
Expand All @@ -340,6 +347,7 @@ def _run_sound(client: Sonilo, args: argparse.Namespace, resource: Any, default_
preserve_speech=True if args.preserve_speech else None,
ducking=False if args.no_ducking else None,
variants_num=args.variants,
**extra,
)
multi = args.variants is not None and args.variants > 1 and len(result.outputs) > 1
if not multi:
Expand Down Expand Up @@ -395,7 +403,12 @@ 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.
# 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.
keep_original_sound=True if args.keep_original_sound else None,
ducking=False if args.no_ducking else None,
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 @@ -612,8 +625,18 @@ def build_parser() -> argparse.ArgumentParser:
_add_global(p_v2vm)
_add_video_source(p_v2vm)
p_v2vm.add_argument("--prompt", default=None, help="Optional creative direction.")
p_v2vm.add_argument("--keep-original-sound", dest="keep_original_sound",
action="store_true",
help="Keep the source video's whole original audio, with the "
"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("--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.")
p_v2vm.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
help="Keep source speech in the mix.")
help="Keep only the source's isolated speech in the mix.")
# Same aliasing as video-to-music, and here the endpoint collapses the two
# into a single boolean before it reaches the model (video_to_video.py:
# `keep_speech = bool(preserve_speech) or bool(isolate_vocals)`), with no
Expand Down Expand Up @@ -644,10 +667,19 @@ def build_parser() -> argparse.ArgumentParser:
p_v2vsd.add_argument("--sfx-prompt", dest="sfx_prompt", default=None,
help="Optional creative direction for the sound effects.")
_add_segments(p_v2vsd, SFX_SHAPE)
p_v2vsd.add_argument("--keep-original-sound", dest="keep_original_sound",
action="store_true",
help="Keep the source video's whole original audio, with the "
"generated music and effects under it. Off by default, so "
"by default the result's audio is the generated audio "
"alone and there is no music_processed stem. Supersedes "
"--preserve-speech. This command only.")
p_v2vsd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
help="Keep source speech in the mix.")
help="Keep only the source's isolated speech in the mix.")
p_v2vsd.add_argument("--no-ducking", dest="no_ducking", action="store_true",
help="Disable automatic ducking (on by default server-side).")
help="Combine the voice and the generated bed as a static mix "
"instead of a dynamic duck. No effect without "
"--keep-original-sound or --preserve-speech.")
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
36 changes: 26 additions & 10 deletions src/sonilo/_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,19 @@ def build_v2v_music_parts(
variants_num: Optional[int] = None,
segments: Optional[List[Segment]] = None,
ducking: Optional[bool] = None,
keep_original_sound: Optional[bool] = None,
) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]:
# video-to-video-music is 202/async-only by design (there is no streaming
# 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)
# Only emitted when explicitly passed: `ducking` is default-ON
# server-side, so an unset value must not go out as "false".
# 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".
if keep_original_sound is not None:
data["keep_original_sound"] = "true" if keep_original_sound else "false"
if ducking is not None:
data["ducking"] = "true" if ducking else "false"
if preserve_speech is not None:
Expand Down Expand Up @@ -247,27 +253,37 @@ def build_v2s_parts(
ducking: Optional[bool],
variants_num: Optional[int] = None,
output_format: Optional[str] = None,
keep_original_sound: Optional[bool] = None,
) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]:
"""Multipart parts shared by /v1/video-to-sound and
/v1/video-to-video-sound.

`output_format` is the one field they do not share: only the audio
endpoint accepts it, since video-to-video-sound always muxes the mix into
an mp4. It is keyword-only with a None default here, and the
VideoToVideoSound resource simply never passes it.
Two fields they do NOT share, one in each direction. Both are keyword-only
with a None default here, and the resource that must not send one simply
never passes it:

* `output_format` is audio-only — video-to-video-sound always muxes the mix
into an mp4 — so VideoToVideoSound never passes it.
* `keep_original_sound` is video-only — it only means something when the
deliverable is a video whose own audio could be preserved — so
VideoToSound never passes it.

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: `ducking` is default-ON server-side, so an
unset value must not go out as "false". Both endpoints are async-only
(202 + poll), so — like video-to-video-music — variants_num travels
straight through with no mode guard.
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
video-to-video-music — variants_num travels straight through with no mode
guard.
"""
data, files, opened = build_v2m_parts(video, video_url, None, segments)
if music_prompt is not None:
data["music_prompt"] = music_prompt
if sfx_prompt is not None:
data["sfx_prompt"] = sfx_prompt
if keep_original_sound is not None:
data["keep_original_sound"] = "true" if keep_original_sound else "false"
if preserve_speech is not None:
data["preserve_speech"] = "true" if preserve_speech else "false"
if ducking is not None:
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.10.0"
__version__ = "0.11.0"
8 changes: 8 additions & 0 deletions src/sonilo/resources/video_to_video_music.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def submit(
video_url: Optional[str] = None,
prompt: Optional[str] = None,
segments: Optional[List[Segment]] = None,
keep_original_sound: Optional[bool] = None,
ducking: Optional[bool] = None,
preserve_speech: Optional[bool] = None,
isolate_vocals: Optional[bool] = None,
Expand All @@ -49,6 +50,7 @@ def submit(
variants_num,
segments=segments,
ducking=ducking,
keep_original_sound=keep_original_sound,
)
close_after = files["video"][1] if files is not None and opened else None
return parse_sfx_task(
Expand All @@ -62,6 +64,7 @@ def generate(
video_url: Optional[str] = None,
prompt: Optional[str] = None,
segments: Optional[List[Segment]] = None,
keep_original_sound: Optional[bool] = None,
ducking: Optional[bool] = None,
preserve_speech: Optional[bool] = None,
isolate_vocals: Optional[bool] = None,
Expand All @@ -75,6 +78,7 @@ def generate(
prompt=prompt,
segments=segments,
ducking=ducking,
keep_original_sound=keep_original_sound,
preserve_speech=preserve_speech,
isolate_vocals=isolate_vocals,
variants_num=variants_num,
Expand All @@ -98,6 +102,7 @@ async def submit(
video_url: Optional[str] = None,
prompt: Optional[str] = None,
segments: Optional[List[Segment]] = None,
keep_original_sound: Optional[bool] = None,
ducking: Optional[bool] = None,
preserve_speech: Optional[bool] = None,
isolate_vocals: Optional[bool] = None,
Expand All @@ -112,6 +117,7 @@ async def submit(
variants_num,
segments=segments,
ducking=ducking,
keep_original_sound=keep_original_sound,
)
close_after = files["video"][1] if files is not None and opened else None
return parse_sfx_task(
Expand All @@ -127,6 +133,7 @@ async def generate(
video_url: Optional[str] = None,
prompt: Optional[str] = None,
segments: Optional[List[Segment]] = None,
keep_original_sound: Optional[bool] = None,
ducking: Optional[bool] = None,
preserve_speech: Optional[bool] = None,
isolate_vocals: Optional[bool] = None,
Expand All @@ -140,6 +147,7 @@ async def generate(
prompt=prompt,
segments=segments,
ducking=ducking,
keep_original_sound=keep_original_sound,
preserve_speech=preserve_speech,
isolate_vocals=isolate_vocals,
variants_num=variants_num,
Expand Down
8 changes: 8 additions & 0 deletions src/sonilo/resources/video_to_video_sound.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def submit(
music_prompt: Optional[str] = None,
sfx_prompt: Optional[str] = None,
segments: Optional[List[SfxSegment]] = None,
keep_original_sound: Optional[bool] = None,
preserve_speech: Optional[bool] = None,
ducking: Optional[bool] = None,
variants_num: Optional[int] = None,
Expand All @@ -44,6 +45,7 @@ def submit(
data, files, opened = build_v2s_parts(
video, video_url, music_prompt, sfx_prompt, segments,
preserve_speech, ducking, variants_num,
keep_original_sound=keep_original_sound,
)
close_after = files["video"][1] if files is not None and opened else None
return parse_sfx_task(
Expand All @@ -58,6 +60,7 @@ def generate(
music_prompt: Optional[str] = None,
sfx_prompt: Optional[str] = None,
segments: Optional[List[SfxSegment]] = None,
keep_original_sound: Optional[bool] = None,
preserve_speech: Optional[bool] = None,
ducking: Optional[bool] = None,
variants_num: Optional[int] = None,
Expand All @@ -70,6 +73,7 @@ def generate(
music_prompt=music_prompt,
sfx_prompt=sfx_prompt,
segments=segments,
keep_original_sound=keep_original_sound,
preserve_speech=preserve_speech,
ducking=ducking,
variants_num=variants_num,
Expand All @@ -94,13 +98,15 @@ async def submit(
music_prompt: Optional[str] = None,
sfx_prompt: Optional[str] = None,
segments: Optional[List[SfxSegment]] = None,
keep_original_sound: Optional[bool] = None,
preserve_speech: Optional[bool] = None,
ducking: Optional[bool] = None,
variants_num: Optional[int] = None,
) -> SfxTask:
data, files, opened = build_v2s_parts(
video, video_url, music_prompt, sfx_prompt, segments,
preserve_speech, ducking, variants_num,
keep_original_sound=keep_original_sound,
)
close_after = files["video"][1] if files is not None and opened else None
return parse_sfx_task(
Expand All @@ -117,6 +123,7 @@ async def generate(
music_prompt: Optional[str] = None,
sfx_prompt: Optional[str] = None,
segments: Optional[List[SfxSegment]] = None,
keep_original_sound: Optional[bool] = None,
preserve_speech: Optional[bool] = None,
ducking: Optional[bool] = None,
variants_num: Optional[int] = None,
Expand All @@ -129,6 +136,7 @@ async def generate(
music_prompt=music_prompt,
sfx_prompt=sfx_prompt,
segments=segments,
keep_original_sound=keep_original_sound,
preserve_speech=preserve_speech,
ducking=ducking,
variants_num=variants_num,
Expand Down
15 changes: 9 additions & 6 deletions src/sonilo/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,8 +365,9 @@ async def _adownload_to(url: str, path: Union[str, Path], timeout: float) -> Pat
class SoundOutput:
"""One entry of a video-to-sound / video-to-video-sound task's `outputs`
array — one per variant (`variants_num`), sorted by `variant_index`.
`music_processed` is present only when preserve_speech or ducking
altered that variant's music bed."""
`music_processed` is present only when a voice source was kept, i.e. with
keep_original_sound or preserve_speech. On video-to-video-sound neither is
on by default, so the default result carries no such stem."""

variant_index: int
output_url: str
Expand All @@ -386,10 +387,12 @@ class SoundResult:
than a media object, because these endpoints render exactly one artifact
whose kind is announced by `output_type` ("audio" for /v1/video-to-sound,
"video" for /v1/video-to-video-sound). `music`, `music_processed` and `sfx`
are the individual stems; `music_processed` is present only when
preserve_speech or ducking altered the music bed. All of the above stay
aliases for `outputs[0]`'s corresponding fields, even when
`variants_num > 1` produced several variants.
are the individual stems; `music_processed` is present only when a voice
source was kept, i.e. with keep_original_sound or preserve_speech — on
video-to-video-sound neither is on by default, so the default result
carries no such stem. All of the above stay aliases for `outputs[0]`'s
corresponding fields, even when `variants_num > 1` produced several
variants.
"""

task_id: str
Expand Down
Loading
Loading