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
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,34 @@ if result.ducked:
result.save("ducked.wav", which="ducked")
```

### Variants (async)

`variants_num` (1-10, default `1`) generates that many distinct music
variants in one request instead of one — each is its own creative direction
with its own title. It's an async-only option, same as `output_format`:
`submit()` / `generate_async()` accept it on both `text_to_music` and
`video_to_music`, auto-selecting async when it's above 1 (an explicit
non-async `mode` alongside `variants_num > 1` raises `SoniloError` locally,
same as the other async-only options above). Cost scales linearly —
`variants_num=3` costs three times a single-variant request — and values
above 1 are never covered by the free trial.

```python
result = client.text_to_music.generate_async(
prompt="cinematic orchestral score",
duration=30,
variants_num=3,
)
for i in range(len(result.audio)):
result.save(f"variant_{i}.m4a", index=i)
title = result.audio[i].title
print(i, title.title if title else None)
```

`result.audio` always has one entry per variant; with `variants_num=1` (the
default) that's the same single-entry list as before this option existed, and
the top-level `result.title` stays an alias for `result.audio[0].title`.

## Video to video

Generate music or sound effects and get back a **re-hosted video** with the
Expand All @@ -139,6 +167,23 @@ 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
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.

```python
music = client.video_to_video_music.generate(
video="my_video.mp4", prompt="cinematic orchestral swell", variants_num=3,
)
for i in range(len(music.videos)):
music.save(f"scored_{i}.mp4", index=i)
```

`music.videos` always has one entry per variant; `music.video` stays a
permanent alias for `music.videos[0]`, so `music.save("scored.mp4")` (no
`index`) keeps working exactly as it did before `variants_num` existed.

## Video to sound

`video_to_sound` and `video_to_video_sound` generate a music bed and sound
Expand Down Expand Up @@ -174,6 +219,28 @@ result.save_stem("sfx.wav", which="sfx")
`segments` takes the same `{"start", "end", "prompt"}` list as `video_to_sfx`.
Input videos may be at most 180 seconds long.

Both also take `variants_num` (1-10, default `1`): each variant pairs its own
generated music with its own generated sound effects. Like
`video_to_video_music`, these endpoints are already async-only, so
`variants_num` needs no `mode` to auto-select.

```python
result = client.video_to_sound.generate(
video_url="https://example.com/clip.mp4",
music_prompt="uplifting orchestral score",
variants_num=3,
)
for i in range(len(result.outputs)):
result.save(f"soundtrack_{i}.wav", index=i)
result.save_stem(f"music_{i}.m4a", which="music", index=i)
```

`result.outputs` always has one entry per variant, sorted by `variant_index`;
`output_url`/`output_type`/`output_bytes`/`music`/`music_processed`/`sfx`
stay aliases for `outputs[0]`'s corresponding fields, so `result.save(...)`
and `result.save_stem(...)` (no `index`) keep working exactly as they did
before `variants_num` existed.

Use `submit()` instead of `generate()` to get a `task_id` back immediately and
poll it yourself with `client.tasks.wait(task_id, parser=parse_sound_result)`.
`AsyncSonilo` exposes the same two resources with `await`-able
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.7.0"
version = "0.8.0"
description = "Official Python client for the Sonilo API"
readme = "README.md"
license = "MIT"
Expand Down
25 changes: 23 additions & 2 deletions sonilo-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ or pass `--api-key sk-...` on any command.
### Notes

- `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`,
`--preserve-speech`, and its legacy alias `--isolate-vocals` each switch to the async
submit-and-poll path.
`--preserve-speech`, `--variants` above 1, and the legacy alias `--isolate-vocals` each switch
to the async submit-and-poll path.
- `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
- Output defaults to `./output.<ext>`; override with `--output`.

Expand Down Expand Up @@ -72,6 +72,24 @@ The two segment shapes are **not** interchangeable:
- `text-to-sfx` takes no segments (its output is a single effect, not a timeline).
- `video-to-video-music` takes no segments either — the API scores the whole clip in one pass.

### Variants

`--variants N` (1-10, default 1) generates that many distinct variants in one request instead of
one, on `text-to-music`, `video-to-music`, `video-to-video-music`, `video-to-sound`, and
`video-to-video-sound`. Cost scales linearly — `--variants 3` costs three times a single-variant
request — and values above 1 are never covered by the free trial.

sonilo text-to-music --prompt "warm lo-fi piano" --duration 30 --variants 3 --output take.m4a
# writes take.0.m4a, take.1.m4a, take.2.m4a

- `--variants` above 1 forces the async submit-and-poll path (see [Notes](#notes) above).
- With `--variants` unset (or `1`), a command writes the single `--output` file exactly as before
this flag existed. Above 1, it instead writes one file per variant, with the variant index
spliced before the extension: `take.m4a` becomes `take.0.m4a`, `take.1.m4a`, etc. — the same
naming `--stem` and dubbing's per-language output already use.
- On `video-to-sound` / `video-to-video-sound`, `--stem` is applied per variant too, e.g.
`take.0.music.m4a`.

### Scored video

`video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
Expand All @@ -92,6 +110,8 @@ file (default `output.mp4`):
[Segments](#segments).
- Neither command exposes `--format`: the output is a video, not an audio file.
- For music *and* effects in one call, use `video-to-video-sound` below.
- `video-to-video-music` also takes `--variants` — see [Variants](#variants) above.
`video-to-video-sfx` does not.

### Combined soundtracks

Expand All @@ -116,6 +136,7 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
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`
or ducking altered the music bed.
- Both also take `--variants` — see [Variants](#variants) above.

### Dubbing

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.5.0"
version = "0.6.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.7.0,<0.8"]
dependencies = ["sonilo>=0.8.0,<0.9"]
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.5.0"
__version__ = "0.6.0"

__all__ = ["__version__"]
96 changes: 80 additions & 16 deletions sonilo-cli/src/sonilo_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,33 @@ def _music_output(args: argparse.Namespace, fmt: str) -> str:
return args.output if args.output is not None else f"output.{fmt}"


def _variant_path(out: str, index: int) -> str:
"""Turn one --output value into a per-variant path: `clip.mp4` + `1`
becomes `clip.1.mp4`. Same transform as _stem_path/_language_path, used
whenever --variants > 1 fans a single --output into one file per variant."""
base = Path(out)
return str(base.with_name(f"{base.stem}.{index}{base.suffix}"))


def _save_music_variants(result: Any, out: str) -> None:
"""Save every entry of an async music result's `audio` list. With
--variants unset (or 1) this is a single file at `out`, byte-identical
to the pre-variants behaviour; with --variants > 1 it fans out to
`out.0.ext`, `out.1.ext`, etc."""
count = len(result.audio or [])
if count <= 1:
path = result.save(out)
_wrote(path, path.stat().st_size)
return
for index in range(count):
path = result.save(_variant_path(out, index), index=index)
_wrote(path, path.stat().st_size)


def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None:
fmt = args.format
use_async = args.use_async or fmt == "wav"
multi = args.variants is not None and args.variants > 1
use_async = args.use_async or fmt == "wav" or multi
out = _music_output(args, fmt)
segments = _segments(args)
if use_async:
Expand All @@ -232,9 +256,9 @@ def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None:
duration=args.duration,
segments=segments,
output_format="wav" if fmt == "wav" else None,
variants_num=args.variants,
)
path = result.save(out)
_wrote(path, path.stat().st_size)
_save_music_variants(result, out)
else:
track = client.text_to_music.generate(
prompt=args.prompt, duration=args.duration, segments=segments
Expand All @@ -245,7 +269,10 @@ def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None:

def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None:
fmt = args.format
use_async = args.use_async or fmt == "wav" or args.isolate_vocals or args.preserve_speech
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
)
out = _music_output(args, fmt)
segments = _segments(args)
if use_async:
Expand All @@ -257,9 +284,9 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None:
isolate_vocals=args.isolate_vocals or None,
preserve_speech=args.preserve_speech or None,
output_format="wav" if fmt == "wav" else None,
variants_num=args.variants,
)
path = result.save(out)
_wrote(path, path.stat().st_size)
_save_music_variants(result, out)
else:
track = client.video_to_music.generate(
video=args.video, video_url=args.video_url, prompt=args.prompt,
Expand Down Expand Up @@ -312,13 +339,25 @@ def _run_sound(client: Sonilo, args: argparse.Namespace, resource: Any, default_
segments=_segments(args),
preserve_speech=True if args.preserve_speech else None,
ducking=False if args.no_ducking else None,
variants_num=args.variants,
)
path = result.save(out)
_wrote(path, path.stat().st_size)
for stem in args.stems or []:
stem_path = _stem_path(out, stem, getattr(result, stem, None))
saved = result.save_stem(stem_path, which=stem)
_wrote(saved, saved.stat().st_size)
multi = args.variants is not None and args.variants > 1 and len(result.outputs) > 1
if not multi:
path = result.save(out)
_wrote(path, path.stat().st_size)
for stem in args.stems or []:
stem_path = _stem_path(out, stem, getattr(result, stem, None))
saved = result.save_stem(stem_path, which=stem)
_wrote(saved, saved.stat().st_size)
return
for index, entry in enumerate(result.outputs):
variant_out = _variant_path(out, index)
path = result.save(variant_out, index=index)
_wrote(path, path.stat().st_size)
for stem in args.stems or []:
stem_path = _stem_path(variant_out, stem, getattr(entry, stem, None))
saved = result.save_stem(stem_path, which=stem, index=index)
_wrote(saved, saved.stat().st_size)


def cmd_video_to_sound(client: Sonilo, args: argparse.Namespace) -> None:
Expand All @@ -330,14 +369,22 @@ def cmd_video_to_video_sound(client: Sonilo, args: argparse.Namespace) -> None:


def _run_video(args: argparse.Namespace, resource: Any, **params: Any) -> None:
"""Run one of the video-returning endpoints and save the single result.
"""Run one of the video-returning endpoints and save the result.

These return the source picture with the generated audio muxed in — one
file, no stems — so the default destination is an `.mp4`, matching
video-to-video-sound.
These return the source picture with the generated audio muxed in — no
stems — so the default destination is an `.mp4`, matching
video-to-video-sound. A `variants_num` in `params` fans out into one
indexed file per variant when it is greater than 1; otherwise this stays
the single-file save from before variants existed.
"""
out = args.output if args.output is not None else "output.mp4"
result = resource.generate(video=args.video, video_url=args.video_url, **params)
variants = params.get("variants_num")
if variants is not None and variants > 1 and len(result.videos) > 1:
for index in range(len(result.videos)):
path = result.save(_variant_path(out, index), index=index)
_wrote(path, path.stat().st_size)
return
path = result.save(out)
_wrote(path, path.stat().st_size)

Expand All @@ -351,6 +398,7 @@ def cmd_video_to_video_music(client: Sonilo, args: argparse.Namespace) -> None:
# same reasoning as --no-ducking on the sound commands.
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 @@ -459,6 +507,17 @@ def _add_segments(parser: argparse.ArgumentParser, shape: _SegmentShape) -> None
parser.set_defaults(segments_shape=shape)


def _add_variants(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--variants", type=int, default=None,
help="How many distinct variants to generate in one request, 1-10 "
"(default 1). Cost scales linearly, and values above 1 are never "
"covered by the free trial. Values above 1 force async and write "
"one indexed file per variant (output.0.ext, output.1.ext, ...) "
"instead of a single --output.",
)


def build_parser() -> argparse.ArgumentParser:
parser = _Parser(prog="sonilo", description="Command-line interface for the Sonilo API")
parser.add_argument("--version", action="version", version=__version__)
Expand All @@ -484,6 +543,7 @@ def build_parser() -> argparse.ArgumentParser:
help="Output container. wav forces async. Default: m4a")
p_t2m.add_argument("--async", dest="use_async", action="store_true",
help="Submit and poll instead of streaming.")
_add_variants(p_t2m)
p_t2m.set_defaults(func=cmd_text_to_music)

p_v2m = sub.add_parser("video-to-music", help="Generate music matched to a video")
Expand All @@ -504,6 +564,7 @@ def build_parser() -> argparse.ArgumentParser:
help="Legacy alias for --preserve-speech. Forces async.")
p_v2m.add_argument("--async", dest="use_async", action="store_true",
help="Submit and poll instead of streaming.")
_add_variants(p_v2m)
p_v2m.set_defaults(func=cmd_video_to_music)

p_t2s = sub.add_parser("text-to-sfx", help="Generate a sound effect from a text prompt")
Expand Down Expand Up @@ -542,6 +603,7 @@ def build_parser() -> argparse.ArgumentParser:
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.")
_add_variants(p_v2sd)
p_v2sd.set_defaults(func=cmd_video_to_sound)

p_v2vm = sub.add_parser(
Expand All @@ -559,6 +621,7 @@ def build_parser() -> argparse.ArgumentParser:
p_v2vm.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
help="Legacy alias for --preserve-speech; no separate stem.")
p_v2vm.add_argument("--output", default=None, help="Where to save the scored video.")
_add_variants(p_v2vm)
p_v2vm.set_defaults(func=cmd_video_to_video_music)

p_v2vfx = sub.add_parser(
Expand Down Expand Up @@ -588,6 +651,7 @@ def build_parser() -> argparse.ArgumentParser:
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.")
_add_variants(p_v2vsd)
p_v2vsd.set_defaults(func=cmd_video_to_video_sound)

p_dub = sub.add_parser("dubbing", help="Dub a video into other languages")
Expand Down
Loading
Loading