sonilo-cli 0.3.0__tar.gz → 0.4.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.
- {sonilo_cli-0.3.0 → sonilo_cli-0.4.0}/PKG-INFO +34 -1
- {sonilo_cli-0.3.0 → sonilo_cli-0.4.0}/README.md +33 -0
- {sonilo_cli-0.3.0 → sonilo_cli-0.4.0}/pyproject.toml +1 -1
- {sonilo_cli-0.3.0 → sonilo_cli-0.4.0}/src/sonilo_cli/__init__.py +1 -1
- {sonilo_cli-0.3.0 → sonilo_cli-0.4.0}/src/sonilo_cli/__main__.py +165 -4
- {sonilo_cli-0.3.0 → sonilo_cli-0.4.0}/tests/test_cli.py +249 -0
- {sonilo_cli-0.3.0 → sonilo_cli-0.4.0}/.gitignore +0 -0
- {sonilo_cli-0.3.0 → sonilo_cli-0.4.0}/LICENSE +0 -0
- {sonilo_cli-0.3.0 → sonilo_cli-0.4.0}/tests/__init__.py +0 -0
- {sonilo_cli-0.3.0 → sonilo_cli-0.4.0}/tests/test_context7.py +0 -0
- {sonilo_cli-0.3.0 → sonilo_cli-0.4.0}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sonilo-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -38,6 +38,7 @@ or pass `--api-key sk-...` on any command.
|
|
|
38
38
|
sonilo video-to-music --video clip.mp4 --prompt "tense synths" --format wav
|
|
39
39
|
sonilo text-to-sfx --prompt "glass shattering on concrete" --duration 3
|
|
40
40
|
sonilo video-to-sfx --video clip.mp4 --output whoosh.wav
|
|
41
|
+
sonilo video-to-sfx --video clip.mp4 --segments @segments.json
|
|
41
42
|
sonilo video-to-sound --video clip.mp4 \
|
|
42
43
|
--music-prompt "uplifting orchestral score" --sfx-prompt "match the on-screen action"
|
|
43
44
|
sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
|
|
@@ -53,6 +54,36 @@ or pass `--api-key sk-...` on any command.
|
|
|
53
54
|
- `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
|
|
54
55
|
- Output defaults to `./output.<ext>`; override with `--output`.
|
|
55
56
|
|
|
57
|
+
### Segments
|
|
58
|
+
|
|
59
|
+
`--segments` scores a timeline instead of one whole-clip prompt. It takes a JSON array, in one of
|
|
60
|
+
three forms — inline, from a file, or from stdin:
|
|
61
|
+
|
|
62
|
+
sonilo text-to-music --prompt "warm lo-fi piano" --duration 30 \
|
|
63
|
+
--segments '[{"start":0,"label":"intro","prompt":"airy pads"}]'
|
|
64
|
+
sonilo video-to-sfx --video clip.mp4 --segments @segments.json
|
|
65
|
+
jq -c '.cues' storyboard.json | sonilo video-to-sfx --video clip.mp4 --segments @-
|
|
66
|
+
|
|
67
|
+
A value starting with `@` names a source to read the JSON from, and `@-` reads standard input — the
|
|
68
|
+
same convention as `curl`, `gh` and `aws`. Anything else is parsed as JSON directly.
|
|
69
|
+
|
|
70
|
+
The two segment shapes are **not** interchangeable:
|
|
71
|
+
|
|
72
|
+
| Shape | Commands | Fields |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| Music | `text-to-music`, `video-to-music` | `{start, prompt, label?}` |
|
|
75
|
+
| SFX | `video-to-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
|
|
76
|
+
|
|
77
|
+
- `start` / `end` are seconds from the start of the track or clip.
|
|
78
|
+
- Passing one shape to a command that takes the other is rejected before any request is made, with
|
|
79
|
+
a message naming the shape that command expects.
|
|
80
|
+
- Only the shape is checked locally. Timing rules — the first segment starting at 0, minimum
|
|
81
|
+
spacing between segments, the `label` vocabulary, how many segments are allowed — are enforced by
|
|
82
|
+
the API, which answers with a `422` describing what it rejected.
|
|
83
|
+
- Keys the CLI does not recognise are forwarded as-is, so a newly added API field works without
|
|
84
|
+
upgrading the CLI.
|
|
85
|
+
- `text-to-sfx` takes no segments (its output is a single effect, not a timeline).
|
|
86
|
+
|
|
56
87
|
### Combined soundtracks
|
|
57
88
|
|
|
58
89
|
`video-to-sound` and `video-to-video-sound` score a clip with a music bed *and* sound effects in one
|
|
@@ -67,6 +98,8 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
|
|
|
67
98
|
--output soundtrack.wav --stem music --stem sfx
|
|
68
99
|
|
|
69
100
|
- `--music-prompt` / `--sfx-prompt` steer the two layers separately; both are optional.
|
|
101
|
+
- `--segments` places individual effects on the timeline, in the SFX shape `{start, end, prompt}` —
|
|
102
|
+
see [Segments](#segments).
|
|
70
103
|
- `--preserve-speech` keeps speech from the source video in the mix.
|
|
71
104
|
- **Ducking is on by default** (music dips under speech). Pass `--no-ducking` to opt out — omitting
|
|
72
105
|
the flag leaves the server default untouched.
|
|
@@ -22,6 +22,7 @@ or pass `--api-key sk-...` on any command.
|
|
|
22
22
|
sonilo video-to-music --video clip.mp4 --prompt "tense synths" --format wav
|
|
23
23
|
sonilo text-to-sfx --prompt "glass shattering on concrete" --duration 3
|
|
24
24
|
sonilo video-to-sfx --video clip.mp4 --output whoosh.wav
|
|
25
|
+
sonilo video-to-sfx --video clip.mp4 --segments @segments.json
|
|
25
26
|
sonilo video-to-sound --video clip.mp4 \
|
|
26
27
|
--music-prompt "uplifting orchestral score" --sfx-prompt "match the on-screen action"
|
|
27
28
|
sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
|
|
@@ -37,6 +38,36 @@ or pass `--api-key sk-...` on any command.
|
|
|
37
38
|
- `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
|
|
38
39
|
- Output defaults to `./output.<ext>`; override with `--output`.
|
|
39
40
|
|
|
41
|
+
### Segments
|
|
42
|
+
|
|
43
|
+
`--segments` scores a timeline instead of one whole-clip prompt. It takes a JSON array, in one of
|
|
44
|
+
three forms — inline, from a file, or from stdin:
|
|
45
|
+
|
|
46
|
+
sonilo text-to-music --prompt "warm lo-fi piano" --duration 30 \
|
|
47
|
+
--segments '[{"start":0,"label":"intro","prompt":"airy pads"}]'
|
|
48
|
+
sonilo video-to-sfx --video clip.mp4 --segments @segments.json
|
|
49
|
+
jq -c '.cues' storyboard.json | sonilo video-to-sfx --video clip.mp4 --segments @-
|
|
50
|
+
|
|
51
|
+
A value starting with `@` names a source to read the JSON from, and `@-` reads standard input — the
|
|
52
|
+
same convention as `curl`, `gh` and `aws`. Anything else is parsed as JSON directly.
|
|
53
|
+
|
|
54
|
+
The two segment shapes are **not** interchangeable:
|
|
55
|
+
|
|
56
|
+
| Shape | Commands | Fields |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| Music | `text-to-music`, `video-to-music` | `{start, prompt, label?}` |
|
|
59
|
+
| SFX | `video-to-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
|
|
60
|
+
|
|
61
|
+
- `start` / `end` are seconds from the start of the track or clip.
|
|
62
|
+
- Passing one shape to a command that takes the other is rejected before any request is made, with
|
|
63
|
+
a message naming the shape that command expects.
|
|
64
|
+
- Only the shape is checked locally. Timing rules — the first segment starting at 0, minimum
|
|
65
|
+
spacing between segments, the `label` vocabulary, how many segments are allowed — are enforced by
|
|
66
|
+
the API, which answers with a `422` describing what it rejected.
|
|
67
|
+
- Keys the CLI does not recognise are forwarded as-is, so a newly added API field works without
|
|
68
|
+
upgrading the CLI.
|
|
69
|
+
- `text-to-sfx` takes no segments (its output is a single effect, not a timeline).
|
|
70
|
+
|
|
40
71
|
### Combined soundtracks
|
|
41
72
|
|
|
42
73
|
`video-to-sound` and `video-to-video-sound` score a clip with a music bed *and* sound effects in one
|
|
@@ -51,6 +82,8 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
|
|
|
51
82
|
--output soundtrack.wav --stem music --stem sfx
|
|
52
83
|
|
|
53
84
|
- `--music-prompt` / `--sfx-prompt` steer the two layers separately; both are optional.
|
|
85
|
+
- `--segments` places individual effects on the timeline, in the SFX shape `{start, end, prompt}` —
|
|
86
|
+
see [Segments](#segments).
|
|
54
87
|
- `--preserve-speech` keeps speech from the source video in the mix.
|
|
55
88
|
- **Ducking is on by default** (music dips under speech). Pass `--no-ducking` to opt out — omitting
|
|
56
89
|
the flag leaves the server default untouched.
|
|
@@ -6,7 +6,7 @@ import os
|
|
|
6
6
|
import sys
|
|
7
7
|
import time
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Any, Dict, List, NoReturn, Optional
|
|
9
|
+
from typing import Any, Dict, List, NamedTuple, NoReturn, Optional, Tuple
|
|
10
10
|
from urllib.parse import urlparse
|
|
11
11
|
|
|
12
12
|
from sonilo import Sonilo
|
|
@@ -36,6 +36,142 @@ def _wrote(path: Any, size: int) -> None:
|
|
|
36
36
|
print(f"Wrote {path} ({size:,} bytes)")
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
# --- --segments ----------------------------------------------------------
|
|
40
|
+
#
|
|
41
|
+
# `segments` is the only structured parameter the API takes — every other
|
|
42
|
+
# flag on this CLI is a scalar. The value follows the curl / gh / aws
|
|
43
|
+
# convention for anything that can get long: a leading `@` makes the value a
|
|
44
|
+
# *source* to read from rather than the value itself, and `@-` means stdin.
|
|
45
|
+
#
|
|
46
|
+
# Validation here is deliberately shape-only. The server owns the semantic
|
|
47
|
+
# rules (first segment at 0, minimum spacing, the label enum, item caps) and
|
|
48
|
+
# a copy of them in the CLI would drift the moment the backend changes, so a
|
|
49
|
+
# request that is the right shape but the wrong content is left to earn its
|
|
50
|
+
# own 422.
|
|
51
|
+
|
|
52
|
+
_STDIN = "@-"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _SegmentShape(NamedTuple):
|
|
56
|
+
"""One command's segment contract, as used for validation and messages."""
|
|
57
|
+
|
|
58
|
+
summary: str
|
|
59
|
+
"""What a correct segment looks like, shown verbatim in errors."""
|
|
60
|
+
required: Tuple[Tuple[str, str], ...]
|
|
61
|
+
optional: Tuple[Tuple[str, str], ...]
|
|
62
|
+
foreign: Tuple[str, ...]
|
|
63
|
+
"""Fields that belong to the *other* shape — see _check_segment."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
MUSIC_SHAPE = _SegmentShape(
|
|
67
|
+
summary="{start, prompt, label?}",
|
|
68
|
+
required=(("start", "number"), ("prompt", "string")),
|
|
69
|
+
optional=(("label", "string"),),
|
|
70
|
+
foreign=("end",),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
SFX_SHAPE = _SegmentShape(
|
|
74
|
+
summary="{start, end, prompt}",
|
|
75
|
+
required=(("start", "number"), ("end", "number"), ("prompt", "string")),
|
|
76
|
+
optional=(),
|
|
77
|
+
foreign=("label",),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# JSON booleans are not numbers, but bool is an int subclass in Python, so
|
|
81
|
+
# `isinstance(True, int)` would let `{"start": true}` through.
|
|
82
|
+
_TYPE_CHECKS = {
|
|
83
|
+
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
|
|
84
|
+
"string": lambda v: isinstance(v, str),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _describe(value: Any) -> str:
|
|
89
|
+
"""Name a JSON value the way the error messages talk about it. For an
|
|
90
|
+
object this lists its keys, which is what makes a shape mix-up obvious."""
|
|
91
|
+
if value is None:
|
|
92
|
+
return "null"
|
|
93
|
+
if isinstance(value, bool):
|
|
94
|
+
return "a boolean"
|
|
95
|
+
if isinstance(value, (int, float)):
|
|
96
|
+
return "a number"
|
|
97
|
+
if isinstance(value, str):
|
|
98
|
+
return "a string"
|
|
99
|
+
if isinstance(value, list):
|
|
100
|
+
return "an empty array" if not value else "an array"
|
|
101
|
+
if isinstance(value, dict):
|
|
102
|
+
return f"an object with keys {', '.join(value)}" if value else "an object with no keys"
|
|
103
|
+
return "an unsupported value"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _read_segments_source(raw: str) -> Tuple[str, str]:
|
|
107
|
+
"""Resolve a raw --segments value to (text, source name for errors)."""
|
|
108
|
+
if not raw.startswith("@"):
|
|
109
|
+
return raw, "--segments"
|
|
110
|
+
if raw == _STDIN:
|
|
111
|
+
return sys.stdin.read(), "stdin"
|
|
112
|
+
path = raw[1:]
|
|
113
|
+
if not path:
|
|
114
|
+
_fail("--segments @ needs a filename, e.g. --segments @segments.json (@- reads stdin)")
|
|
115
|
+
try:
|
|
116
|
+
return Path(path).read_text(), path
|
|
117
|
+
except OSError as exc:
|
|
118
|
+
_fail(f"could not read segments from {path}: {exc.strerror or exc}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _check_segment(item: Any, index: int, shape: _SegmentShape, command: str) -> None:
|
|
122
|
+
expected = f"{command} segments take {shape.summary}"
|
|
123
|
+
# Element-level problems name the offending index (0-based, matching the
|
|
124
|
+
# Node CLI): an array is usually three or four items, and pointing at one
|
|
125
|
+
# saves the reader counting. The shape mismatch below is the exception —
|
|
126
|
+
# it is a whole-payload mistake, so it reads better without an index.
|
|
127
|
+
if not isinstance(item, dict):
|
|
128
|
+
_fail(f"{expected} — element {index} is not an object")
|
|
129
|
+
# A key that belongs to the *other* shape is the tell for the one
|
|
130
|
+
# predictable mistake here — SFX-shaped segments on a music command, or
|
|
131
|
+
# the reverse — so it is reported instead of forwarded, even though it
|
|
132
|
+
# would otherwise look like a required field is merely missing. Keys that
|
|
133
|
+
# belong to neither shape pass through untouched, so a field added to the
|
|
134
|
+
# API later needs no CLI release.
|
|
135
|
+
if any(key in item for key in shape.foreign) or any(
|
|
136
|
+
field not in item for field, _ in shape.required
|
|
137
|
+
):
|
|
138
|
+
_fail(f"{expected} — got {_describe(item)}")
|
|
139
|
+
for field, kind in shape.required + shape.optional:
|
|
140
|
+
if field in item and not _TYPE_CHECKS[kind](item[field]):
|
|
141
|
+
_fail(f'{expected} — "{field}" must be a {kind} (element {index})')
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_segments(
|
|
145
|
+
raw: Optional[str], shape: _SegmentShape, command: str
|
|
146
|
+
) -> Optional[List[Dict[str, Any]]]:
|
|
147
|
+
"""Read, parse and shape-check one --segments value.
|
|
148
|
+
|
|
149
|
+
`raw` is the flag as typed (inline JSON, `@file`, or `@-`). Returns None
|
|
150
|
+
when the flag was not given, so the field stays out of the request
|
|
151
|
+
entirely rather than being sent as an empty list.
|
|
152
|
+
"""
|
|
153
|
+
if raw is None:
|
|
154
|
+
return None
|
|
155
|
+
text, source = _read_segments_source(raw)
|
|
156
|
+
try:
|
|
157
|
+
value = json.loads(text)
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
_fail(f"could not parse segments from {source}: {exc}")
|
|
160
|
+
if not isinstance(value, list) or not value:
|
|
161
|
+
_fail(
|
|
162
|
+
f"{command} --segments must be a non-empty JSON array of "
|
|
163
|
+
f"{shape.summary} objects — got {_describe(value)}"
|
|
164
|
+
)
|
|
165
|
+
for index, item in enumerate(value):
|
|
166
|
+
_check_segment(item, index, shape, command)
|
|
167
|
+
return value
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _segments(args: argparse.Namespace) -> Optional[List[Dict[str, Any]]]:
|
|
171
|
+
"""parse_segments() for whichever subcommand is running."""
|
|
172
|
+
return parse_segments(args.segments, args.segments_shape, args.command)
|
|
173
|
+
|
|
174
|
+
|
|
39
175
|
def build_client(api_key: Optional[str]) -> Sonilo:
|
|
40
176
|
key = api_key or os.environ.get("SONILO_API_KEY")
|
|
41
177
|
if not key:
|
|
@@ -89,16 +225,20 @@ def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None:
|
|
|
89
225
|
fmt = args.format
|
|
90
226
|
use_async = args.use_async or fmt == "wav"
|
|
91
227
|
out = _music_output(args, fmt)
|
|
228
|
+
segments = _segments(args)
|
|
92
229
|
if use_async:
|
|
93
230
|
result = client.text_to_music.generate_async(
|
|
94
231
|
prompt=args.prompt,
|
|
95
232
|
duration=args.duration,
|
|
233
|
+
segments=segments,
|
|
96
234
|
output_format="wav" if fmt == "wav" else None,
|
|
97
235
|
)
|
|
98
236
|
path = result.save(out)
|
|
99
237
|
_wrote(path, path.stat().st_size)
|
|
100
238
|
else:
|
|
101
|
-
track = client.text_to_music.generate(
|
|
239
|
+
track = client.text_to_music.generate(
|
|
240
|
+
prompt=args.prompt, duration=args.duration, segments=segments
|
|
241
|
+
)
|
|
102
242
|
path = track.save(out)
|
|
103
243
|
_wrote(path, len(track.audio))
|
|
104
244
|
|
|
@@ -107,11 +247,13 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None:
|
|
|
107
247
|
fmt = args.format
|
|
108
248
|
use_async = args.use_async or fmt == "wav" or args.isolate_vocals or args.preserve_speech
|
|
109
249
|
out = _music_output(args, fmt)
|
|
250
|
+
segments = _segments(args)
|
|
110
251
|
if use_async:
|
|
111
252
|
result = client.video_to_music.generate_async(
|
|
112
253
|
video=args.video,
|
|
113
254
|
video_url=args.video_url,
|
|
114
255
|
prompt=args.prompt,
|
|
256
|
+
segments=segments,
|
|
115
257
|
isolate_vocals=args.isolate_vocals or None,
|
|
116
258
|
preserve_speech=args.preserve_speech or None,
|
|
117
259
|
output_format="wav" if fmt == "wav" else None,
|
|
@@ -120,7 +262,8 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None:
|
|
|
120
262
|
_wrote(path, path.stat().st_size)
|
|
121
263
|
else:
|
|
122
264
|
track = client.video_to_music.generate(
|
|
123
|
-
video=args.video, video_url=args.video_url, prompt=args.prompt
|
|
265
|
+
video=args.video, video_url=args.video_url, prompt=args.prompt,
|
|
266
|
+
segments=segments,
|
|
124
267
|
)
|
|
125
268
|
path = track.save(out)
|
|
126
269
|
_wrote(path, len(track.audio))
|
|
@@ -142,7 +285,7 @@ def cmd_video_to_sfx(client: Sonilo, args: argparse.Namespace) -> None:
|
|
|
142
285
|
out = args.output if args.output is not None else f"output.{args.format}"
|
|
143
286
|
result = client.video_to_sfx.generate(
|
|
144
287
|
video=args.video, video_url=args.video_url,
|
|
145
|
-
prompt=args.prompt, audio_format=args.format,
|
|
288
|
+
prompt=args.prompt, segments=_segments(args), audio_format=args.format,
|
|
146
289
|
)
|
|
147
290
|
path = result.save(out)
|
|
148
291
|
_wrote(path, path.stat().st_size)
|
|
@@ -166,6 +309,7 @@ def _run_sound(client: Sonilo, args: argparse.Namespace, resource: Any, default_
|
|
|
166
309
|
video_url=args.video_url,
|
|
167
310
|
music_prompt=args.music_prompt,
|
|
168
311
|
sfx_prompt=args.sfx_prompt,
|
|
312
|
+
segments=_segments(args),
|
|
169
313
|
preserve_speech=True if args.preserve_speech else None,
|
|
170
314
|
ducking=False if args.no_ducking else None,
|
|
171
315
|
)
|
|
@@ -269,6 +413,18 @@ def _add_video_source(parser: argparse.ArgumentParser) -> None:
|
|
|
269
413
|
help="Remote video URL to score.")
|
|
270
414
|
|
|
271
415
|
|
|
416
|
+
def _add_segments(parser: argparse.ArgumentParser, shape: _SegmentShape) -> None:
|
|
417
|
+
parser.add_argument(
|
|
418
|
+
"--segments", default=None,
|
|
419
|
+
help=f"Timed segments, as a JSON array of {shape.summary} objects. "
|
|
420
|
+
"Pass the JSON inline, @FILE to read it from a file, "
|
|
421
|
+
"or @- to read it from stdin.",
|
|
422
|
+
)
|
|
423
|
+
# Carried on the namespace so the one shared reader knows which contract
|
|
424
|
+
# to check the value against, and can name it when the check fails.
|
|
425
|
+
parser.set_defaults(segments_shape=shape)
|
|
426
|
+
|
|
427
|
+
|
|
272
428
|
def build_parser() -> argparse.ArgumentParser:
|
|
273
429
|
parser = _Parser(prog="sonilo", description="Command-line interface for the Sonilo API")
|
|
274
430
|
parser.add_argument("--version", action="version", version=__version__)
|
|
@@ -288,6 +444,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
288
444
|
_add_global(p_t2m)
|
|
289
445
|
p_t2m.add_argument("--prompt", required=True, help="What the music should sound like.")
|
|
290
446
|
p_t2m.add_argument("--duration", type=int, required=True, help="Track length in seconds.")
|
|
447
|
+
_add_segments(p_t2m, MUSIC_SHAPE)
|
|
291
448
|
p_t2m.add_argument("--output", default=None, help="Where to save the audio.")
|
|
292
449
|
p_t2m.add_argument("--format", choices=["m4a", "wav"], default="m4a",
|
|
293
450
|
help="Output container. wav forces async. Default: m4a")
|
|
@@ -299,6 +456,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
299
456
|
_add_global(p_v2m)
|
|
300
457
|
_add_video_source(p_v2m)
|
|
301
458
|
p_v2m.add_argument("--prompt", default=None, help="Optional creative direction.")
|
|
459
|
+
_add_segments(p_v2m, MUSIC_SHAPE)
|
|
302
460
|
p_v2m.add_argument("--output", default=None, help="Where to save the audio.")
|
|
303
461
|
p_v2m.add_argument("--format", choices=["m4a", "wav"], default="m4a",
|
|
304
462
|
help="Output container. wav forces async.")
|
|
@@ -323,6 +481,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
323
481
|
_add_global(p_v2s)
|
|
324
482
|
_add_video_source(p_v2s)
|
|
325
483
|
p_v2s.add_argument("--prompt", default=None, help="Optional creative direction.")
|
|
484
|
+
_add_segments(p_v2s, SFX_SHAPE)
|
|
326
485
|
p_v2s.add_argument("--output", default=None, help="Where to save the audio.")
|
|
327
486
|
p_v2s.add_argument("--format", choices=_SFX_FORMATS, default="wav",
|
|
328
487
|
help="Output format. Default: wav")
|
|
@@ -337,6 +496,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
337
496
|
help="Optional creative direction for the music bed.")
|
|
338
497
|
p_v2sd.add_argument("--sfx-prompt", dest="sfx_prompt", default=None,
|
|
339
498
|
help="Optional creative direction for the sound effects.")
|
|
499
|
+
_add_segments(p_v2sd, SFX_SHAPE)
|
|
340
500
|
p_v2sd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
|
|
341
501
|
help="Keep source speech in the mix.")
|
|
342
502
|
p_v2sd.add_argument("--no-ducking", dest="no_ducking", action="store_true",
|
|
@@ -355,6 +515,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
355
515
|
help="Optional creative direction for the music bed.")
|
|
356
516
|
p_v2vsd.add_argument("--sfx-prompt", dest="sfx_prompt", default=None,
|
|
357
517
|
help="Optional creative direction for the sound effects.")
|
|
518
|
+
_add_segments(p_v2vsd, SFX_SHAPE)
|
|
358
519
|
p_v2vsd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
|
|
359
520
|
help="Keep source speech in the mix.")
|
|
360
521
|
p_v2vsd.add_argument("--no-ducking", dest="no_ducking", action="store_true",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import io
|
|
1
2
|
import json
|
|
2
3
|
from urllib.parse import unquote_plus
|
|
3
4
|
|
|
@@ -536,3 +537,251 @@ def test_dubbing_without_languages_omits_the_field(tmp_path):
|
|
|
536
537
|
# absent. Sending `languages=[]` or the string "None" would silently
|
|
537
538
|
# override that default with something else.
|
|
538
539
|
assert b"languages" not in route.calls.last.request.content
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# --- --segments -----------------------------------------------------------
|
|
543
|
+
#
|
|
544
|
+
# The two shapes are not interchangeable: music segments are
|
|
545
|
+
# {start, prompt, label?} and SFX segments are {start, end, prompt}. Both are
|
|
546
|
+
# sent as a JSON string inside the form-encoded body (see
|
|
547
|
+
# sonilo._requests.build_v2m_parts), so assertions decode the body first.
|
|
548
|
+
|
|
549
|
+
MUSIC_SEGMENTS = [{"start": 0, "label": "intro", "prompt": "airy pads"}]
|
|
550
|
+
SFX_SEGMENTS = [{"start": 0, "end": 5, "prompt": "footsteps on gravel"}]
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _sent_segments(route):
|
|
554
|
+
"""The `segments` value as it left the CLI, decoded back to Python."""
|
|
555
|
+
body = unquote_plus(route.calls.last.request.content.decode())
|
|
556
|
+
for field in body.split("&"):
|
|
557
|
+
name, _, value = field.partition("=")
|
|
558
|
+
if name == "segments":
|
|
559
|
+
return json.loads(value)
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@respx.mock
|
|
564
|
+
def test_segments_inline_json_reaches_the_request_body(tmp_path):
|
|
565
|
+
route = respx.post(f"{BASE}/v1/text-to-music").mock(
|
|
566
|
+
return_value=httpx.Response(200, text=_music_stream_body())
|
|
567
|
+
)
|
|
568
|
+
run(["text-to-music", "--prompt", "lofi", "--duration", "30",
|
|
569
|
+
"--segments", json.dumps(MUSIC_SEGMENTS),
|
|
570
|
+
"--output", str(tmp_path / "song.m4a")])
|
|
571
|
+
assert _sent_segments(route) == MUSIC_SEGMENTS
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@respx.mock
|
|
575
|
+
def test_segments_from_a_file(tmp_path):
|
|
576
|
+
route = respx.post(f"{BASE}/v1/text-to-music").mock(
|
|
577
|
+
return_value=httpx.Response(200, text=_music_stream_body())
|
|
578
|
+
)
|
|
579
|
+
src = tmp_path / "segments.json"
|
|
580
|
+
src.write_text(json.dumps(MUSIC_SEGMENTS))
|
|
581
|
+
run(["text-to-music", "--prompt", "lofi", "--duration", "30",
|
|
582
|
+
"--segments", f"@{src}", "--output", str(tmp_path / "song.m4a")])
|
|
583
|
+
assert _sent_segments(route) == MUSIC_SEGMENTS
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
@respx.mock
|
|
587
|
+
def test_segments_from_stdin(tmp_path, monkeypatch):
|
|
588
|
+
route = respx.post(f"{BASE}/v1/text-to-music").mock(
|
|
589
|
+
return_value=httpx.Response(200, text=_music_stream_body())
|
|
590
|
+
)
|
|
591
|
+
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(MUSIC_SEGMENTS)))
|
|
592
|
+
run(["text-to-music", "--prompt", "lofi", "--duration", "30",
|
|
593
|
+
"--segments", "@-", "--output", str(tmp_path / "song.m4a")])
|
|
594
|
+
assert _sent_segments(route) == MUSIC_SEGMENTS
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@respx.mock
|
|
598
|
+
def test_sfx_segments_reach_the_request_body(tmp_path):
|
|
599
|
+
route = respx.post(f"{BASE}/v1/video-to-sound").mock(
|
|
600
|
+
return_value=httpx.Response(200, json={"task_id": "sg1", "status": "processing"})
|
|
601
|
+
)
|
|
602
|
+
respx.get(f"{BASE}/v1/tasks/sg1").mock(
|
|
603
|
+
return_value=httpx.Response(200, json=_sound_body("sg1"))
|
|
604
|
+
)
|
|
605
|
+
respx.get("https://r2.example.com/sound.wav").mock(
|
|
606
|
+
return_value=httpx.Response(200, content=b"MIXED")
|
|
607
|
+
)
|
|
608
|
+
run(["video-to-sound", "--video-url", "http://x/y.mp4",
|
|
609
|
+
"--segments", json.dumps(SFX_SEGMENTS),
|
|
610
|
+
"--output", str(tmp_path / "s.wav")])
|
|
611
|
+
assert _sent_segments(route) == SFX_SEGMENTS
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@respx.mock
|
|
615
|
+
def test_omitting_segments_sends_no_segments_field(tmp_path):
|
|
616
|
+
route = respx.post(f"{BASE}/v1/text-to-music").mock(
|
|
617
|
+
return_value=httpx.Response(200, text=_music_stream_body())
|
|
618
|
+
)
|
|
619
|
+
run(["text-to-music", "--prompt", "lofi", "--duration", "30",
|
|
620
|
+
"--output", str(tmp_path / "song.m4a")])
|
|
621
|
+
# Not `segments=[]` and not the string "None" — the field must be absent,
|
|
622
|
+
# exactly as --languages is for dubbing.
|
|
623
|
+
assert b"segments" not in route.calls.last.request.content
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def test_segments_unknown_keys_pass_through(tmp_path):
|
|
627
|
+
"""A field the API adds later must not need a CLI release to be usable."""
|
|
628
|
+
from sonilo_cli.__main__ import MUSIC_SHAPE, parse_segments
|
|
629
|
+
|
|
630
|
+
value = [{"start": 0, "prompt": "pads", "intensity": 0.4}]
|
|
631
|
+
assert parse_segments(json.dumps(value), MUSIC_SHAPE, "text-to-music") == value
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def test_segments_malformed_json_names_the_source(capsys):
|
|
635
|
+
with pytest.raises(SystemExit) as exc:
|
|
636
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4",
|
|
637
|
+
"--segments", "[{start: 0}]"])
|
|
638
|
+
assert exc.value.code == 1
|
|
639
|
+
err = capsys.readouterr().err
|
|
640
|
+
assert err.startswith("sonilo:")
|
|
641
|
+
assert "--segments" in err # the offending source
|
|
642
|
+
assert "Expecting" in err # the parser's own complaint
|
|
643
|
+
assert "Traceback" not in err
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def test_segments_malformed_json_from_a_file_names_the_file(tmp_path, capsys):
|
|
647
|
+
src = tmp_path / "segments.json"
|
|
648
|
+
src.write_text("{oops")
|
|
649
|
+
with pytest.raises(SystemExit) as exc:
|
|
650
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4", "--segments", f"@{src}"])
|
|
651
|
+
assert exc.value.code == 1
|
|
652
|
+
assert str(src) in capsys.readouterr().err
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def test_segments_malformed_json_from_stdin_names_stdin(capsys, monkeypatch):
|
|
656
|
+
monkeypatch.setattr("sys.stdin", io.StringIO("{oops"))
|
|
657
|
+
with pytest.raises(SystemExit) as exc:
|
|
658
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4", "--segments", "@-"])
|
|
659
|
+
assert exc.value.code == 1
|
|
660
|
+
# "stdin", not "standard input" — the Node CLI says stdin.
|
|
661
|
+
assert "could not parse segments from stdin:" in capsys.readouterr().err
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def test_segments_unreadable_file_exits_1(tmp_path, capsys):
|
|
665
|
+
missing = tmp_path / "nope.json"
|
|
666
|
+
with pytest.raises(SystemExit) as exc:
|
|
667
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4", "--segments", f"@{missing}"])
|
|
668
|
+
assert exc.value.code == 1
|
|
669
|
+
err = capsys.readouterr().err
|
|
670
|
+
assert str(missing) in err
|
|
671
|
+
assert "Traceback" not in err
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def test_segments_must_be_a_list(capsys):
|
|
675
|
+
with pytest.raises(SystemExit) as exc:
|
|
676
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4",
|
|
677
|
+
"--segments", '{"start": 0, "prompt": "pads"}'])
|
|
678
|
+
assert exc.value.code == 1
|
|
679
|
+
err = capsys.readouterr().err
|
|
680
|
+
assert "JSON array" in err
|
|
681
|
+
assert "{start, prompt, label?}" in err
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def test_segments_must_not_be_empty(capsys):
|
|
685
|
+
with pytest.raises(SystemExit) as exc:
|
|
686
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4", "--segments", "[]"])
|
|
687
|
+
assert exc.value.code == 1
|
|
688
|
+
assert "empty array" in capsys.readouterr().err
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def test_segments_elements_must_be_objects(capsys):
|
|
692
|
+
with pytest.raises(SystemExit) as exc:
|
|
693
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4", "--segments", '["intro"]'])
|
|
694
|
+
assert exc.value.code == 1
|
|
695
|
+
err = capsys.readouterr().err
|
|
696
|
+
assert "video-to-music segments take {start, prompt, label?}" in err
|
|
697
|
+
assert "element 0 is not an object" in err
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def test_segments_element_errors_name_the_offending_index(capsys):
|
|
701
|
+
"""Indices are 0-based, and point at the bad element rather than always
|
|
702
|
+
reporting the first — matching the Node CLI, so the two agree."""
|
|
703
|
+
with pytest.raises(SystemExit) as exc:
|
|
704
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4",
|
|
705
|
+
"--segments", '[{"start": 0, "prompt": "a"}, {"start": 5, "prompt": "b"}, "oops"]'])
|
|
706
|
+
assert exc.value.code == 1
|
|
707
|
+
assert "element 2 is not an object" in capsys.readouterr().err
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def test_music_command_rejects_sfx_shaped_segments(capsys):
|
|
711
|
+
"""The predictable mistake, in the direction that is hardest to spot: the
|
|
712
|
+
music shape's required fields are all present, so only the foreign `end`
|
|
713
|
+
key gives it away."""
|
|
714
|
+
with pytest.raises(SystemExit) as exc:
|
|
715
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4",
|
|
716
|
+
"--segments", json.dumps(SFX_SEGMENTS)])
|
|
717
|
+
assert exc.value.code == 1
|
|
718
|
+
err = capsys.readouterr().err
|
|
719
|
+
# Naming both the expected shape and the keys actually given is what makes
|
|
720
|
+
# this self-correcting without a docs lookup.
|
|
721
|
+
assert "video-to-music segments take {start, prompt, label?}" in err
|
|
722
|
+
assert "got an object with keys start, end, prompt" in err
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def test_sfx_command_rejects_music_shaped_segments(capsys):
|
|
726
|
+
with pytest.raises(SystemExit) as exc:
|
|
727
|
+
run(["video-to-sfx", "--video-url", "http://x/y.mp4",
|
|
728
|
+
"--segments", json.dumps(MUSIC_SEGMENTS)])
|
|
729
|
+
assert exc.value.code == 1
|
|
730
|
+
err = capsys.readouterr().err
|
|
731
|
+
assert "video-to-sfx segments take {start, end, prompt}" in err
|
|
732
|
+
assert "got an object with keys start, label, prompt" in err
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def test_segments_field_types_are_checked(capsys):
|
|
736
|
+
with pytest.raises(SystemExit) as exc:
|
|
737
|
+
run(["video-to-sfx", "--video-url", "http://x/y.mp4",
|
|
738
|
+
"--segments", '[{"start": 0, "end": "5", "prompt": "thud"}]'])
|
|
739
|
+
assert exc.value.code == 1
|
|
740
|
+
err = capsys.readouterr().err
|
|
741
|
+
assert "video-to-sfx segments take {start, end, prompt}" in err
|
|
742
|
+
assert '"end" must be a number (element 0)' in err
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def test_segments_field_type_errors_name_the_offending_index(capsys):
|
|
746
|
+
with pytest.raises(SystemExit) as exc:
|
|
747
|
+
run(["video-to-sfx", "--video-url", "http://x/y.mp4",
|
|
748
|
+
"--segments", '[{"start": 0, "end": 5, "prompt": "a"},'
|
|
749
|
+
' {"start": 5, "end": 9, "prompt": 7}]'])
|
|
750
|
+
assert exc.value.code == 1
|
|
751
|
+
assert '"prompt" must be a string (element 1)' in capsys.readouterr().err
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def test_segments_semantic_rules_are_left_to_the_server():
|
|
755
|
+
"""Spacing, the label enum, the first segment's start and item caps are
|
|
756
|
+
server-side rules; duplicating them here would drift. Shape-valid input
|
|
757
|
+
must pass client-side however implausible it looks."""
|
|
758
|
+
from sonilo_cli.__main__ import MUSIC_SHAPE, parse_segments
|
|
759
|
+
|
|
760
|
+
value = [{"start": 900, "prompt": "x", "label": "not-in-the-enum"},
|
|
761
|
+
{"start": 901, "prompt": "y"}]
|
|
762
|
+
assert parse_segments(json.dumps(value), MUSIC_SHAPE, "text-to-music") == value
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
@pytest.mark.parametrize(
|
|
766
|
+
"command",
|
|
767
|
+
["text-to-music", "video-to-music", "video-to-sfx",
|
|
768
|
+
"video-to-sound", "video-to-video-sound"],
|
|
769
|
+
)
|
|
770
|
+
def test_segments_help_shows_all_three_value_forms(command, capsys):
|
|
771
|
+
with pytest.raises(SystemExit):
|
|
772
|
+
main([command, "--help"])
|
|
773
|
+
help_text = capsys.readouterr().out
|
|
774
|
+
assert "--segments" in help_text
|
|
775
|
+
assert "@FILE" in help_text
|
|
776
|
+
assert "@-" in help_text
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
@pytest.mark.parametrize("command", ["text-to-sfx"])
|
|
780
|
+
def test_commands_without_segments_reject_the_flag(command, capsys):
|
|
781
|
+
"""text-to-sfx takes no segments in the SDK, so the CLI must not offer it
|
|
782
|
+
(video-to-video-music, the other segment-less endpoint, has no CLI
|
|
783
|
+
command at all)."""
|
|
784
|
+
with pytest.raises(SystemExit) as exc:
|
|
785
|
+
run([command, "--prompt", "x", "--duration", "3", "--segments", "[]"])
|
|
786
|
+
assert exc.value.code == 1
|
|
787
|
+
assert "unrecognized arguments" in capsys.readouterr().err
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|