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
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")

Expand All @@ -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.
Expand All @@ -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
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.9.0"
version = "0.10.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.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]
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.6.0"
__version__ = "0.7.0"

__all__ = ["__version__"]
16 changes: 8 additions & 8 deletions sonilo-cli/src/sonilo_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,15 +247,15 @@ 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:
result = client.text_to_music.generate_async(
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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
31 changes: 25 additions & 6 deletions src/sonilo/_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
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.9.0"
__version__ = "0.10.0"
8 changes: 8 additions & 0 deletions src/sonilo/resources/video_to_sound.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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,
output_format=output_format,
)
close_after = files["video"][1] if files is not None and opened else None
return parse_sfx_task(
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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:
Expand All @@ -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,
Expand Down
34 changes: 30 additions & 4 deletions src/sonilo/resources/video_to_video_music.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading