sonilo-cli 0.13.0__tar.gz → 0.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,19 @@
1
+ Metadata-Version: 2.5
2
+ Name: sonilo-cli
3
+ Version: 0.14.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.16,>=0.15.0
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
+
1
17
  # sonilo-cli
2
18
 
3
19
  Command-line interface for the [Sonilo API](https://github.com/sonilo-ai/sonilo-python) — generate music and sound effects from text or video.
@@ -92,8 +108,8 @@ production sign-in coexist without overwriting each other.
92
108
  ### Notes
93
109
 
94
110
  - `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`,
95
- `--preserve-speech`, `--variants` above 1, and the legacy alias `--isolate-vocals` each switch
96
- to the async submit-and-poll path.
111
+ `--preserve-speech`, `--variants` above 1, `--stems`, and the legacy alias `--isolate-vocals`
112
+ each switch to the async submit-and-poll path.
97
113
  - `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
98
114
  - Output defaults to `./output.<ext>`; override with `--output`.
99
115
 
@@ -157,6 +173,27 @@ not sent at all and the API's own 0.5 default applies; `--prompt-influence 0` is
157
173
 
158
174
  sonilo video-to-music --video clip.mp4 --prompt "tense synths" --prompt-influence 0.8
159
175
 
176
+ ### Music stems
177
+
178
+ `--stems` on `text-to-music` and `video-to-music` also splits the generated music into four
179
+ stems — drums, bass, vocals, other — saved next to the main output with the stem name spliced
180
+ before the extension (`take.m4a` → `take.drums.m4a`, and per variant with `--variants` above 1:
181
+ `take.0.drums.m4a`). It is free of charge, and forces the async path. On `video-to-music` it
182
+ splits the *generated* music, never the video's own audio.
183
+
184
+ sonilo text-to-music --prompt "warm lo-fi piano" --duration 60 --stems --output take.m4a
185
+ # writes take.m4a, take.drums.m4a, take.bass.m4a, take.vocals.m4a, take.other.m4a
186
+
187
+ - Separation runs **after** generation and typically adds 2-6 minutes to the wait; the CLI
188
+ waits up to 2400 seconds on these runs (covering the separation service's own 30-minute
189
+ ceiling, the way dubbing's `--timeout` covers its backend's). If the wait still times out,
190
+ the task keeps running server-side — resume it with `sonilo tasks wait <task-id>`.
191
+ - Separation can also come up short without failing the run: streams that did not separate are
192
+ reported on stderr (the API's `stems_error`), while the stems that did come back are still
193
+ saved — a partial result is not an error exit, and the main output is always written.
194
+ - Not the same flag as `--stem` on the sound commands, which saves layers those endpoints
195
+ already return; `--stems` *requests* a separation the API would not otherwise run.
196
+
160
197
  ### Scored video
161
198
 
162
199
  `video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
@@ -1,19 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: sonilo-cli
3
- Version: 0.13.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.15,>=0.14.0
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
1
  # sonilo-cli
18
2
 
19
3
  Command-line interface for the [Sonilo API](https://github.com/sonilo-ai/sonilo-python) — generate music and sound effects from text or video.
@@ -108,8 +92,8 @@ production sign-in coexist without overwriting each other.
108
92
  ### Notes
109
93
 
110
94
  - `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`,
111
- `--preserve-speech`, `--variants` above 1, and the legacy alias `--isolate-vocals` each switch
112
- to the async submit-and-poll path.
95
+ `--preserve-speech`, `--variants` above 1, `--stems`, and the legacy alias `--isolate-vocals`
96
+ each switch to the async submit-and-poll path.
113
97
  - `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
114
98
  - Output defaults to `./output.<ext>`; override with `--output`.
115
99
 
@@ -173,6 +157,27 @@ not sent at all and the API's own 0.5 default applies; `--prompt-influence 0` is
173
157
 
174
158
  sonilo video-to-music --video clip.mp4 --prompt "tense synths" --prompt-influence 0.8
175
159
 
160
+ ### Music stems
161
+
162
+ `--stems` on `text-to-music` and `video-to-music` also splits the generated music into four
163
+ stems — drums, bass, vocals, other — saved next to the main output with the stem name spliced
164
+ before the extension (`take.m4a` → `take.drums.m4a`, and per variant with `--variants` above 1:
165
+ `take.0.drums.m4a`). It is free of charge, and forces the async path. On `video-to-music` it
166
+ splits the *generated* music, never the video's own audio.
167
+
168
+ sonilo text-to-music --prompt "warm lo-fi piano" --duration 60 --stems --output take.m4a
169
+ # writes take.m4a, take.drums.m4a, take.bass.m4a, take.vocals.m4a, take.other.m4a
170
+
171
+ - Separation runs **after** generation and typically adds 2-6 minutes to the wait; the CLI
172
+ waits up to 2400 seconds on these runs (covering the separation service's own 30-minute
173
+ ceiling, the way dubbing's `--timeout` covers its backend's). If the wait still times out,
174
+ the task keeps running server-side — resume it with `sonilo tasks wait <task-id>`.
175
+ - Separation can also come up short without failing the run: streams that did not separate are
176
+ reported on stderr (the API's `stems_error`), while the stems that did come back are still
177
+ saved — a partial result is not an error exit, and the main output is always written.
178
+ - Not the same flag as `--stem` on the sound commands, which saves layers those endpoints
179
+ already return; `--stems` *requests* a separation the API would not otherwise run.
180
+
176
181
  ### Scored video
177
182
 
178
183
  `video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sonilo-cli"
7
- version = "0.13.0"
7
+ version = "0.14.0"
8
8
  description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.9"
12
12
  authors = [{ name = "Sonilo AI" }]
13
- dependencies = ["sonilo>=0.14.0,<0.15"]
13
+ dependencies = ["sonilo>=0.15.0,<0.16"]
14
14
  keywords = ["sonilo", "cli", "music", "sfx", "text-to-music", "video-to-music", "ai"]
15
15
 
16
16
  [project.urls]
@@ -1,3 +1,3 @@
1
- __version__ = "0.13.0"
1
+ __version__ = "0.14.0"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -11,6 +11,7 @@ from urllib.parse import urlparse
11
11
 
12
12
  from sonilo import Sonilo
13
13
  from sonilo.errors import APIError, SoniloError
14
+ from sonilo.resources.tasks import DEFAULT_WAIT_TIMEOUT
14
15
 
15
16
  from sonilo_cli import __version__, credentials
16
17
  from sonilo_cli.login import LoginError, cmd_login, cmd_logout, cmd_whoami
@@ -291,10 +292,53 @@ def _save_music_variants(result: Any, out: str) -> None:
291
292
  _wrote(path, path.stat().st_size)
292
293
 
293
294
 
295
+ # Matched to the separation service's own ceiling on top of the generation
296
+ # wait: separation runs after generation, typically adds 2-6 minutes and gives
297
+ # up after 30 (1800s), so the SDK's generic DEFAULT_WAIT_TIMEOUT of 600s would
298
+ # abandon a stems task the user has already been charged the generation for —
299
+ # the same reasoning as DUBBING_WAIT_TIMEOUT below.
300
+ STEMS_WAIT_TIMEOUT = 2400.0
301
+
302
+ _MUSIC_STEMS = ("drums", "bass", "vocals", "other")
303
+
304
+
305
+ def _save_music_stems(result: Any, out: str) -> None:
306
+ """Save the separated stems of an async music result next to `out`,
307
+ `take.m4a` becoming `take.drums.m4a` etc. — the same transform --stem
308
+ applies on the sound commands. With several variants the stem files pick
309
+ up the entry's own stream_index (`take.0.drums.m4a`), matching the
310
+ indexed file _save_music_variants wrote for that stream.
311
+
312
+ Entries are matched by stream_index, never list position — the list can
313
+ be shorter than `audio`. `stems_error` is a warning, not a failure, and
314
+ never suppresses the partial stems that did come back: the music itself
315
+ succeeded and separation is free, so incomplete stems must not turn the
316
+ whole command into an error exit."""
317
+ if result.stems_error:
318
+ sys.stderr.write(f"sonilo: stem separation incomplete: {result.stems_error}\n")
319
+ entries = result.stems or []
320
+ if not entries:
321
+ if not result.stems_error:
322
+ sys.stderr.write("sonilo: task succeeded but returned no stems\n")
323
+ return
324
+ multi = len(result.audio or []) > 1
325
+ for entry in entries:
326
+ base = _variant_path(out, entry.stream_index) if multi else out
327
+ for stem in _MUSIC_STEMS:
328
+ media = getattr(entry, stem, None)
329
+ if media is None:
330
+ continue
331
+ path = result.save_stem(
332
+ _stem_path(base, stem, media),
333
+ which=stem, stream_index=entry.stream_index,
334
+ )
335
+ _wrote(path, path.stat().st_size)
336
+
337
+
294
338
  def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None:
295
339
  fmt = args.format
296
340
  multi = args.variants is not None and args.variants > 1
297
- use_async = args.use_async or fmt != "m4a" or multi
341
+ use_async = args.use_async or fmt != "m4a" or multi or args.stems
298
342
  out = _music_output(args, fmt)
299
343
  segments = _segments(args)
300
344
  if use_async:
@@ -304,8 +348,12 @@ def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None:
304
348
  segments=segments,
305
349
  output_format=fmt if fmt != "m4a" else None,
306
350
  variants_num=args.variants,
351
+ stems=True if args.stems else None,
352
+ timeout=STEMS_WAIT_TIMEOUT if args.stems else DEFAULT_WAIT_TIMEOUT,
307
353
  )
308
354
  _save_music_variants(result, out)
355
+ if args.stems:
356
+ _save_music_stems(result, out)
309
357
  else:
310
358
  track = client.text_to_music.generate(
311
359
  prompt=args.prompt, duration=args.duration, segments=segments
@@ -318,7 +366,8 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None:
318
366
  fmt = args.format
319
367
  multi = args.variants is not None and args.variants > 1
320
368
  use_async = (
321
- args.use_async or fmt != "m4a" or args.isolate_vocals or args.preserve_speech or multi
369
+ args.use_async or fmt != "m4a" or args.isolate_vocals or args.preserve_speech
370
+ or multi or args.stems
322
371
  )
323
372
  out = _music_output(args, fmt)
324
373
  segments = _segments(args)
@@ -333,8 +382,12 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None:
333
382
  output_format=fmt if fmt != "m4a" else None,
334
383
  variants_num=args.variants,
335
384
  prompt_influence=args.prompt_influence,
385
+ stems=True if args.stems else None,
386
+ timeout=STEMS_WAIT_TIMEOUT if args.stems else DEFAULT_WAIT_TIMEOUT,
336
387
  )
337
388
  _save_music_variants(result, out)
389
+ if args.stems:
390
+ _save_music_stems(result, out)
338
391
  else:
339
392
  # prompt_influence rides the streaming path too — it is a generation
340
393
  # parameter, not a finalize-time one, so it never forces async.
@@ -675,6 +728,21 @@ def _add_variants(parser: argparse.ArgumentParser) -> None:
675
728
  )
676
729
 
677
730
 
731
+ def _add_stems(parser: argparse.ArgumentParser) -> None:
732
+ # Only the two music-generation commands take this — the API accepts it
733
+ # nowhere else. Not to be confused with --stem on the sound commands,
734
+ # which saves layers those endpoints already return; --stems *requests*
735
+ # a separation the API would not otherwise run.
736
+ parser.add_argument(
737
+ "--stems", action="store_true",
738
+ help="Also split the generated music into drums/bass/vocals/other "
739
+ "stems, saved next to the output (output.drums.m4a, ...). Free "
740
+ "of charge. Forces async, and separation typically adds 2-6 "
741
+ "minutes to the wait. Streams that fail to separate are "
742
+ "reported on stderr; the rest are still saved.",
743
+ )
744
+
745
+
678
746
  def build_parser() -> argparse.ArgumentParser:
679
747
  parser = _Parser(prog="sonilo", description="Command-line interface for the Sonilo API")
680
748
  parser.add_argument("--version", action="version", version=__version__)
@@ -728,6 +796,7 @@ def build_parser() -> argparse.ArgumentParser:
728
796
  p_t2m.add_argument("--async", dest="use_async", action="store_true",
729
797
  help="Submit and poll instead of streaming.")
730
798
  _add_variants(p_t2m)
799
+ _add_stems(p_t2m)
731
800
  p_t2m.set_defaults(func=cmd_text_to_music)
732
801
 
733
802
  p_v2m = sub.add_parser("video-to-music", help="Generate music matched to a video")
@@ -750,6 +819,7 @@ def build_parser() -> argparse.ArgumentParser:
750
819
  help="Submit and poll instead of streaming.")
751
820
  _add_variants(p_v2m)
752
821
  _add_prompt_influence(p_v2m)
822
+ _add_stems(p_v2m)
753
823
  p_v2m.set_defaults(func=cmd_video_to_music)
754
824
 
755
825
  p_t2s = sub.add_parser("text-to-sfx", help="Generate a sound effect from a text prompt")
@@ -1435,3 +1435,175 @@ def test_video_analysis_requires_a_video_source(capsys):
1435
1435
  # Asserted on the message, not just the exit code: an unknown command
1436
1436
  # also exits 1, so the code alone would pass before the command exists.
1437
1437
  assert "--video" in capsys.readouterr().err
1438
+
1439
+
1440
+ # ---------- --stems (music stem separation) ----------
1441
+
1442
+
1443
+ def _stems_entry(i, base="https://r2.example.com"):
1444
+ return {
1445
+ "stream_index": i,
1446
+ "drums": {"url": f"{base}/s{i}.drums.m4a"},
1447
+ "bass": {"url": f"{base}/s{i}.bass.m4a"},
1448
+ "vocals": {"url": f"{base}/s{i}.vocals.m4a"},
1449
+ "other": {"url": f"{base}/s{i}.other.m4a"},
1450
+ }
1451
+
1452
+
1453
+ def _mock_stem_downloads(*indices):
1454
+ for i in indices:
1455
+ for stem in ("drums", "bass", "vocals", "other"):
1456
+ respx.get(f"https://r2.example.com/s{i}.{stem}.m4a").mock(
1457
+ return_value=httpx.Response(200, content=f"{stem}{i}".encode())
1458
+ )
1459
+
1460
+
1461
+ @respx.mock
1462
+ def test_text_to_music_stems_forces_async_and_saves_stem_files(tmp_path, capsys):
1463
+ submit = respx.post(f"{BASE}/v1/text-to-music").mock(
1464
+ return_value=httpx.Response(200, json={"task_id": "ts1", "status": "processing"})
1465
+ )
1466
+ respx.get(f"{BASE}/v1/tasks/ts1").mock(
1467
+ return_value=httpx.Response(200, json={
1468
+ "task_id": "ts1", "type": "text_to_music", "status": "succeeded",
1469
+ "audio": [{"stream_index": 0, "url": "https://r2.example.com/ts1.m4a"}],
1470
+ "stems": [_stems_entry(0)],
1471
+ })
1472
+ )
1473
+ respx.get("https://r2.example.com/ts1.m4a").mock(
1474
+ return_value=httpx.Response(200, content=b"MIX")
1475
+ )
1476
+ _mock_stem_downloads(0)
1477
+ out = tmp_path / "take.m4a"
1478
+ run(["text-to-music", "--prompt", "lofi", "--duration", "10",
1479
+ "--stems", "--output", str(out)])
1480
+ # --stems must force the async submit-and-poll path, same as --format wav.
1481
+ assert submit.called
1482
+ body = submit.calls.last.request.content.decode()
1483
+ assert "stems=true" in body
1484
+ assert out.read_bytes() == b"MIX"
1485
+ for stem in ("drums", "bass", "vocals", "other"):
1486
+ assert (tmp_path / f"take.{stem}.m4a").read_bytes() == f"{stem}0".encode()
1487
+ # A clean separation warns about nothing.
1488
+ assert capsys.readouterr().err == ""
1489
+
1490
+
1491
+ @respx.mock
1492
+ def test_text_to_music_omits_stems_when_flag_unset(tmp_path):
1493
+ # Async for another reason (--format wav): the field must stay off the
1494
+ # wire entirely rather than pinning an explicit false.
1495
+ submit = respx.post(f"{BASE}/v1/text-to-music").mock(
1496
+ return_value=httpx.Response(200, json={"task_id": "tn1", "status": "processing"})
1497
+ )
1498
+ respx.get(f"{BASE}/v1/tasks/tn1").mock(
1499
+ return_value=httpx.Response(200, json={
1500
+ "task_id": "tn1", "type": "text_to_music", "status": "succeeded",
1501
+ "audio": [{"stream_index": 0, "url": "https://r2.example.com/tn1.wav"}],
1502
+ })
1503
+ )
1504
+ respx.get("https://r2.example.com/tn1.wav").mock(
1505
+ return_value=httpx.Response(200, content=b"RIF")
1506
+ )
1507
+ run(["text-to-music", "--prompt", "lofi", "--duration", "10",
1508
+ "--format", "wav", "--output", str(tmp_path / "t.wav")])
1509
+ assert b"stems" not in submit.calls.last.request.content
1510
+
1511
+
1512
+ @respx.mock
1513
+ def test_video_to_music_stems_partial_failure_warns_but_saves_the_rest(tmp_path, capsys):
1514
+ """stems_error accompanies a PARTIAL stems list: the warning goes to
1515
+ stderr, the stems that DID come back are still written (named by their
1516
+ own stream_index, not list position), and the run is not an error —
1517
+ the music itself succeeded and separation is free."""
1518
+ submit = respx.post(f"{BASE}/v1/video-to-music").mock(
1519
+ return_value=httpx.Response(200, json={"task_id": "vs1", "status": "processing"})
1520
+ )
1521
+ respx.get(f"{BASE}/v1/tasks/vs1").mock(
1522
+ return_value=httpx.Response(200, json={
1523
+ "task_id": "vs1", "type": "video_to_music", "status": "succeeded",
1524
+ "variants_num": 2,
1525
+ "audio": [
1526
+ {"stream_index": 0, "url": "https://r2.example.com/vs1.0.m4a"},
1527
+ {"stream_index": 1, "url": "https://r2.example.com/vs1.1.m4a"},
1528
+ ],
1529
+ # Only stream 1 separated; positional lookup would misfile these.
1530
+ "stems": [_stems_entry(1)],
1531
+ "stems_error": "stream 0 failed to separate",
1532
+ })
1533
+ )
1534
+ respx.get("https://r2.example.com/vs1.0.m4a").mock(
1535
+ return_value=httpx.Response(200, content=b"A0")
1536
+ )
1537
+ respx.get("https://r2.example.com/vs1.1.m4a").mock(
1538
+ return_value=httpx.Response(200, content=b"A1")
1539
+ )
1540
+ _mock_stem_downloads(1)
1541
+ out = tmp_path / "take.m4a"
1542
+ run(["video-to-music", "--video-url", "http://x/y.mp4",
1543
+ "--variants", "2", "--stems", "--output", str(out)])
1544
+ assert "stems=true" in submit.calls.last.request.content.decode()
1545
+ assert (tmp_path / "take.0.m4a").read_bytes() == b"A0"
1546
+ assert (tmp_path / "take.1.m4a").read_bytes() == b"A1"
1547
+ # Stem files carry stream 1's index; stream 0 has none.
1548
+ assert (tmp_path / "take.1.drums.m4a").read_bytes() == b"drums1"
1549
+ assert (tmp_path / "take.1.other.m4a").read_bytes() == b"other1"
1550
+ assert not (tmp_path / "take.0.drums.m4a").exists()
1551
+ err = capsys.readouterr().err
1552
+ assert "stream 0 failed to separate" in err
1553
+
1554
+
1555
+ def test_stems_passes_the_long_timeout_and_tri_state(tmp_path, monkeypatch):
1556
+ """--stems switches the wait to STEMS_WAIT_TIMEOUT — the SDK's 600s
1557
+ default would abandon a separation that legitimately runs up to 30
1558
+ minutes past generation — while a stems-less async run keeps the
1559
+ default and sends no stems field at all."""
1560
+ from sonilo.resources.text_to_music import TextToMusic
1561
+ from sonilo.types import MusicAudioMedia, MusicResult
1562
+
1563
+ calls = []
1564
+
1565
+ def fake_generate_async(self, **kwargs):
1566
+ calls.append(kwargs)
1567
+ return MusicResult(
1568
+ task_id="t", status="succeeded",
1569
+ audio=[MusicAudioMedia(stream_index=0, url="https://r2.example.com/t.m4a")],
1570
+ stems=kwargs.get("stems") and [],
1571
+ stems_error="separation skipped" if kwargs.get("stems") else None,
1572
+ )
1573
+
1574
+ monkeypatch.setattr(TextToMusic, "generate_async", fake_generate_async)
1575
+ with respx.mock:
1576
+ respx.get("https://r2.example.com/t.m4a").mock(
1577
+ return_value=httpx.Response(200, content=b"A")
1578
+ )
1579
+ run(["text-to-music", "--prompt", "x", "--duration", "10",
1580
+ "--stems", "--output", str(tmp_path / "a.m4a")])
1581
+ run(["text-to-music", "--prompt", "x", "--duration", "10",
1582
+ "--format", "wav", "--output", str(tmp_path / "b.wav")])
1583
+ assert calls[0]["stems"] is True
1584
+ assert calls[0]["timeout"] == 2400.0
1585
+ assert calls[1]["stems"] is None
1586
+ assert calls[1]["timeout"] == 600.0
1587
+
1588
+
1589
+ def test_stems_warns_when_none_come_back(tmp_path, monkeypatch, capsys):
1590
+ from sonilo.resources.text_to_music import TextToMusic
1591
+ from sonilo.types import MusicAudioMedia, MusicResult
1592
+
1593
+ def fake_generate_async(self, **kwargs):
1594
+ return MusicResult(
1595
+ task_id="t", status="succeeded",
1596
+ audio=[MusicAudioMedia(stream_index=0, url="https://r2.example.com/t.m4a")],
1597
+ )
1598
+
1599
+ monkeypatch.setattr(TextToMusic, "generate_async", fake_generate_async)
1600
+ with respx.mock:
1601
+ respx.get("https://r2.example.com/t.m4a").mock(
1602
+ return_value=httpx.Response(200, content=b"A")
1603
+ )
1604
+ run(["text-to-music", "--prompt", "x", "--duration", "10",
1605
+ "--stems", "--output", str(tmp_path / "a.m4a")])
1606
+ # The main output was written and the absence of stems is a warning,
1607
+ # never a failure exit.
1608
+ assert (tmp_path / "a.m4a").read_bytes() == b"A"
1609
+ assert "no stems" in capsys.readouterr().err
File without changes
File without changes