sonilo-cli 0.1.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.
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .DS_Store
8
+ /docs/
9
+ .superpowers/
@@ -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.
@@ -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,71 @@
1
+ # sonilo-cli
2
+
3
+ Command-line interface for the [Sonilo API](https://github.com/sonilo-ai/sonilo-python) — generate music and sound effects from text or video.
4
+
5
+ ## Install
6
+
7
+ pip install sonilo-cli
8
+
9
+ ## Auth
10
+
11
+ Set your API key once:
12
+
13
+ export SONILO_API_KEY=sk-...
14
+
15
+ or pass `--api-key sk-...` on any command.
16
+
17
+ ## Commands
18
+
19
+ sonilo account # plan limits and available services
20
+ sonilo usage --days 7 # usage summary
21
+ sonilo text-to-music --prompt "warm lo-fi piano, rain" --duration 30
22
+ sonilo video-to-music --video clip.mp4 --prompt "tense synths" --format wav
23
+ sonilo text-to-sfx --prompt "glass shattering on concrete" --duration 3
24
+ sonilo video-to-sfx --video clip.mp4 --output whoosh.wav
25
+ sonilo video-to-sound --video clip.mp4 \
26
+ --music-prompt "uplifting orchestral score" --sfx-prompt "match the on-screen action"
27
+ sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
28
+ sonilo tasks get <task-id>
29
+ sonilo tasks wait <task-id> --poll-interval 2 --timeout 600
30
+
31
+ ### Notes
32
+
33
+ - `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`,
34
+ `--isolate-vocals`, and `--preserve-speech` each switch to the async submit-and-poll path.
35
+ - `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
36
+ - Output defaults to `./output.<ext>`; override with `--output`.
37
+
38
+ ### Combined soundtracks
39
+
40
+ `video-to-sound` and `video-to-video-sound` score a clip with a music bed *and* sound effects in one
41
+ call (one charge, instead of chaining two requests). Both are async-only and take the same options —
42
+ they differ only in what comes back: `video-to-sound` writes the mixed **audio** (default
43
+ `output.wav`), `video-to-video-sound` writes the **source video with that audio muxed in** (default
44
+ `output.mp4`).
45
+
46
+ sonilo video-to-sound --video clip.mp4 \
47
+ --music-prompt "uplifting orchestral score" \
48
+ --sfx-prompt "match the on-screen action" \
49
+ --output soundtrack.wav --stem music --stem sfx
50
+
51
+ - `--music-prompt` / `--sfx-prompt` steer the two layers separately; both are optional.
52
+ - `--preserve-speech` keeps speech from the source video in the mix.
53
+ - **Ducking is on by default** (music dips under speech). Pass `--no-ducking` to opt out — omitting
54
+ the flag leaves the server default untouched.
55
+ - `--stem` is repeatable (`music`, `music_processed`, `sfx`) and saves the individual layers next to
56
+ the combined output, so you can re-balance the mix yourself. With `--output soundtrack.wav`, the
57
+ music stem lands at `soundtrack.music.m4a`. `music_processed` exists only when `--preserve-speech`
58
+ or ducking altered the music bed.
59
+
60
+ ## Free trial
61
+
62
+ Accounts created through self-serve signup start with free runs on every endpoint — no card
63
+ required:
64
+
65
+ | Free runs | Endpoints |
66
+ | --- | --- |
67
+ | 2 each | text-to-music, text-to-sfx, audio-ducking |
68
+ | 1 each | video-to-music, video-to-sfx, video-to-video-music, video-to-video-sfx, video-to-sound, video-to-video-sound |
69
+
70
+ Once an endpoint's free runs are used up, calls to it bill at the normal rate. `sonilo account`
71
+ shows the services available to your key.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sonilo-cli"
7
+ version = "0.1.0"
8
+ description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Sonilo AI" }]
13
+ dependencies = ["sonilo>=0.5,<0.6"]
14
+ keywords = ["sonilo", "cli", "music", "sfx", "text-to-music", "video-to-music", "ai"]
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/sonilo-ai/sonilo-python"
18
+
19
+ [project.scripts]
20
+ sonilo = "sonilo_cli.__main__:main"
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["pytest>=8", "respx>=0.21"]
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/sonilo_cli"]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ __version__ = "0.1.0"
2
+
3
+ __all__ = ["__version__"]
@@ -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()
File without changes
@@ -0,0 +1,371 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+ import respx
6
+
7
+ from sonilo_cli.__main__ import main
8
+
9
+ BASE = "https://api.sonilo.com"
10
+
11
+
12
+ def run(argv, api_key="sk-test"):
13
+ full = (["--api-key", api_key] if api_key is not None else []) + argv
14
+ main(full)
15
+
16
+
17
+ @respx.mock
18
+ def test_account_prints_json(capsys):
19
+ respx.get(f"{BASE}/v1/account/services").mock(
20
+ return_value=httpx.Response(200, json={"plan": "pro"})
21
+ )
22
+ run(["account"])
23
+ out = json.loads(capsys.readouterr().out)
24
+ assert out == {"plan": "pro"}
25
+
26
+
27
+ @respx.mock
28
+ def test_usage_passes_days(capsys):
29
+ route = respx.get(f"{BASE}/v1/account/usage").mock(
30
+ return_value=httpx.Response(200, json={"days": 7})
31
+ )
32
+ run(["usage", "--days", "7"])
33
+ assert route.calls.last.request.url.params["days"] == "7"
34
+
35
+
36
+ def test_missing_api_key_exits_1(capsys, monkeypatch):
37
+ monkeypatch.delenv("SONILO_API_KEY", raising=False)
38
+ with pytest.raises(SystemExit) as exc:
39
+ main(["account"]) # no --api-key, no env
40
+ assert exc.value.code == 1
41
+ assert "no API key" in capsys.readouterr().err
42
+
43
+
44
+ def test_unknown_command_exits_1(capsys):
45
+ with pytest.raises(SystemExit) as exc:
46
+ main(["--api-key", "sk-test", "frobnicate"])
47
+ assert exc.value.code == 1
48
+ assert "sonilo:" in capsys.readouterr().err
49
+
50
+
51
+ @respx.mock
52
+ def test_api_error_has_no_traceback(capsys):
53
+ respx.get(f"{BASE}/v1/account/services").mock(
54
+ return_value=httpx.Response(401, json={"error": {"message": "bad key"}})
55
+ )
56
+ with pytest.raises(SystemExit) as exc:
57
+ run(["account"])
58
+ assert exc.value.code == 1
59
+ err = capsys.readouterr().err
60
+ assert err.startswith("sonilo:")
61
+ assert "Traceback" not in err
62
+
63
+
64
+ def _music_stream_body():
65
+ # Minimal NDJSON stream matching sonilo._streaming.collect_track: an
66
+ # audio_chunk event (base64 "data", decoded by iter_events) followed by
67
+ # the terminal "complete" event. Confirmed against tests/test_streaming.py.
68
+ import base64
69
+
70
+ chunk = {"type": "audio_chunk", "data": base64.b64encode(b"ID3xx").decode()}
71
+ done = {"type": "complete"}
72
+ return "\n".join(json.dumps(e) for e in (chunk, done)) + "\n"
73
+
74
+
75
+ @respx.mock
76
+ def test_text_to_music_streaming_saves_m4a(tmp_path, capsys):
77
+ respx.post(f"{BASE}/v1/text-to-music").mock(
78
+ return_value=httpx.Response(200, text=_music_stream_body())
79
+ )
80
+ out = tmp_path / "song.m4a"
81
+ run(["text-to-music", "--prompt", "lofi", "--duration", "10", "--output", str(out)])
82
+ assert out.read_bytes() == b"ID3xx"
83
+ assert "Wrote" in capsys.readouterr().out
84
+
85
+
86
+ @respx.mock
87
+ def test_text_to_music_wav_forces_async(tmp_path):
88
+ submit = respx.post(f"{BASE}/v1/text-to-music").mock(
89
+ return_value=httpx.Response(200, json={"task_id": "t1", "status": "processing"})
90
+ )
91
+ respx.get(f"{BASE}/v1/tasks/t1").mock(
92
+ return_value=httpx.Response(200, json={
93
+ "task_id": "t1", "type": "text_to_music", "status": "succeeded",
94
+ "audio": [{"stream_index": 0, "url": "https://r2.example.com/a.wav",
95
+ "content_type": "audio/wav", "file_size": 3}],
96
+ })
97
+ )
98
+ respx.get("https://r2.example.com/a.wav").mock(
99
+ return_value=httpx.Response(200, content=b"RIF")
100
+ )
101
+ out = tmp_path / "song.wav"
102
+ run(["text-to-music", "--prompt", "lofi", "--duration", "10",
103
+ "--format", "wav", "--output", str(out)])
104
+ # Async path used: a submit POST happened AND polling GET happened.
105
+ assert submit.called
106
+ assert out.read_bytes() == b"RIF"
107
+
108
+
109
+ def test_video_to_music_requires_a_video_source():
110
+ with pytest.raises(SystemExit) as exc:
111
+ run(["video-to-music", "--prompt", "x"]) # neither --video nor --video-url
112
+ assert exc.value.code == 1
113
+
114
+
115
+ def test_video_to_music_rejects_both_sources():
116
+ with pytest.raises(SystemExit) as exc:
117
+ run(["video-to-music", "--video", "a.mp4", "--video-url", "http://x/y.mp4"])
118
+ assert exc.value.code == 1
119
+
120
+
121
+ @respx.mock
122
+ def test_text_to_sfx_saves_wav(tmp_path):
123
+ respx.post(f"{BASE}/v1/text-to-sfx").mock(
124
+ return_value=httpx.Response(200, json={"task_id": "s1", "status": "processing"})
125
+ )
126
+ respx.get(f"{BASE}/v1/tasks/s1").mock(
127
+ return_value=httpx.Response(200, json={
128
+ "task_id": "s1", "type": "text_to_sfx", "status": "succeeded",
129
+ "audio": {"url": "https://r2.example.com/s.wav",
130
+ "content_type": "audio/wav", "file_size": 3},
131
+ })
132
+ )
133
+ respx.get("https://r2.example.com/s.wav").mock(
134
+ return_value=httpx.Response(200, content=b"RIF")
135
+ )
136
+ out = tmp_path / "fx.wav"
137
+ run(["text-to-sfx", "--prompt", "glass break", "--duration", "3", "--output", str(out)])
138
+ assert out.read_bytes() == b"RIF"
139
+
140
+
141
+ @respx.mock
142
+ def test_text_to_sfx_format_maps_to_audio_format(tmp_path):
143
+ route = respx.post(f"{BASE}/v1/text-to-sfx").mock(
144
+ return_value=httpx.Response(200, json={"task_id": "s2", "status": "processing"})
145
+ )
146
+ respx.get(f"{BASE}/v1/tasks/s2").mock(
147
+ return_value=httpx.Response(200, json={
148
+ "task_id": "s2", "type": "text_to_sfx", "status": "succeeded",
149
+ "audio": {"url": "https://r2.example.com/s.mp3",
150
+ "content_type": "audio/mpeg", "file_size": 3},
151
+ })
152
+ )
153
+ respx.get("https://r2.example.com/s.mp3").mock(
154
+ return_value=httpx.Response(200, content=b"ID3")
155
+ )
156
+ run(["text-to-sfx", "--prompt", "x", "--duration", "2",
157
+ "--format", "mp3", "--output", str(tmp_path / "fx.mp3")])
158
+ # The request body is form-encoded (per build_sfx_t2s_data/_post_json, and
159
+ # confirmed against tests/test_sfx.py::test_text_to_sfx_submit_posts_form),
160
+ # not JSON. It carries the chosen format under audio_format.
161
+ body = route.calls.last.request.content.decode()
162
+ assert "audio_format=mp3" in body
163
+
164
+
165
+ def test_video_to_sfx_requires_a_video_source():
166
+ with pytest.raises(SystemExit) as exc:
167
+ run(["video-to-sfx", "--prompt", "x"])
168
+ assert exc.value.code == 1
169
+
170
+
171
+ @respx.mock
172
+ def test_tasks_get_prints_raw_json(capsys):
173
+ respx.get(f"{BASE}/v1/tasks/abc").mock(
174
+ return_value=httpx.Response(200, json={"task_id": "abc", "status": "processing"})
175
+ )
176
+ run(["tasks", "get", "abc"])
177
+ assert json.loads(capsys.readouterr().out) == {"task_id": "abc", "status": "processing"}
178
+
179
+
180
+ @respx.mock
181
+ def test_tasks_wait_polls_until_succeeded(capsys):
182
+ respx.get(f"{BASE}/v1/tasks/abc").mock(
183
+ side_effect=[
184
+ httpx.Response(200, json={"task_id": "abc", "status": "processing"}),
185
+ httpx.Response(200, json={"task_id": "abc", "status": "succeeded"}),
186
+ ]
187
+ )
188
+ run(["tasks", "wait", "abc", "--poll-interval", "0"])
189
+ assert json.loads(capsys.readouterr().out)["status"] == "succeeded"
190
+
191
+
192
+ @respx.mock
193
+ def test_tasks_wait_failed_exits_1(capsys):
194
+ respx.get(f"{BASE}/v1/tasks/abc").mock(
195
+ return_value=httpx.Response(200, json={"task_id": "abc", "status": "failed"})
196
+ )
197
+ with pytest.raises(SystemExit) as exc:
198
+ run(["tasks", "wait", "abc", "--poll-interval", "0"])
199
+ assert exc.value.code == 1
200
+
201
+
202
+ def test_tasks_unknown_subcommand_exits_1(capsys):
203
+ with pytest.raises(SystemExit) as exc:
204
+ run(["tasks", "frob", "abc"])
205
+ assert exc.value.code == 1
206
+
207
+
208
+ # --- video-to-sound / video-to-video-sound -------------------------------
209
+ #
210
+ # Fixture shape confirmed against tests/test_video_to_sound.py::SUCCESS_BODY
211
+ # in the SDK repo.
212
+
213
+ SOUND_SUCCESS_BODY = {
214
+ "task_id": "sd1",
215
+ "type": "video_to_sound",
216
+ "status": "succeeded",
217
+ "output_url": "https://r2.example.com/sound.wav",
218
+ "output_type": "audio",
219
+ "output_bytes": 5,
220
+ "music": {"url": "https://r2.example.com/sound.music.m4a",
221
+ "content_type": "audio/mp4", "file_size": 5},
222
+ "sfx": {"url": "https://r2.example.com/sound.sfx.wav",
223
+ "content_type": "audio/wav", "file_size": 3},
224
+ }
225
+
226
+
227
+ def _sound_body(task_id, **overrides):
228
+ return {**SOUND_SUCCESS_BODY, "task_id": task_id, **overrides}
229
+
230
+
231
+ @respx.mock
232
+ def test_video_to_sound_saves_combined_output(tmp_path):
233
+ respx.post(f"{BASE}/v1/video-to-sound").mock(
234
+ return_value=httpx.Response(200, json={"task_id": "sd1", "status": "processing"})
235
+ )
236
+ respx.get(f"{BASE}/v1/tasks/sd1").mock(
237
+ return_value=httpx.Response(200, json=_sound_body("sd1"))
238
+ )
239
+ respx.get("https://r2.example.com/sound.wav").mock(
240
+ return_value=httpx.Response(200, content=b"MIXED")
241
+ )
242
+ out = tmp_path / "s.wav"
243
+ run(["video-to-sound", "--video-url", "http://x/y.mp4", "--output", str(out)])
244
+ assert out.read_bytes() == b"MIXED"
245
+
246
+
247
+ @respx.mock
248
+ def test_video_to_sound_stem_flag_saves_stems_alongside(tmp_path):
249
+ respx.post(f"{BASE}/v1/video-to-sound").mock(
250
+ return_value=httpx.Response(200, json={"task_id": "sd2", "status": "processing"})
251
+ )
252
+ respx.get(f"{BASE}/v1/tasks/sd2").mock(
253
+ return_value=httpx.Response(200, json=_sound_body("sd2"))
254
+ )
255
+ respx.get("https://r2.example.com/sound.wav").mock(
256
+ return_value=httpx.Response(200, content=b"MIXED")
257
+ )
258
+ respx.get("https://r2.example.com/sound.music.m4a").mock(
259
+ return_value=httpx.Response(200, content=b"MUSIC")
260
+ )
261
+ respx.get("https://r2.example.com/sound.sfx.wav").mock(
262
+ return_value=httpx.Response(200, content=b"SFX")
263
+ )
264
+ out = tmp_path / "s.wav"
265
+ run(["video-to-sound", "--video-url", "http://x/y.mp4", "--output", str(out),
266
+ "--stem", "music", "--stem", "sfx"])
267
+ assert (tmp_path / "s.music.m4a").read_bytes() == b"MUSIC"
268
+ assert (tmp_path / "s.sfx.wav").read_bytes() == b"SFX"
269
+
270
+
271
+ @respx.mock
272
+ def test_video_to_sound_ducking_absent_omits_field(tmp_path):
273
+ route = respx.post(f"{BASE}/v1/video-to-sound").mock(
274
+ return_value=httpx.Response(200, json={"task_id": "sd3", "status": "processing"})
275
+ )
276
+ respx.get(f"{BASE}/v1/tasks/sd3").mock(
277
+ return_value=httpx.Response(200, json=_sound_body("sd3"))
278
+ )
279
+ respx.get("https://r2.example.com/sound.wav").mock(
280
+ return_value=httpx.Response(200, content=b"MIXED")
281
+ )
282
+ run(["video-to-sound", "--video-url", "http://x/y.mp4",
283
+ "--output", str(tmp_path / "s.wav")])
284
+ # ducking is default-ON server-side: an unset --no-ducking must forward
285
+ # `None`, not `False`, so the field must be entirely absent from the
286
+ # form-encoded body (per build_v2s_parts).
287
+ body = route.calls.last.request.content.decode()
288
+ assert "ducking=" not in body
289
+
290
+
291
+ @respx.mock
292
+ def test_video_to_sound_no_ducking_sets_false(tmp_path):
293
+ route = respx.post(f"{BASE}/v1/video-to-sound").mock(
294
+ return_value=httpx.Response(200, json={"task_id": "sd4", "status": "processing"})
295
+ )
296
+ respx.get(f"{BASE}/v1/tasks/sd4").mock(
297
+ return_value=httpx.Response(200, json=_sound_body("sd4"))
298
+ )
299
+ respx.get("https://r2.example.com/sound.wav").mock(
300
+ return_value=httpx.Response(200, content=b"MIXED")
301
+ )
302
+ run(["video-to-sound", "--video-url", "http://x/y.mp4",
303
+ "--output", str(tmp_path / "s.wav"), "--no-ducking"])
304
+ body = route.calls.last.request.content.decode()
305
+ assert "ducking=false" in body
306
+
307
+
308
+ @respx.mock
309
+ def test_video_to_sound_preserve_speech_absent_omits_field(tmp_path):
310
+ route = respx.post(f"{BASE}/v1/video-to-sound").mock(
311
+ return_value=httpx.Response(200, json={"task_id": "sd5", "status": "processing"})
312
+ )
313
+ respx.get(f"{BASE}/v1/tasks/sd5").mock(
314
+ return_value=httpx.Response(200, json=_sound_body("sd5"))
315
+ )
316
+ respx.get("https://r2.example.com/sound.wav").mock(
317
+ return_value=httpx.Response(200, content=b"MIXED")
318
+ )
319
+ run(["video-to-sound", "--video-url", "http://x/y.mp4",
320
+ "--output", str(tmp_path / "s.wav")])
321
+ body = route.calls.last.request.content.decode()
322
+ assert "preserve_speech=" not in body
323
+
324
+
325
+ @respx.mock
326
+ def test_video_to_sound_preserve_speech_flag_sets_true(tmp_path):
327
+ route = respx.post(f"{BASE}/v1/video-to-sound").mock(
328
+ return_value=httpx.Response(200, json={"task_id": "sd6", "status": "processing"})
329
+ )
330
+ respx.get(f"{BASE}/v1/tasks/sd6").mock(
331
+ return_value=httpx.Response(200, json=_sound_body("sd6"))
332
+ )
333
+ respx.get("https://r2.example.com/sound.wav").mock(
334
+ return_value=httpx.Response(200, content=b"MIXED")
335
+ )
336
+ run(["video-to-sound", "--video-url", "http://x/y.mp4",
337
+ "--output", str(tmp_path / "s.wav"), "--preserve-speech"])
338
+ body = route.calls.last.request.content.decode()
339
+ assert "preserve_speech=true" in body
340
+
341
+
342
+ @respx.mock
343
+ def test_video_to_video_sound_defaults_to_mp4(tmp_path, monkeypatch):
344
+ monkeypatch.chdir(tmp_path)
345
+ route = respx.post(f"{BASE}/v1/video-to-video-sound").mock(
346
+ return_value=httpx.Response(200, json={"task_id": "sd7", "status": "processing"})
347
+ )
348
+ respx.get(f"{BASE}/v1/tasks/sd7").mock(
349
+ return_value=httpx.Response(200, json=_sound_body(
350
+ "sd7", type="video_to_video_sound", output_type="video",
351
+ output_url="https://r2.example.com/sound.mp4",
352
+ ))
353
+ )
354
+ respx.get("https://r2.example.com/sound.mp4").mock(
355
+ return_value=httpx.Response(200, content=b"MP4DATA")
356
+ )
357
+ run(["video-to-video-sound", "--video-url", "http://x/y.mp4"])
358
+ assert route.called
359
+ assert (tmp_path / "output.mp4").read_bytes() == b"MP4DATA"
360
+
361
+
362
+ def test_video_to_sound_requires_a_video_source():
363
+ with pytest.raises(SystemExit) as exc:
364
+ run(["video-to-sound"])
365
+ assert exc.value.code == 1
366
+
367
+
368
+ def test_video_to_video_sound_requires_a_video_source():
369
+ with pytest.raises(SystemExit) as exc:
370
+ run(["video-to-video-sound"])
371
+ assert exc.value.code == 1
@@ -0,0 +1,12 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ ROOT = Path(__file__).resolve().parents[2]
5
+
6
+
7
+ def test_context7_json_is_valid_and_complete():
8
+ data = json.loads((ROOT / "context7.json").read_text())
9
+ assert data["projectTitle"] == "Sonilo"
10
+ assert data["$schema"] == "https://context7.com/schema/context7.json"
11
+ assert "**/__pycache__/**" in data["excludeFolders"]
12
+ assert isinstance(data["rules"], list) and len(data["rules"]) >= 5
@@ -0,0 +1,16 @@
1
+ import pytest
2
+
3
+ import sonilo_cli
4
+ from sonilo_cli.__main__ import main
5
+
6
+
7
+ def test_version_is_a_string():
8
+ assert isinstance(sonilo_cli.__version__, str)
9
+ assert sonilo_cli.__version__.count(".") == 2
10
+
11
+
12
+ def test_version_flag_prints_and_exits_zero(capsys):
13
+ with pytest.raises(SystemExit) as exc:
14
+ main(["--version"])
15
+ assert exc.value.code == 0
16
+ assert sonilo_cli.__version__ in capsys.readouterr().out