diff --git a/README.md b/README.md index 506fe9a..9fed599 100644 --- a/README.md +++ b/README.md @@ -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"}], ) @@ -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. diff --git a/pyproject.toml b/pyproject.toml index e4a8cf8..ca2d1ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index 6755a3e..9e6c5c6 100644 --- a/sonilo-cli/pyproject.toml +++ b/sonilo-cli/pyproject.toml @@ -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] diff --git a/sonilo-cli/src/sonilo_cli/__init__.py b/sonilo-cli/src/sonilo_cli/__init__.py index 47b9ec7..32480aa 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.7.0" +__version__ = "0.8.0" __all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index e53098a..e9efbed 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -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, @@ -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: @@ -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, @@ -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 @@ -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.") diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index b1ffd20..b639298 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -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: @@ -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: diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index 61fb31c..ae6db5f 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.10.0" +__version__ = "0.11.0" diff --git a/src/sonilo/resources/video_to_video_music.py b/src/sonilo/resources/video_to_video_music.py index c0b053b..c8e7cc5 100644 --- a/src/sonilo/resources/video_to_video_music.py +++ b/src/sonilo/resources/video_to_video_music.py @@ -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, @@ -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( @@ -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, @@ -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, @@ -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, @@ -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( @@ -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, @@ -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, diff --git a/src/sonilo/resources/video_to_video_sound.py b/src/sonilo/resources/video_to_video_sound.py index f60e41a..b476502 100644 --- a/src/sonilo/resources/video_to_video_sound.py +++ b/src/sonilo/resources/video_to_video_sound.py @@ -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, @@ -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( @@ -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, @@ -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, @@ -94,6 +98,7 @@ 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, @@ -101,6 +106,7 @@ async 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( @@ -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, @@ -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, diff --git a/src/sonilo/types.py b/src/sonilo/types.py index 085cf59..2262af2 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -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 @@ -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 diff --git a/tests/test_keep_original_sound.py b/tests/test_keep_original_sound.py new file mode 100644 index 0000000..5905209 --- /dev/null +++ b/tests/test_keep_original_sound.py @@ -0,0 +1,115 @@ +"""Covers `keep_original_sound` on the two video-output endpoints. + +The server flipped both endpoints' defaults: a request that does not set this +flag now returns the generated audio alone, where it previously returned the +source video's speech with the generated music ducked underneath. These tests +pin the two things that keep the SDK honest about that — the flag is omitted +from the wire unless explicitly passed (so an unset value never becomes an +explicit "true"), and it never reaches the audio-only endpoint at all. +""" +import inspect + +from sonilo._requests import build_v2s_parts, build_v2v_music_parts +from sonilo.resources.video_to_sound import AsyncVideoToSound, VideoToSound +from sonilo.resources.video_to_video_music import ( + AsyncVideoToVideoMusic, + VideoToVideoMusic, +) +from sonilo.resources.video_to_video_sound import ( + AsyncVideoToVideoSound, + VideoToVideoSound, +) + + +# --- video-to-video-music ---------------------------------------------------- + +def test_v2v_music_omits_keep_original_sound_when_none(): + """Default-OFF server-side, so an unset value must not go out as "true" — + and must not go out as "false" either, which would pin a default the server + owns.""" + data, _, _ = build_v2v_music_parts(None, "https://x/v.mp4", None, None, None) + assert "keep_original_sound" not in data + + +def test_v2v_music_sends_keep_original_sound(): + data, _, _ = build_v2v_music_parts( + None, "https://x/v.mp4", None, None, None, + keep_original_sound=True, + ) + assert data["keep_original_sound"] == "true" + + +def test_v2v_music_static_mix_row(): + """`keep_original_sound` + `ducking=False` is the new static-mix row: the + whole original track, mixed at a fixed offset instead of ducked.""" + data, _, _ = build_v2v_music_parts( + None, "https://x/v.mp4", None, None, None, + keep_original_sound=True, ducking=False, + ) + assert data["keep_original_sound"] == "true" + assert data["ducking"] == "false" + + +def test_v2v_music_sends_both_flags_leaving_precedence_to_the_server(): + """Deliberately not resolved client-side: the server supersedes + preserve_speech with keep_original_sound and logs that it did. Resolving it + here would hide the override and desync from the other SDKs.""" + data, _, _ = build_v2v_music_parts( + None, "https://x/v.mp4", None, True, None, + keep_original_sound=True, + ) + assert data["keep_original_sound"] == "true" + assert data["preserve_speech"] == "true" + + +def test_v2v_music_explicit_false_is_sent(): + """An explicit False is a real request ("do not keep it"), distinct from + unset, so it does go on the wire.""" + data, _, _ = build_v2v_music_parts( + None, "https://x/v.mp4", None, None, None, + keep_original_sound=False, + ) + assert data["keep_original_sound"] == "false" + + +# --- video-to-video-sound ---------------------------------------------------- + +def test_v2s_omits_keep_original_sound_when_none(): + data, _, _ = build_v2s_parts( + None, "https://x/v.mp4", None, None, None, None, None, + ) + assert "keep_original_sound" not in data + + +def test_v2s_sends_keep_original_sound(): + data, _, _ = build_v2s_parts( + None, "https://x/v.mp4", None, None, None, None, None, + keep_original_sound=True, + ) + assert data["keep_original_sound"] == "true" + + +# --- the audio endpoint must never expose or send it ------------------------- + +def test_audio_endpoint_does_not_expose_keep_original_sound(): + """`keep_original_sound` is video-only: it only means something when the + deliverable is a video whose own audio could be preserved. The audio + resource simply never passes it to the shared builder — the mirror of how + `output_format` is kept off video-to-video-sound. Asserted on the public + signatures so adding it by reflex would fail here.""" + for cls in (VideoToSound, AsyncVideoToSound): + for method in ("submit", "generate"): + params = inspect.signature(getattr(cls, method)).parameters + assert "keep_original_sound" not in params, f"{cls.__name__}.{method}" + + +def test_video_endpoints_do_expose_keep_original_sound(): + for cls in ( + VideoToVideoMusic, + AsyncVideoToVideoMusic, + VideoToVideoSound, + AsyncVideoToVideoSound, + ): + for method in ("submit", "generate"): + params = inspect.signature(getattr(cls, method)).parameters + assert "keep_original_sound" in params, f"{cls.__name__}.{method}"