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
@@ -0,0 +1,3 @@
1
+ __version__ = "0.1.0"
2
+
3
+ __all__ = ["__version__"]
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sonilo = sonilo_cli.__main__:main
@@ -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.