diff --git a/README.md b/README.md index 7d036a6..506fe9a 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,8 @@ locally before any request is sent. - `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`. -- `output_format` — `"m4a"` (default) or `"wav"` (requires async mode). +- `output_format` — `"m4a"` (default), `"wav"`, or `"mp3"` (320 kbps). + Anything but `m4a` is a finalize-time transcode and requires async mode. ```python result = client.video_to_music.generate_async( @@ -153,10 +154,14 @@ 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. 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, + # segments=[{"start": 0, "prompt": "sparse pads"}, + # {"start": 30, "prompt": "add drums"}], ) music.save("scored.mp4") @@ -167,7 +172,10 @@ sfx = client.video_to_video_sfx.generate( sfx.save("with_sfx.mp4") ``` -`video_to_video_music` also takes `variants_num` (1-10, default `1`): each +`video_to_video_music` copies the source picture without re-encoding, so the +input must carry H.264, H.265/HEVC, VP9 or AV1 video (mp4, mov, m4v or webm) +and run no longer than 360 seconds; animated gif and VP8 webm are rejected. +It also takes `variants_num` (1-10, default `1`): each variant scores the source video with a different musical direction. This endpoint is already async-only, so no `mode` to auto-select — `variants_num` just travels straight through. @@ -186,6 +194,11 @@ permanent alias for `music.videos[0]`, so `music.save("scored.mp4")` (no ## Video to sound +`video_to_sound` takes `output_format` — `"wav"` (default), `"m4a"` or +`"mp3"` — applying to the combined track only; the `music` and `sfx` stems +keep their native formats. `video_to_video_sound` does not take it: that +endpoint always muxes the mix into an mp4. + `video_to_sound` and `video_to_video_sound` generate a music bed and sound effects for the same clip and return them mixed into a single soundtrack — one call, one charge, instead of chaining two requests. `video_to_sound` returns the diff --git a/pyproject.toml b/pyproject.toml index 16b884e..e4a8cf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.9.0" +version = "0.10.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 bee5448..6755a3e 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.6.0" +version = "0.7.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.8.0,<0.9"] +dependencies = ["sonilo>=0.10.0,<0.11"] 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 6131df7..47b9ec7 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.6.0" +__version__ = "0.7.0" __all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index 5f8a151..e53098a 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -247,7 +247,7 @@ def _save_music_variants(result: Any, out: str) -> None: def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None: fmt = args.format multi = args.variants is not None and args.variants > 1 - use_async = args.use_async or fmt == "wav" or multi + use_async = args.use_async or fmt != "m4a" or multi out = _music_output(args, fmt) segments = _segments(args) if use_async: @@ -255,7 +255,7 @@ def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None: prompt=args.prompt, duration=args.duration, segments=segments, - output_format="wav" if fmt == "wav" else None, + output_format=fmt if fmt != "m4a" else None, variants_num=args.variants, ) _save_music_variants(result, out) @@ -271,7 +271,7 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None: fmt = args.format multi = args.variants is not None and args.variants > 1 use_async = ( - args.use_async or fmt == "wav" or args.isolate_vocals or args.preserve_speech or multi + args.use_async or fmt != "m4a" or args.isolate_vocals or args.preserve_speech or multi ) out = _music_output(args, fmt) segments = _segments(args) @@ -283,7 +283,7 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None: segments=segments, isolate_vocals=args.isolate_vocals or None, preserve_speech=args.preserve_speech or None, - output_format="wav" if fmt == "wav" else None, + output_format=fmt if fmt != "m4a" else None, variants_num=args.variants, ) _save_music_variants(result, out) @@ -539,8 +539,8 @@ def build_parser() -> argparse.ArgumentParser: p_t2m.add_argument("--duration", type=int, required=True, help="Track length in seconds.") _add_segments(p_t2m, MUSIC_SHAPE) p_t2m.add_argument("--output", default=None, help="Where to save the audio.") - p_t2m.add_argument("--format", choices=["m4a", "wav"], default="m4a", - help="Output container. wav forces async. Default: m4a") + p_t2m.add_argument("--format", choices=["m4a", "wav", "mp3"], default="m4a", + help="Output container. Anything but m4a forces async. mp3 is 320 kbps. Default: m4a") p_t2m.add_argument("--async", dest="use_async", action="store_true", help="Submit and poll instead of streaming.") _add_variants(p_t2m) @@ -552,8 +552,8 @@ def build_parser() -> argparse.ArgumentParser: p_v2m.add_argument("--prompt", default=None, help="Optional creative direction.") _add_segments(p_v2m, MUSIC_SHAPE) p_v2m.add_argument("--output", default=None, help="Where to save the audio.") - p_v2m.add_argument("--format", choices=["m4a", "wav"], default="m4a", - help="Output container. wav forces async.") + p_v2m.add_argument("--format", choices=["m4a", "wav", "mp3"], default="m4a", + help="Output container. Anything but m4a forces async. mp3 is 320 kbps.") p_v2m.add_argument("--preserve-speech", dest="preserve_speech", action="store_true", help="Keep source speech in the mix. Forces async.") # The API ORs isolate_vocals into preserve_speech (video_to_music.py: diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index f1b83e4..b1ffd20 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -142,7 +142,7 @@ def _resolve_music_mode( ducking: Optional[bool] = None, variants_num: Optional[int] = None, ) -> str: - """isolate_vocals/preserve_speech/ducking/output_format='wav'/ + """isolate_vocals/preserve_speech/ducking/a non-m4a output_format/ variants_num>1 only work with async processing: auto-select mode "async" when the caller didn't specify one, but fail fast if they explicitly asked for anything else. submit() also needs an async response (a @@ -151,14 +151,17 @@ def _resolve_music_mode( needs_async = ( bool(isolate_vocals) or bool(preserve_speech) - or output_format == "wav" + # Any non-m4a container is a finalize-time transcode, so it needs + # async. Testing != "m4a" rather than == "wav" keeps this correct as + # formats are added (mp3 landed after the original check). + or (output_format is not None and output_format != "m4a") or ducking is not None or (variants_num is not None and variants_num > 1) ) if needs_async and mode is not None and mode != "async": raise SoniloError( - "isolate_vocals/preserve_speech/ducking/output_format='wav'/" - "variants_num>1 require mode='async'" + "isolate_vocals/preserve_speech/ducking/output_format other " + "than 'm4a'/variants_num>1 require mode='async'" ) return "async" if needs_async else (mode or "async") @@ -190,6 +193,8 @@ def build_v2m_async_parts( data["output_format"] = output_format if ducking is not None: data["ducking"] = "true" if ducking else "false" + if output_format is not None: + data["output_format"] = output_format if variants_num is not None: data["variants_num"] = str(variants_num) return data, files, opened @@ -202,11 +207,17 @@ def build_v2v_music_parts( preserve_speech: Optional[bool], isolate_vocals: Optional[bool], variants_num: Optional[int] = None, + segments: Optional[List[Segment]] = None, + ducking: 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, None) + 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". + if ducking is not None: + data["ducking"] = "true" if ducking else "false" if preserve_speech is not None: data["preserve_speech"] = "true" if preserve_speech else "false" if isolate_vocals is not None: @@ -235,9 +246,15 @@ def build_v2s_parts( preserve_speech: Optional[bool], ducking: Optional[bool], variants_num: Optional[int] = None, + output_format: Optional[str] = None, ) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: """Multipart parts shared by /v1/video-to-sound and - /v1/video-to-video-sound — their form fields are identical. + /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. These endpoints take `music_prompt`/`sfx_prompt` instead of a single `prompt`, so build_v2m_parts is called with prompt=None. Booleans are only @@ -255,6 +272,8 @@ def build_v2s_parts( data["preserve_speech"] = "true" if preserve_speech else "false" if ducking is not None: data["ducking"] = "true" if ducking else "false" + if output_format is not None: + data["output_format"] = output_format if variants_num is not None: data["variants_num"] = str(variants_num) return data, files, opened diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index 3e2f46a..61fb31c 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.9.0" +__version__ = "0.10.0" diff --git a/src/sonilo/resources/video_to_sound.py b/src/sonilo/resources/video_to_sound.py index 51c07d9..46fb8ff 100644 --- a/src/sonilo/resources/video_to_sound.py +++ b/src/sonilo/resources/video_to_sound.py @@ -33,6 +33,7 @@ def submit( preserve_speech: Optional[bool] = None, ducking: Optional[bool] = None, variants_num: Optional[int] = None, + output_format: Optional[str] = None, ) -> SfxTask: """`variants_num` (1-10, default 1) generates that many distinct variants in one request; the result's `outputs` gets one entry per @@ -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, + output_format=output_format, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -61,6 +63,7 @@ def generate( preserve_speech: Optional[bool] = None, ducking: Optional[bool] = None, variants_num: Optional[int] = None, + output_format: Optional[str] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> SoundResult: @@ -73,6 +76,7 @@ def generate( preserve_speech=preserve_speech, ducking=ducking, variants_num=variants_num, + output_format=output_format, ) return self._client.tasks.wait( task.task_id, @@ -97,10 +101,12 @@ async def submit( preserve_speech: Optional[bool] = None, ducking: Optional[bool] = None, variants_num: Optional[int] = None, + output_format: Optional[str] = None, ) -> SfxTask: data, files, opened = build_v2s_parts( video, video_url, music_prompt, sfx_prompt, segments, preserve_speech, ducking, variants_num, + output_format=output_format, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -120,6 +126,7 @@ async def generate( preserve_speech: Optional[bool] = None, ducking: Optional[bool] = None, variants_num: Optional[int] = None, + output_format: Optional[str] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> SoundResult: @@ -132,6 +139,7 @@ async def generate( preserve_speech=preserve_speech, ducking=ducking, variants_num=variants_num, + output_format=output_format, ) return await self._client.tasks.wait( task.task_id, diff --git a/src/sonilo/resources/video_to_video_music.py b/src/sonilo/resources/video_to_video_music.py index e131cc1..c0b053b 100644 --- a/src/sonilo/resources/video_to_video_music.py +++ b/src/sonilo/resources/video_to_video_music.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, List, Optional from sonilo._requests import build_v2v_music_parts from sonilo.resources.tasks import ( @@ -9,7 +9,7 @@ parse_sfx_task, parse_video_result, ) -from sonilo.types import SfxTask, VideoResult +from sonilo.types import Segment, SfxTask, VideoResult if TYPE_CHECKING: from sonilo._async_client import AsyncSonilo @@ -28,6 +28,8 @@ def submit( video: Any = None, video_url: Optional[str] = None, prompt: Optional[str] = None, + segments: Optional[List[Segment]] = None, + ducking: Optional[bool] = None, preserve_speech: Optional[bool] = None, isolate_vocals: Optional[bool] = None, variants_num: Optional[int] = None, @@ -39,7 +41,14 @@ def submit( This endpoint is always async, so there is no mode to auto-select. """ data, files, opened = build_v2v_music_parts( - video, video_url, prompt, preserve_speech, isolate_vocals, variants_num + video, + video_url, + prompt, + preserve_speech, + isolate_vocals, + variants_num, + segments=segments, + ducking=ducking, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -52,6 +61,8 @@ def generate( video: Any = None, video_url: Optional[str] = None, prompt: Optional[str] = None, + segments: Optional[List[Segment]] = None, + ducking: Optional[bool] = None, preserve_speech: Optional[bool] = None, isolate_vocals: Optional[bool] = None, variants_num: Optional[int] = None, @@ -62,6 +73,8 @@ def generate( video=video, video_url=video_url, prompt=prompt, + segments=segments, + ducking=ducking, preserve_speech=preserve_speech, isolate_vocals=isolate_vocals, variants_num=variants_num, @@ -84,12 +97,21 @@ async def submit( video: Any = None, video_url: Optional[str] = None, prompt: Optional[str] = None, + segments: Optional[List[Segment]] = None, + ducking: Optional[bool] = None, preserve_speech: Optional[bool] = None, isolate_vocals: Optional[bool] = None, variants_num: Optional[int] = None, ) -> SfxTask: data, files, opened = build_v2v_music_parts( - video, video_url, prompt, preserve_speech, isolate_vocals, variants_num + video, + video_url, + prompt, + preserve_speech, + isolate_vocals, + variants_num, + segments=segments, + ducking=ducking, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -104,6 +126,8 @@ async def generate( video: Any = None, video_url: Optional[str] = None, prompt: Optional[str] = None, + segments: Optional[List[Segment]] = None, + ducking: Optional[bool] = None, preserve_speech: Optional[bool] = None, isolate_vocals: Optional[bool] = None, variants_num: Optional[int] = None, @@ -114,6 +138,8 @@ async def generate( video=video, video_url=video_url, prompt=prompt, + segments=segments, + ducking=ducking, preserve_speech=preserve_speech, isolate_vocals=isolate_vocals, variants_num=variants_num, diff --git a/tests/test_mp3_and_v2v_music_params.py b/tests/test_mp3_and_v2v_music_params.py new file mode 100644 index 0000000..943a825 --- /dev/null +++ b/tests/test_mp3_and_v2v_music_params.py @@ -0,0 +1,70 @@ +"""Covers the three API changes synced in this branch: the mp3 container, the +audio-only output_format on video-to-sound, and ducking/segments on +video-to-video-music.""" +import pytest + +from sonilo._requests import build_v2s_parts, build_v2v_music_parts, _resolve_music_mode +from sonilo.errors import SoniloError + + +# --- mp3 / the widened async gate ------------------------------------------- + +@pytest.mark.parametrize("fmt", ["wav", "mp3"]) +def test_non_m4a_formats_force_async(fmt): + """Both wav and mp3 are finalize-time transcodes. The gate used to name + 'wav' specifically, which would have let mp3 through as a plain stream.""" + assert _resolve_music_mode(None, None, output_format=fmt) == "async" + with pytest.raises(SoniloError): + _resolve_music_mode("stream", None, output_format=fmt) + + +def test_m4a_still_streams(): + assert _resolve_music_mode("stream", None, output_format="m4a") == "stream" + + +# --- video-to-sound output_format (audio endpoint only) ---------------------- + +def test_v2s_emits_output_format_when_given(): + data, _, _ = build_v2s_parts( + None, "https://x/v.mp4", None, None, None, None, None, output_format="mp3" + ) + assert data["output_format"] == "mp3" + + +def test_v2s_omits_output_format_when_unset(): + """The server defaults the combined track to wav; an unset value must not + go out. video-to-video-sound never passes it at all -- that endpoint + always returns an mp4.""" + data, _, _ = build_v2s_parts( + None, "https://x/v.mp4", None, None, None, None, None + ) + assert "output_format" not in data + + +# --- video-to-video-music ducking + segments -------------------------------- + +def test_v2v_music_omits_ducking_when_unset(): + """ducking is default-ON server-side, so an unset value must not become an + explicit 'false' on the wire.""" + data, _, _ = build_v2v_music_parts(None, "https://x/v.mp4", None, None, None) + assert "ducking" not in data + + +def test_v2v_music_sends_ducking_false_when_opted_out(): + data, _, _ = build_v2v_music_parts( + None, "https://x/v.mp4", None, None, None, ducking=False + ) + assert data["ducking"] == "false" + + +def test_v2v_music_serializes_segments(): + segments = [ + {"start": 0, "prompt": "sparse pads", "label": "intro"}, + {"start": 30, "prompt": "add drums", "label": "verse"}, + ] + data, _, _ = build_v2v_music_parts( + None, "https://x/v.mp4", None, None, None, segments=segments + ) + import json + + assert json.loads(data["segments"]) == segments