sonilo-cli 0.8.2__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sonilo-cli
3
- Version: 0.8.2
3
+ Version: 0.10.0
4
4
  Summary: Command-line interface for the Sonilo API: generate music and sound effects from text or video
5
5
  Project-URL: Repository, https://github.com/sonilo-ai/sonilo-python
6
6
  Author: Sonilo AI
@@ -8,7 +8,7 @@ License-Expression: MIT
8
8
  License-File: LICENSE
9
9
  Keywords: ai,cli,music,sfx,sonilo,text-to-music,video-to-music
10
10
  Requires-Python: >=3.9
11
- Requires-Dist: sonilo<0.12,>=0.11.0
11
+ Requires-Dist: sonilo<0.13,>=0.12.0
12
12
  Provides-Extra: dev
13
13
  Requires-Dist: pytest>=8; extra == 'dev'
14
14
  Requires-Dist: respx>=0.21; extra == 'dev'
@@ -106,6 +106,17 @@ request — and values above 1 are never covered by the free trial.
106
106
  - On `video-to-sound` / `video-to-video-sound`, `--stem` is applied per variant too, e.g.
107
107
  `take.0.music.m4a`.
108
108
 
109
+ ### Prompt influence
110
+
111
+ `--prompt-influence` (0-1, API default 0.5) sets how strongly the generated music follows the
112
+ prompt, on `video-to-music` and `video-to-video-music` only. Lower values let the video lead;
113
+ higher values follow the prompt more literally. It is free of charge, and unlike `--format wav` it
114
+ does not force the async path — it works on the streaming default too. Left unset, the field is
115
+ not sent at all and the API's own 0.5 default applies; `--prompt-influence 0` is a real value
116
+ ("let the video lead entirely") and is sent. Out-of-range values earn a `422` from the API.
117
+
118
+ sonilo video-to-music --video clip.mp4 --prompt "tense synths" --prompt-influence 0.8
119
+
109
120
  ### Scored video
110
121
 
111
122
  `video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
@@ -128,6 +139,8 @@ file (default `output.mp4`):
128
139
  - For music *and* effects in one call, use `video-to-video-sound` below.
129
140
  - `video-to-video-music` also takes `--variants` — see [Variants](#variants) above.
130
141
  `video-to-video-sfx` does not.
142
+ - `video-to-video-music` also takes `--prompt-influence` — see
143
+ [Prompt influence](#prompt-influence) above. `video-to-video-sfx` does not.
131
144
 
132
145
  ### Combined soundtracks
133
146
 
@@ -146,8 +159,10 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
146
159
  - `--segments` places individual effects on the timeline, in the SFX shape `{start, end, prompt}` —
147
160
  see [Segments](#segments).
148
161
  - `--preserve-speech` keeps speech from the source video in the mix.
149
- - **Ducking is on by default** (music dips under speech). Pass `--no-ducking` to opt out — omitting
150
- the flag leaves the server default untouched.
162
+ - **Ducking is off by default.** Pass `--ducking` to bring the source video's own speech into the
163
+ mix with the generated bed dipped under it — without it the result is the generated music and
164
+ effects alone. Omitting both flags leaves the server default untouched; `--no-ducking` still
165
+ works and now just states that default explicitly.
151
166
  - `--stem` is repeatable (`music`, `music_processed`, `sfx`) and saves the individual layers next to
152
167
  the combined output, so you can re-balance the mix yourself. With `--output soundtrack.wav`, the
153
168
  music stem lands at `soundtrack.music.m4a`. `music_processed` exists only when `--preserve-speech`
@@ -90,6 +90,17 @@ request — and values above 1 are never covered by the free trial.
90
90
  - On `video-to-sound` / `video-to-video-sound`, `--stem` is applied per variant too, e.g.
91
91
  `take.0.music.m4a`.
92
92
 
93
+ ### Prompt influence
94
+
95
+ `--prompt-influence` (0-1, API default 0.5) sets how strongly the generated music follows the
96
+ prompt, on `video-to-music` and `video-to-video-music` only. Lower values let the video lead;
97
+ higher values follow the prompt more literally. It is free of charge, and unlike `--format wav` it
98
+ does not force the async path — it works on the streaming default too. Left unset, the field is
99
+ not sent at all and the API's own 0.5 default applies; `--prompt-influence 0` is a real value
100
+ ("let the video lead entirely") and is sent. Out-of-range values earn a `422` from the API.
101
+
102
+ sonilo video-to-music --video clip.mp4 --prompt "tense synths" --prompt-influence 0.8
103
+
93
104
  ### Scored video
94
105
 
95
106
  `video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
@@ -112,6 +123,8 @@ file (default `output.mp4`):
112
123
  - For music *and* effects in one call, use `video-to-video-sound` below.
113
124
  - `video-to-video-music` also takes `--variants` — see [Variants](#variants) above.
114
125
  `video-to-video-sfx` does not.
126
+ - `video-to-video-music` also takes `--prompt-influence` — see
127
+ [Prompt influence](#prompt-influence) above. `video-to-video-sfx` does not.
115
128
 
116
129
  ### Combined soundtracks
117
130
 
@@ -130,8 +143,10 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
130
143
  - `--segments` places individual effects on the timeline, in the SFX shape `{start, end, prompt}` —
131
144
  see [Segments](#segments).
132
145
  - `--preserve-speech` keeps speech from the source video in the mix.
133
- - **Ducking is on by default** (music dips under speech). Pass `--no-ducking` to opt out — omitting
134
- the flag leaves the server default untouched.
146
+ - **Ducking is off by default.** Pass `--ducking` to bring the source video's own speech into the
147
+ mix with the generated bed dipped under it — without it the result is the generated music and
148
+ effects alone. Omitting both flags leaves the server default untouched; `--no-ducking` still
149
+ works and now just states that default explicitly.
135
150
  - `--stem` is repeatable (`music`, `music_processed`, `sfx`) and saves the individual layers next to
136
151
  the combined output, so you can re-balance the mix yourself. With `--output soundtrack.wav`, the
137
152
  music stem lands at `soundtrack.music.m4a`. `music_processed` exists only when `--preserve-speech`
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sonilo-cli"
7
- version = "0.8.2"
7
+ version = "0.10.0"
8
8
  description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.9"
12
12
  authors = [{ name = "Sonilo AI" }]
13
- dependencies = ["sonilo>=0.11.0,<0.12"]
13
+ dependencies = ["sonilo>=0.12.0,<0.13"]
14
14
  keywords = ["sonilo", "cli", "music", "sfx", "text-to-music", "video-to-music", "ai"]
15
15
 
16
16
  [project.urls]
@@ -1,3 +1,3 @@
1
- __version__ = "0.8.0"
1
+ __version__ = "0.10.0"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -167,6 +167,30 @@ def parse_segments(
167
167
  return value
168
168
 
169
169
 
170
+ def _ducking(args: argparse.Namespace) -> Optional[bool]:
171
+ """Resolve --ducking / --no-ducking, or None to let the server default win.
172
+
173
+ `ducking` used to be default-ON server-side, so the only direction worth
174
+ expressing was turning it off and --no-ducking was the only flag. It is now
175
+ default-OFF, which makes --ducking the useful one. --no-ducking is kept
176
+ because dropping it would turn every script that passes it into a hard
177
+ argparse error; it now sends the explicit False the server would have
178
+ applied anyway.
179
+
180
+ Passing both is a contradiction with no sensible winner, so it exits rather
181
+ than silently picking one.
182
+ """
183
+ on = getattr(args, "ducking", False)
184
+ off = getattr(args, "no_ducking", False)
185
+ if on and off:
186
+ raise SystemExit("pass at most one of --ducking or --no-ducking")
187
+ if on:
188
+ return True
189
+ if off:
190
+ return False
191
+ return None
192
+
193
+
170
194
  def _segments(args: argparse.Namespace) -> Optional[List[Dict[str, Any]]]:
171
195
  """parse_segments() for whichever subcommand is running."""
172
196
  return parse_segments(args.segments, args.segments_shape, args.command)
@@ -285,12 +309,15 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None:
285
309
  preserve_speech=args.preserve_speech or None,
286
310
  output_format=fmt if fmt != "m4a" else None,
287
311
  variants_num=args.variants,
312
+ prompt_influence=args.prompt_influence,
288
313
  )
289
314
  _save_music_variants(result, out)
290
315
  else:
316
+ # prompt_influence rides the streaming path too — it is a generation
317
+ # parameter, not a finalize-time one, so it never forces async.
291
318
  track = client.video_to_music.generate(
292
319
  video=args.video, video_url=args.video_url, prompt=args.prompt,
293
- segments=segments,
320
+ segments=segments, prompt_influence=args.prompt_influence,
294
321
  )
295
322
  path = track.save(out)
296
323
  _wrote(path, len(track.audio))
@@ -345,7 +372,7 @@ def _run_sound(client: Sonilo, args: argparse.Namespace, resource: Any, default_
345
372
  sfx_prompt=args.sfx_prompt,
346
373
  segments=_segments(args),
347
374
  preserve_speech=True if args.preserve_speech else None,
348
- ducking=False if args.no_ducking else None,
375
+ ducking=_ducking(args),
349
376
  variants_num=args.variants,
350
377
  **extra,
351
378
  )
@@ -403,15 +430,15 @@ def cmd_video_to_video_music(client: Sonilo, args: argparse.Namespace) -> None:
403
430
  client.video_to_video_music,
404
431
  prompt=args.prompt,
405
432
  # Unset flags forward None, not False, so the server default stands —
406
- # same reasoning as --no-ducking on the sound commands. The two
407
- # defaults run opposite ways: ducking is default-ON server-side and
408
- # keep_original_sound default-OFF, so each is only ever sent to change
409
- # the default, never to restate it.
433
+ # same reasoning as --ducking on the sound commands. Both ducking and
434
+ # keep_original_sound are default-OFF server-side, so each is only ever
435
+ # sent to change the default, never to restate it.
410
436
  keep_original_sound=True if args.keep_original_sound else None,
411
- ducking=False if args.no_ducking else None,
437
+ ducking=_ducking(args),
412
438
  preserve_speech=True if args.preserve_speech else None,
413
439
  isolate_vocals=True if args.isolate_vocals else None,
414
440
  variants_num=args.variants,
441
+ prompt_influence=args.prompt_influence,
415
442
  )
416
443
 
417
444
 
@@ -520,6 +547,19 @@ def _add_segments(parser: argparse.ArgumentParser, shape: _SegmentShape) -> None
520
547
  parser.set_defaults(segments_shape=shape)
521
548
 
522
549
 
550
+ def _add_prompt_influence(parser: argparse.ArgumentParser) -> None:
551
+ # Only the two music-from-video commands take this — the API accepts it
552
+ # nowhere else. type=float so 0 arrives as 0.0, a real value ("let the
553
+ # video lead entirely"), distinct from the unset None that keeps the
554
+ # field off the wire and leaves the API its own 0.5 default.
555
+ parser.add_argument(
556
+ "--prompt-influence", dest="prompt_influence", type=float, default=None,
557
+ help="How strongly the generated music follows the prompt, 0-1 "
558
+ "(API default 0.5). Lower values let the video lead; higher "
559
+ "values follow the prompt more literally. Free of charge.",
560
+ )
561
+
562
+
523
563
  def _add_variants(parser: argparse.ArgumentParser) -> None:
524
564
  parser.add_argument(
525
565
  "--variants", type=int, default=None,
@@ -578,6 +618,7 @@ def build_parser() -> argparse.ArgumentParser:
578
618
  p_v2m.add_argument("--async", dest="use_async", action="store_true",
579
619
  help="Submit and poll instead of streaming.")
580
620
  _add_variants(p_v2m)
621
+ _add_prompt_influence(p_v2m)
581
622
  p_v2m.set_defaults(func=cmd_video_to_music)
582
623
 
583
624
  p_t2s = sub.add_parser("text-to-sfx", help="Generate a sound effect from a text prompt")
@@ -611,8 +652,14 @@ def build_parser() -> argparse.ArgumentParser:
611
652
  _add_segments(p_v2sd, SFX_SHAPE)
612
653
  p_v2sd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
613
654
  help="Keep source speech in the mix.")
655
+ p_v2sd.add_argument("--ducking", dest="ducking", action="store_true",
656
+ help="Bring the source video's own speech into the mix and duck "
657
+ "the generated bed under it. Off by default, so by default "
658
+ "the result is the generated music and effects alone and "
659
+ "there is no music_processed stem.")
614
660
  p_v2sd.add_argument("--no-ducking", dest="no_ducking", action="store_true",
615
- help="Disable automatic ducking (on by default server-side).")
661
+ help="Explicit opt-out. Same as the default; kept so existing "
662
+ "scripts keep working.")
616
663
  p_v2sd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS,
617
664
  default=None, help="Also save an individual stem. Repeatable.")
618
665
  p_v2sd.add_argument("--output", default=None, help="Where to save the combined audio.")
@@ -631,10 +678,13 @@ def build_parser() -> argparse.ArgumentParser:
631
678
  "generated music under it. Off by default, so by default "
632
679
  "the result's audio is the generated music alone. "
633
680
  "Supersedes --preserve-speech.")
681
+ p_v2vm.add_argument("--ducking", dest="ducking", action="store_true",
682
+ help="Duck the music under the voice instead of mixing it in at "
683
+ "a static level. No effect without --keep-original-sound "
684
+ "or --preserve-speech.")
634
685
  p_v2vm.add_argument("--no-ducking", dest="no_ducking", action="store_true",
635
- help="Combine the voice and the music as a static mix instead "
636
- "of a dynamic duck. No effect without "
637
- "--keep-original-sound or --preserve-speech.")
686
+ help="Explicit opt-out. Same as the default; kept so existing "
687
+ "scripts keep working.")
638
688
  p_v2vm.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
639
689
  help="Keep only the source's isolated speech in the mix.")
640
690
  # Same aliasing as video-to-music, and here the endpoint collapses the two
@@ -645,6 +695,7 @@ def build_parser() -> argparse.ArgumentParser:
645
695
  help="Legacy alias for --preserve-speech; no separate stem.")
646
696
  p_v2vm.add_argument("--output", default=None, help="Where to save the scored video.")
647
697
  _add_variants(p_v2vm)
698
+ _add_prompt_influence(p_v2vm)
648
699
  p_v2vm.set_defaults(func=cmd_video_to_video_music)
649
700
 
650
701
  p_v2vfx = sub.add_parser(
@@ -676,10 +727,13 @@ def build_parser() -> argparse.ArgumentParser:
676
727
  "--preserve-speech. This command only.")
677
728
  p_v2vsd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
678
729
  help="Keep only the source's isolated speech in the mix.")
679
- p_v2vsd.add_argument("--no-ducking", dest="no_ducking", action="store_true",
680
- help="Combine the voice and the generated bed as a static mix "
681
- "instead of a dynamic duck. No effect without "
730
+ p_v2vsd.add_argument("--ducking", dest="ducking", action="store_true",
731
+ help="Duck the generated bed under the voice instead of mixing "
732
+ "it in at a static level. No effect without "
682
733
  "--keep-original-sound or --preserve-speech.")
734
+ p_v2vsd.add_argument("--no-ducking", dest="no_ducking", action="store_true",
735
+ help="Explicit opt-out. Same as the default; kept so existing "
736
+ "scripts keep working.")
683
737
  p_v2vsd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS,
684
738
  default=None, help="Also save an individual stem. Repeatable.")
685
739
  p_v2vsd.add_argument("--output", default=None, help="Where to save the combined video.")
@@ -369,15 +369,25 @@ def test_video_to_sound_ducking_absent_omits_field(tmp_path):
369
369
  )
370
370
  run(["video-to-sound", "--video-url", "http://x/y.mp4",
371
371
  "--output", str(tmp_path / "s.wav")])
372
- # ducking is default-ON server-side: an unset --no-ducking must forward
373
- # `None`, not `False`, so the field must be entirely absent from the
374
- # form-encoded body (per build_v2s_parts).
372
+ # ducking is default-OFF server-side, and neither flag was passed, so the
373
+ # CLI must forward `None`, not `False` — the field has to be absent from
374
+ # the form-encoded body (per build_v2s_parts) and let the server decide.
375
375
  body = route.calls.last.request.content.decode()
376
376
  assert "ducking=" not in body
377
377
 
378
378
 
379
379
  @respx.mock
380
- def test_video_to_sound_no_ducking_sets_false(tmp_path):
380
+ @pytest.mark.parametrize(
381
+ "flag, wire",
382
+ [
383
+ # --ducking is the direction that does something now that the server
384
+ # default is off; --no-ducking predates the flip, still parses so
385
+ # existing scripts do not break, and states the default explicitly.
386
+ ("--ducking", "ducking=true"),
387
+ ("--no-ducking", "ducking=false"),
388
+ ],
389
+ )
390
+ def test_video_to_sound_ducking_flags(tmp_path, flag, wire):
381
391
  route = respx.post(f"{BASE}/v1/video-to-sound").mock(
382
392
  return_value=httpx.Response(200, json={"task_id": "sd4", "status": "processing"})
383
393
  )
@@ -388,9 +398,15 @@ def test_video_to_sound_no_ducking_sets_false(tmp_path):
388
398
  return_value=httpx.Response(200, content=b"MIXED")
389
399
  )
390
400
  run(["video-to-sound", "--video-url", "http://x/y.mp4",
391
- "--output", str(tmp_path / "s.wav"), "--no-ducking"])
401
+ "--output", str(tmp_path / "s.wav"), flag])
392
402
  body = route.calls.last.request.content.decode()
393
- assert "ducking=false" in body
403
+ assert wire in body
404
+
405
+
406
+ def test_video_to_sound_rejects_both_ducking_flags(tmp_path):
407
+ with pytest.raises(SystemExit):
408
+ run(["video-to-sound", "--video-url", "http://x/y.mp4",
409
+ "--output", str(tmp_path / "s.wav"), "--ducking", "--no-ducking"])
394
410
 
395
411
 
396
412
  @respx.mock
@@ -1080,3 +1096,64 @@ def test_video_to_video_commands_are_listed_in_top_level_help(command, capsys):
1080
1096
  with pytest.raises(SystemExit):
1081
1097
  main(["--help"])
1082
1098
  assert command in capsys.readouterr().out
1099
+
1100
+
1101
+ # --- --prompt-influence -------------------------------------------------------
1102
+ #
1103
+ # Only video-to-music and video-to-video-music offer the flag — the API
1104
+ # accepts prompt_influence nowhere else. Unset forwards None (field absent,
1105
+ # API default 0.5 stands); 0 is a real value and must be sent as 0.0.
1106
+
1107
+
1108
+ @respx.mock
1109
+ def test_video_to_music_prompt_influence_rides_the_streaming_path(tmp_path):
1110
+ """prompt_influence is a generation parameter, valid on stream and async
1111
+ alike, so unlike --format wav it must NOT force the async path."""
1112
+ route = respx.post(f"{BASE}/v1/video-to-music").mock(
1113
+ return_value=httpx.Response(200, text=_music_stream_body())
1114
+ )
1115
+ run(["video-to-music", "--video-url", "http://x/y.mp4",
1116
+ "--prompt-influence", "0.8", "--output", str(tmp_path / "song.m4a")])
1117
+ body = route.calls.last.request.content.decode()
1118
+ assert "prompt_influence=0.8" in body
1119
+
1120
+
1121
+ @respx.mock
1122
+ def test_video_to_music_prompt_influence_zero_is_sent(tmp_path):
1123
+ """0 means "let the video lead entirely" — a real request, distinct from
1124
+ unset, so it goes on the wire (as 0.0: argparse's float parse)."""
1125
+ route = respx.post(f"{BASE}/v1/video-to-music").mock(
1126
+ return_value=httpx.Response(200, text=_music_stream_body())
1127
+ )
1128
+ run(["video-to-music", "--video-url", "http://x/y.mp4",
1129
+ "--prompt-influence", "0", "--output", str(tmp_path / "song.m4a")])
1130
+ body = route.calls.last.request.content.decode()
1131
+ assert "prompt_influence=0.0" in body
1132
+
1133
+
1134
+ @respx.mock
1135
+ def test_video_to_music_omits_prompt_influence_when_unset(tmp_path):
1136
+ route = respx.post(f"{BASE}/v1/video-to-music").mock(
1137
+ return_value=httpx.Response(200, text=_music_stream_body())
1138
+ )
1139
+ run(["video-to-music", "--video-url", "http://x/y.mp4",
1140
+ "--output", str(tmp_path / "song.m4a")])
1141
+ # Absent, not "None" and not an explicit 0.5 pinning the API's default.
1142
+ assert b"prompt_influence" not in route.calls.last.request.content
1143
+
1144
+
1145
+ @respx.mock
1146
+ def test_video_to_video_music_prompt_influence_reaches_the_request_body(tmp_path):
1147
+ route = _mock_video_task("video-to-video-music", "vmpi1", "video_to_video_music")
1148
+ run(["video-to-video-music", "--video-url", "http://x/y.mp4",
1149
+ "--prompt-influence", "0.3", "--output", str(tmp_path / "s.mp4")])
1150
+ body = route.calls.last.request.content.decode()
1151
+ assert "prompt_influence=0.3" in body
1152
+
1153
+
1154
+ @respx.mock
1155
+ def test_video_to_video_music_omits_prompt_influence_when_unset(tmp_path):
1156
+ route = _mock_video_task("video-to-video-music", "vmpi2", "video_to_video_music")
1157
+ run(["video-to-video-music", "--video-url", "http://x/y.mp4",
1158
+ "--output", str(tmp_path / "s.mp4")])
1159
+ assert b"prompt_influence" not in route.calls.last.request.content
File without changes
File without changes