sonilo-cli 0.1.0__py3-none-any.whl
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.
sonilo_cli/__init__.py
ADDED
sonilo_cli/__main__.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, List, NoReturn, Optional
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from sonilo import Sonilo
|
|
13
|
+
from sonilo.errors import SoniloError
|
|
14
|
+
|
|
15
|
+
from sonilo_cli import __version__
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Parser(argparse.ArgumentParser):
|
|
19
|
+
"""ArgumentParser that fails with `sonilo: <msg>` and exit code 1."""
|
|
20
|
+
|
|
21
|
+
def error(self, message: str) -> NoReturn: # noqa: D401
|
|
22
|
+
sys.stderr.write(f"sonilo: {message}\n")
|
|
23
|
+
raise SystemExit(1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fail(message: str) -> NoReturn:
|
|
27
|
+
sys.stderr.write(f"sonilo: {message}\n")
|
|
28
|
+
raise SystemExit(1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _print_json(value: Any) -> None:
|
|
32
|
+
print(json.dumps(value, indent=2))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _wrote(path: Any, size: int) -> None:
|
|
36
|
+
print(f"Wrote {path} ({size:,} bytes)")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_client(api_key: Optional[str]) -> Sonilo:
|
|
40
|
+
key = api_key or os.environ.get("SONILO_API_KEY")
|
|
41
|
+
if not key:
|
|
42
|
+
_fail(
|
|
43
|
+
"no API key — pass --api-key <key> or set the "
|
|
44
|
+
"SONILO_API_KEY environment variable"
|
|
45
|
+
)
|
|
46
|
+
return Sonilo(api_key=key)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_account(client: Sonilo, args: argparse.Namespace) -> None:
|
|
50
|
+
_print_json(client.account.services())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_usage(client: Sonilo, args: argparse.Namespace) -> None:
|
|
54
|
+
_print_json(client.account.usage(days=args.days))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _music_output(args: argparse.Namespace, fmt: str) -> str:
|
|
58
|
+
return args.output if args.output is not None else f"output.{fmt}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None:
|
|
62
|
+
fmt = args.format
|
|
63
|
+
use_async = args.use_async or fmt == "wav"
|
|
64
|
+
out = _music_output(args, fmt)
|
|
65
|
+
if use_async:
|
|
66
|
+
result = client.text_to_music.generate_async(
|
|
67
|
+
prompt=args.prompt,
|
|
68
|
+
duration=args.duration,
|
|
69
|
+
output_format="wav" if fmt == "wav" else None,
|
|
70
|
+
)
|
|
71
|
+
path = result.save(out)
|
|
72
|
+
_wrote(path, path.stat().st_size)
|
|
73
|
+
else:
|
|
74
|
+
track = client.text_to_music.generate(prompt=args.prompt, duration=args.duration)
|
|
75
|
+
path = track.save(out)
|
|
76
|
+
_wrote(path, len(track.audio))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None:
|
|
80
|
+
fmt = args.format
|
|
81
|
+
use_async = args.use_async or fmt == "wav" or args.isolate_vocals or args.preserve_speech
|
|
82
|
+
out = _music_output(args, fmt)
|
|
83
|
+
if use_async:
|
|
84
|
+
result = client.video_to_music.generate_async(
|
|
85
|
+
video=args.video,
|
|
86
|
+
video_url=args.video_url,
|
|
87
|
+
prompt=args.prompt,
|
|
88
|
+
isolate_vocals=args.isolate_vocals or None,
|
|
89
|
+
preserve_speech=args.preserve_speech or None,
|
|
90
|
+
output_format="wav" if fmt == "wav" else None,
|
|
91
|
+
)
|
|
92
|
+
path = result.save(out)
|
|
93
|
+
_wrote(path, path.stat().st_size)
|
|
94
|
+
else:
|
|
95
|
+
track = client.video_to_music.generate(
|
|
96
|
+
video=args.video, video_url=args.video_url, prompt=args.prompt
|
|
97
|
+
)
|
|
98
|
+
path = track.save(out)
|
|
99
|
+
_wrote(path, len(track.audio))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_SFX_FORMATS = ["wav", "mp3", "aac", "flac"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_text_to_sfx(client: Sonilo, args: argparse.Namespace) -> None:
|
|
106
|
+
out = args.output if args.output is not None else f"output.{args.format}"
|
|
107
|
+
result = client.text_to_sfx.generate(
|
|
108
|
+
prompt=args.prompt, duration=args.duration, audio_format=args.format
|
|
109
|
+
)
|
|
110
|
+
path = result.save(out)
|
|
111
|
+
_wrote(path, path.stat().st_size)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_video_to_sfx(client: Sonilo, args: argparse.Namespace) -> None:
|
|
115
|
+
out = args.output if args.output is not None else f"output.{args.format}"
|
|
116
|
+
result = client.video_to_sfx.generate(
|
|
117
|
+
video=args.video, video_url=args.video_url,
|
|
118
|
+
prompt=args.prompt, audio_format=args.format,
|
|
119
|
+
)
|
|
120
|
+
path = result.save(out)
|
|
121
|
+
_wrote(path, path.stat().st_size)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_SOUND_STEMS = ["music", "music_processed", "sfx"]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _stem_path(out: str, stem: str, media: Any) -> str:
|
|
128
|
+
base = Path(out)
|
|
129
|
+
ext = ""
|
|
130
|
+
if media is not None and getattr(media, "url", None):
|
|
131
|
+
ext = Path(urlparse(media.url).path).suffix
|
|
132
|
+
return str(base.with_name(f"{base.stem}.{stem}{ext or base.suffix}"))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _run_sound(client: Sonilo, args: argparse.Namespace, resource: Any, default_ext: str) -> None:
|
|
136
|
+
out = args.output if args.output is not None else f"output.{default_ext}"
|
|
137
|
+
result = resource.generate(
|
|
138
|
+
video=args.video,
|
|
139
|
+
video_url=args.video_url,
|
|
140
|
+
music_prompt=args.music_prompt,
|
|
141
|
+
sfx_prompt=args.sfx_prompt,
|
|
142
|
+
preserve_speech=True if args.preserve_speech else None,
|
|
143
|
+
ducking=False if args.no_ducking else None,
|
|
144
|
+
)
|
|
145
|
+
path = result.save(out)
|
|
146
|
+
_wrote(path, path.stat().st_size)
|
|
147
|
+
for stem in args.stems or []:
|
|
148
|
+
stem_path = _stem_path(out, stem, getattr(result, stem, None))
|
|
149
|
+
saved = result.save_stem(stem_path, which=stem)
|
|
150
|
+
_wrote(saved, saved.stat().st_size)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cmd_video_to_sound(client: Sonilo, args: argparse.Namespace) -> None:
|
|
154
|
+
_run_sound(client, args, client.video_to_sound, "wav")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_video_to_video_sound(client: Sonilo, args: argparse.Namespace) -> None:
|
|
158
|
+
_run_sound(client, args, client.video_to_video_sound, "mp4")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _identity(body: Any) -> Any:
|
|
162
|
+
return body
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cmd_tasks_get(client: Sonilo, args: argparse.Namespace) -> None:
|
|
166
|
+
_print_json(client.tasks.get(args.task_id, parser=_identity))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def cmd_tasks_wait(client: Sonilo, args: argparse.Namespace) -> None:
|
|
170
|
+
deadline = time.monotonic() + args.timeout
|
|
171
|
+
while True:
|
|
172
|
+
body = client.tasks.get(args.task_id, parser=_identity)
|
|
173
|
+
status = body.get("status") if isinstance(body, dict) else None
|
|
174
|
+
if status == "succeeded":
|
|
175
|
+
_print_json(body)
|
|
176
|
+
return
|
|
177
|
+
if status == "failed":
|
|
178
|
+
_print_json(body)
|
|
179
|
+
raise SystemExit(1)
|
|
180
|
+
remaining = deadline - time.monotonic()
|
|
181
|
+
if remaining <= 0:
|
|
182
|
+
_fail(f"timed out after {args.timeout}s waiting for task {args.task_id}")
|
|
183
|
+
time.sleep(min(args.poll_interval, max(0.0, remaining)))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _add_global(parser: argparse.ArgumentParser) -> None:
|
|
187
|
+
# default=SUPPRESS (not None) is required here: argparse subparsers parse
|
|
188
|
+
# into a *fresh* namespace and then copy every key back onto the parent
|
|
189
|
+
# namespace (see cpython's _SubParsersAction.__call__), so a subparser
|
|
190
|
+
# default of None would clobber an --api-key already set on the parent
|
|
191
|
+
# parser (e.g. `sonilo --api-key X account`). With SUPPRESS, the key is
|
|
192
|
+
# only ever set when the flag is actually given, so it never overwrites
|
|
193
|
+
# a value set elsewhere with a default.
|
|
194
|
+
parser.add_argument(
|
|
195
|
+
"--api-key",
|
|
196
|
+
dest="api_key",
|
|
197
|
+
default=argparse.SUPPRESS,
|
|
198
|
+
help="Overrides the SONILO_API_KEY environment variable.",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _add_video_source(parser: argparse.ArgumentParser) -> None:
|
|
203
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
204
|
+
group.add_argument("--video", default=None, help="Local video file to score.")
|
|
205
|
+
group.add_argument("--video-url", dest="video_url", default=None,
|
|
206
|
+
help="Remote video URL to score.")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
210
|
+
parser = _Parser(prog="sonilo", description="Command-line interface for the Sonilo API")
|
|
211
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
212
|
+
_add_global(parser)
|
|
213
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
214
|
+
|
|
215
|
+
p_account = sub.add_parser("account", help="Show plan limits and available services")
|
|
216
|
+
_add_global(p_account)
|
|
217
|
+
p_account.set_defaults(func=cmd_account)
|
|
218
|
+
|
|
219
|
+
p_usage = sub.add_parser("usage", help="Show usage summary")
|
|
220
|
+
_add_global(p_usage)
|
|
221
|
+
p_usage.add_argument("--days", type=int, default=None, help="Look-back window in days.")
|
|
222
|
+
p_usage.set_defaults(func=cmd_usage)
|
|
223
|
+
|
|
224
|
+
p_t2m = sub.add_parser("text-to-music", help="Generate music from a text prompt")
|
|
225
|
+
_add_global(p_t2m)
|
|
226
|
+
p_t2m.add_argument("--prompt", required=True, help="What the music should sound like.")
|
|
227
|
+
p_t2m.add_argument("--duration", type=int, required=True, help="Track length in seconds.")
|
|
228
|
+
p_t2m.add_argument("--output", default=None, help="Where to save the audio.")
|
|
229
|
+
p_t2m.add_argument("--format", choices=["m4a", "wav"], default="m4a",
|
|
230
|
+
help="Output container. wav forces async. Default: m4a")
|
|
231
|
+
p_t2m.add_argument("--async", dest="use_async", action="store_true",
|
|
232
|
+
help="Submit and poll instead of streaming.")
|
|
233
|
+
p_t2m.set_defaults(func=cmd_text_to_music)
|
|
234
|
+
|
|
235
|
+
p_v2m = sub.add_parser("video-to-music", help="Generate music matched to a video")
|
|
236
|
+
_add_global(p_v2m)
|
|
237
|
+
_add_video_source(p_v2m)
|
|
238
|
+
p_v2m.add_argument("--prompt", default=None, help="Optional creative direction.")
|
|
239
|
+
p_v2m.add_argument("--output", default=None, help="Where to save the audio.")
|
|
240
|
+
p_v2m.add_argument("--format", choices=["m4a", "wav"], default="m4a",
|
|
241
|
+
help="Output container. wav forces async.")
|
|
242
|
+
p_v2m.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
|
|
243
|
+
help="Split out a vocals-only stem. Forces async.")
|
|
244
|
+
p_v2m.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
|
|
245
|
+
help="Keep source speech in the mix. Forces async.")
|
|
246
|
+
p_v2m.add_argument("--async", dest="use_async", action="store_true",
|
|
247
|
+
help="Submit and poll instead of streaming.")
|
|
248
|
+
p_v2m.set_defaults(func=cmd_video_to_music)
|
|
249
|
+
|
|
250
|
+
p_t2s = sub.add_parser("text-to-sfx", help="Generate a sound effect from a text prompt")
|
|
251
|
+
_add_global(p_t2s)
|
|
252
|
+
p_t2s.add_argument("--prompt", required=True, help="What the sound effect should be.")
|
|
253
|
+
p_t2s.add_argument("--duration", type=int, required=True, help="Effect length in seconds.")
|
|
254
|
+
p_t2s.add_argument("--output", default=None, help="Where to save the audio.")
|
|
255
|
+
p_t2s.add_argument("--format", choices=_SFX_FORMATS, default="wav",
|
|
256
|
+
help="Output format. Default: wav")
|
|
257
|
+
p_t2s.set_defaults(func=cmd_text_to_sfx)
|
|
258
|
+
|
|
259
|
+
p_v2s = sub.add_parser("video-to-sfx", help="Generate a sound effect matched to a video")
|
|
260
|
+
_add_global(p_v2s)
|
|
261
|
+
_add_video_source(p_v2s)
|
|
262
|
+
p_v2s.add_argument("--prompt", default=None, help="Optional creative direction.")
|
|
263
|
+
p_v2s.add_argument("--output", default=None, help="Where to save the audio.")
|
|
264
|
+
p_v2s.add_argument("--format", choices=_SFX_FORMATS, default="wav",
|
|
265
|
+
help="Output format. Default: wav")
|
|
266
|
+
p_v2s.set_defaults(func=cmd_video_to_sfx)
|
|
267
|
+
|
|
268
|
+
p_v2sd = sub.add_parser(
|
|
269
|
+
"video-to-sound", help="Generate matched music+sfx audio for a video"
|
|
270
|
+
)
|
|
271
|
+
_add_global(p_v2sd)
|
|
272
|
+
_add_video_source(p_v2sd)
|
|
273
|
+
p_v2sd.add_argument("--music-prompt", dest="music_prompt", default=None,
|
|
274
|
+
help="Optional creative direction for the music bed.")
|
|
275
|
+
p_v2sd.add_argument("--sfx-prompt", dest="sfx_prompt", default=None,
|
|
276
|
+
help="Optional creative direction for the sound effects.")
|
|
277
|
+
p_v2sd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
|
|
278
|
+
help="Keep source speech in the mix.")
|
|
279
|
+
p_v2sd.add_argument("--no-ducking", dest="no_ducking", action="store_true",
|
|
280
|
+
help="Disable automatic ducking (on by default server-side).")
|
|
281
|
+
p_v2sd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS,
|
|
282
|
+
default=None, help="Also save an individual stem. Repeatable.")
|
|
283
|
+
p_v2sd.add_argument("--output", default=None, help="Where to save the combined audio.")
|
|
284
|
+
p_v2sd.set_defaults(func=cmd_video_to_sound)
|
|
285
|
+
|
|
286
|
+
p_v2vsd = sub.add_parser(
|
|
287
|
+
"video-to-video-sound", help="Generate matched music+sfx muxed into the source video"
|
|
288
|
+
)
|
|
289
|
+
_add_global(p_v2vsd)
|
|
290
|
+
_add_video_source(p_v2vsd)
|
|
291
|
+
p_v2vsd.add_argument("--music-prompt", dest="music_prompt", default=None,
|
|
292
|
+
help="Optional creative direction for the music bed.")
|
|
293
|
+
p_v2vsd.add_argument("--sfx-prompt", dest="sfx_prompt", default=None,
|
|
294
|
+
help="Optional creative direction for the sound effects.")
|
|
295
|
+
p_v2vsd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
|
|
296
|
+
help="Keep source speech in the mix.")
|
|
297
|
+
p_v2vsd.add_argument("--no-ducking", dest="no_ducking", action="store_true",
|
|
298
|
+
help="Disable automatic ducking (on by default server-side).")
|
|
299
|
+
p_v2vsd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS,
|
|
300
|
+
default=None, help="Also save an individual stem. Repeatable.")
|
|
301
|
+
p_v2vsd.add_argument("--output", default=None, help="Where to save the combined video.")
|
|
302
|
+
p_v2vsd.set_defaults(func=cmd_video_to_video_sound)
|
|
303
|
+
|
|
304
|
+
p_tasks = sub.add_parser("tasks", help="Inspect async tasks")
|
|
305
|
+
_add_global(p_tasks)
|
|
306
|
+
tsub = p_tasks.add_subparsers(dest="tasks_command", metavar="<get|wait>")
|
|
307
|
+
|
|
308
|
+
p_get = tsub.add_parser("get", help="Fetch the current state of an async task")
|
|
309
|
+
_add_global(p_get)
|
|
310
|
+
p_get.add_argument("task_id", help="The task id to fetch.")
|
|
311
|
+
p_get.set_defaults(func=cmd_tasks_get)
|
|
312
|
+
|
|
313
|
+
p_wait = tsub.add_parser("wait", help="Poll an async task until it finishes")
|
|
314
|
+
_add_global(p_wait)
|
|
315
|
+
p_wait.add_argument("task_id", help="The task id to poll.")
|
|
316
|
+
p_wait.add_argument("--poll-interval", dest="poll_interval", type=float, default=2.0,
|
|
317
|
+
help="Seconds between polls. Default: 2")
|
|
318
|
+
p_wait.add_argument("--timeout", type=float, default=600.0,
|
|
319
|
+
help="Give up after this many seconds. Default: 600")
|
|
320
|
+
p_wait.set_defaults(func=cmd_tasks_wait)
|
|
321
|
+
|
|
322
|
+
return parser
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def main(argv: Optional[List[str]] = None) -> None:
|
|
326
|
+
parser = build_parser()
|
|
327
|
+
args = parser.parse_args(argv)
|
|
328
|
+
func = getattr(args, "func", None)
|
|
329
|
+
if func is None:
|
|
330
|
+
parser.error("missing command (try `sonilo --help`)")
|
|
331
|
+
client = build_client(getattr(args, "api_key", None))
|
|
332
|
+
try:
|
|
333
|
+
func(client, args)
|
|
334
|
+
except SoniloError as exc:
|
|
335
|
+
_fail(str(exc))
|
|
336
|
+
finally:
|
|
337
|
+
client.close()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == "__main__":
|
|
341
|
+
main()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sonilo-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line interface for the Sonilo API: generate music and sound effects from text or video
|
|
5
|
+
Project-URL: Repository, https://github.com/sonilo-ai/sonilo-python
|
|
6
|
+
Author: Sonilo AI
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: ai,cli,music,sfx,sonilo,text-to-music,video-to-music
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: sonilo<0.6,>=0.5
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# sonilo-cli
|
|
18
|
+
|
|
19
|
+
Command-line interface for the [Sonilo API](https://github.com/sonilo-ai/sonilo-python) — generate music and sound effects from text or video.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
pip install sonilo-cli
|
|
24
|
+
|
|
25
|
+
## Auth
|
|
26
|
+
|
|
27
|
+
Set your API key once:
|
|
28
|
+
|
|
29
|
+
export SONILO_API_KEY=sk-...
|
|
30
|
+
|
|
31
|
+
or pass `--api-key sk-...` on any command.
|
|
32
|
+
|
|
33
|
+
## Commands
|
|
34
|
+
|
|
35
|
+
sonilo account # plan limits and available services
|
|
36
|
+
sonilo usage --days 7 # usage summary
|
|
37
|
+
sonilo text-to-music --prompt "warm lo-fi piano, rain" --duration 30
|
|
38
|
+
sonilo video-to-music --video clip.mp4 --prompt "tense synths" --format wav
|
|
39
|
+
sonilo text-to-sfx --prompt "glass shattering on concrete" --duration 3
|
|
40
|
+
sonilo video-to-sfx --video clip.mp4 --output whoosh.wav
|
|
41
|
+
sonilo video-to-sound --video clip.mp4 \
|
|
42
|
+
--music-prompt "uplifting orchestral score" --sfx-prompt "match the on-screen action"
|
|
43
|
+
sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
|
|
44
|
+
sonilo tasks get <task-id>
|
|
45
|
+
sonilo tasks wait <task-id> --poll-interval 2 --timeout 600
|
|
46
|
+
|
|
47
|
+
### Notes
|
|
48
|
+
|
|
49
|
+
- `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`,
|
|
50
|
+
`--isolate-vocals`, and `--preserve-speech` each switch to the async submit-and-poll path.
|
|
51
|
+
- `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
|
|
52
|
+
- Output defaults to `./output.<ext>`; override with `--output`.
|
|
53
|
+
|
|
54
|
+
### Combined soundtracks
|
|
55
|
+
|
|
56
|
+
`video-to-sound` and `video-to-video-sound` score a clip with a music bed *and* sound effects in one
|
|
57
|
+
call (one charge, instead of chaining two requests). Both are async-only and take the same options —
|
|
58
|
+
they differ only in what comes back: `video-to-sound` writes the mixed **audio** (default
|
|
59
|
+
`output.wav`), `video-to-video-sound` writes the **source video with that audio muxed in** (default
|
|
60
|
+
`output.mp4`).
|
|
61
|
+
|
|
62
|
+
sonilo video-to-sound --video clip.mp4 \
|
|
63
|
+
--music-prompt "uplifting orchestral score" \
|
|
64
|
+
--sfx-prompt "match the on-screen action" \
|
|
65
|
+
--output soundtrack.wav --stem music --stem sfx
|
|
66
|
+
|
|
67
|
+
- `--music-prompt` / `--sfx-prompt` steer the two layers separately; both are optional.
|
|
68
|
+
- `--preserve-speech` keeps speech from the source video in the mix.
|
|
69
|
+
- **Ducking is on by default** (music dips under speech). Pass `--no-ducking` to opt out — omitting
|
|
70
|
+
the flag leaves the server default untouched.
|
|
71
|
+
- `--stem` is repeatable (`music`, `music_processed`, `sfx`) and saves the individual layers next to
|
|
72
|
+
the combined output, so you can re-balance the mix yourself. With `--output soundtrack.wav`, the
|
|
73
|
+
music stem lands at `soundtrack.music.m4a`. `music_processed` exists only when `--preserve-speech`
|
|
74
|
+
or ducking altered the music bed.
|
|
75
|
+
|
|
76
|
+
## Free trial
|
|
77
|
+
|
|
78
|
+
Accounts created through self-serve signup start with free runs on every endpoint — no card
|
|
79
|
+
required:
|
|
80
|
+
|
|
81
|
+
| Free runs | Endpoints |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| 2 each | text-to-music, text-to-sfx, audio-ducking |
|
|
84
|
+
| 1 each | video-to-music, video-to-sfx, video-to-video-music, video-to-video-sfx, video-to-sound, video-to-video-sound |
|
|
85
|
+
|
|
86
|
+
Once an endpoint's free runs are used up, calls to it bill at the normal rate. `sonilo account`
|
|
87
|
+
shows the services available to your key.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
sonilo_cli/__init__.py,sha256=tr2-zP7nAwYvPIwHWMSf1BmGnXbB-5N7mN_ONxhPZAw,49
|
|
2
|
+
sonilo_cli/__main__.py,sha256=KFjNMF0JSDTAcuI042ytQ55er3EZkjIJB21G90mWvdQ,14141
|
|
3
|
+
sonilo_cli-0.1.0.dist-info/METADATA,sha256=jV8ki0mlRROORdaDL-x1jlcF-EOOuLO_KJU1jo4D7BU,3724
|
|
4
|
+
sonilo_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
5
|
+
sonilo_cli-0.1.0.dist-info/entry_points.txt,sha256=WJ-VCW2tp4aZfTHtrhnpIhABEVt9gdiVxI7Vl3Tk4OA,52
|
|
6
|
+
sonilo_cli-0.1.0.dist-info/licenses/LICENSE,sha256=CWNa0JUhCcgEbCAYa96vZji3ML97PzrjKNRGocl1N2I,1066
|
|
7
|
+
sonilo_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sonilo AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|