diff --git a/README.md b/README.md index 66828c3..0a600bb 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,55 @@ for i in range(len(result.audio)): default) that's the same single-entry list as before this option existed, and the top-level `result.title` stays an alias for `result.audio[0].title`. +### Stems (async) + +`stems=True` also splits the generated music into four stems — **drums**, +**bass**, **vocals** and **other** — alongside the normal output. It is free +of charge, and an async-only option like `output_format`: `submit()` / +`generate_async()` accept it on both `text_to_music` and `video_to_music` +(an explicit non-async `mode` alongside it raises `SoniloError` locally, +same as the other async-only options). On `video_to_music` it splits the +**generated** music, never the video's own audio. + +```python +result = client.text_to_music.generate_async( + prompt="cinematic orchestral score", + duration=60, + stems=True, + timeout=2400, # separation can run long — see below +) +result.save("track.m4a") +entry = result.stems_for(0) # look up by stream_index, never list position +if entry is not None: + result.save_stem("drums.m4a", which="drums") + result.save_stem("bass.m4a", which="bass", stream_index=0) +if result.stems_error: + print("separation incomplete:", result.stems_error) +``` + +The result gains two **independent** fields: + +- `result.stems` — one entry per stream that separated successfully, each + carrying its `stream_index` and the four stems as media objects + (`url` / `content_type` / `file_size`). Look entries up by `stream_index`, + never by list position — the list can be shorter than `audio` when some + streams failed to separate; `result.stems_for(stream_index)` does the + lookup, and `save_stem(path, which=..., stream_index=...)` downloads one + stem. The stems normally follow `output_format`; each stem's + `content_type` reports what was actually delivered. +- `result.stems_error` — present when separation failed wholly or in part, + or was skipped. It can appear **alongside** a partial `stems` list, so + never treat it as "no stems" — check `stems` itself for what did come + back. + +Separation runs **after** generation: it typically adds 2-6 minutes to the +wait and gives up after 30. The SDK's default wait is `DEFAULT_WAIT_TIMEOUT` +(600 seconds), which a legitimate stems task can outlive — pass a longer +`timeout` explicitly (2400 seconds covers the separation ceiling on top of a +normal generation, and is what the CLI uses), or prefer `submit()` plus your +own `client.tasks.wait(...)`, the same advice as for dubbing. A timed-out +wait only stops waiting; the task keeps running server-side. + ### Prompt influence `prompt_influence` (0-1, API default `0.5`) sets how strongly the generated diff --git a/context7.json b/context7.json index e351aa8..ba5c1f6 100644 --- a/context7.json +++ b/context7.json @@ -15,6 +15,8 @@ "Read the API key from the SONILO_API_KEY environment variable by default; pass api_key= explicitly only when the caller has a reason to override it.", "text-to-music and video-to-music: use generate() (streaming) for short synchronous tracks. It does NOT support output_format=\"wav\", isolate_vocals, preserve_speech or ducking.", "For those options use generate_async(), or submit() + client.tasks.wait(parser=parse_music_result). isolate_vocals/preserve_speech/ducking are video-to-music only; text-to-music takes output_format alone.", + "stems=True on text_to_music / video_to_music submit()/generate_async() splits the GENERATED music (never a video's own audio) into drums/bass/vocals/other. Free, async-only; separation adds 2-6 min (30-min cap) — pass timeout=2400, not the 600s default.", + "Look MusicResult.stems entries up by stream_index (stems_for(i), save_stem(which=, stream_index=)), never list position — the list can be shorter than audio. stems_error can accompany a PARTIAL stems list; never treat it as \"no stems\".", "text-to-sfx and video-to-sfx are always async: submit() returns a task and generate() (or tasks.wait()) polls it to completion. Neither has a streaming variant.", "client.video_to_sound and client.video_to_video_sound score one clip with a music bed AND sound effects in a single call \u2014 prefer them over chaining video-to-music with video-to-sfx, which is charged twice.", "Both sound endpoints are async-only and take identical options: music_prompt and sfx_prompt (NOT a single prompt), preserve_speech, ducking. video_to_sound returns mixed audio; video_to_video_sound returns the video with that audio muxed in.", diff --git a/pyproject.toml b/pyproject.toml index 191f424..5657d89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.14.0" +version = "0.15.0" description = "Official Python client for the Sonilo API" readme = "README.md" license = "MIT" diff --git a/sonilo-cli/README.md b/sonilo-cli/README.md index 173ec0c..cdca81e 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -92,8 +92,8 @@ production sign-in coexist without overwriting each other. ### Notes - `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`, - `--preserve-speech`, `--variants` above 1, and the legacy alias `--isolate-vocals` each switch - to the async submit-and-poll path. + `--preserve-speech`, `--variants` above 1, `--stems`, and the legacy alias `--isolate-vocals` + each switch to the async submit-and-poll path. - `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`. - Output defaults to `./output.`; override with `--output`. @@ -157,6 +157,27 @@ not sent at all and the API's own 0.5 default applies; `--prompt-influence 0` is sonilo video-to-music --video clip.mp4 --prompt "tense synths" --prompt-influence 0.8 +### Music stems + +`--stems` on `text-to-music` and `video-to-music` also splits the generated music into four +stems — drums, bass, vocals, other — saved next to the main output with the stem name spliced +before the extension (`take.m4a` → `take.drums.m4a`, and per variant with `--variants` above 1: +`take.0.drums.m4a`). It is free of charge, and forces the async path. On `video-to-music` it +splits the *generated* music, never the video's own audio. + + sonilo text-to-music --prompt "warm lo-fi piano" --duration 60 --stems --output take.m4a + # writes take.m4a, take.drums.m4a, take.bass.m4a, take.vocals.m4a, take.other.m4a + +- Separation runs **after** generation and typically adds 2-6 minutes to the wait; the CLI + waits up to 2400 seconds on these runs (covering the separation service's own 30-minute + ceiling, the way dubbing's `--timeout` covers its backend's). If the wait still times out, + the task keeps running server-side — resume it with `sonilo tasks wait `. +- Separation can also come up short without failing the run: streams that did not separate are + reported on stderr (the API's `stems_error`), while the stems that did come back are still + saved — a partial result is not an error exit, and the main output is always written. +- Not the same flag as `--stem` on the sound commands, which saves layers those endpoints + already return; `--stems` *requests* a separation the API would not otherwise run. + ### Scored video `video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music` diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index f78471a..e95da50 100644 --- a/sonilo-cli/pyproject.toml +++ b/sonilo-cli/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "hatchling.build" [project] name = "sonilo-cli" -version = "0.13.0" +version = "0.14.0" description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video" readme = "README.md" license = "MIT" requires-python = ">=3.9" authors = [{ name = "Sonilo AI" }] -dependencies = ["sonilo>=0.14.0,<0.15"] +dependencies = ["sonilo>=0.15.0,<0.16"] keywords = ["sonilo", "cli", "music", "sfx", "text-to-music", "video-to-music", "ai"] [project.urls] diff --git a/sonilo-cli/src/sonilo_cli/__init__.py b/sonilo-cli/src/sonilo_cli/__init__.py index 4d59e00..35fc02c 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.13.0" +__version__ = "0.14.0" __all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index 30a8670..c39b406 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -11,6 +11,7 @@ from sonilo import Sonilo from sonilo.errors import APIError, SoniloError +from sonilo.resources.tasks import DEFAULT_WAIT_TIMEOUT from sonilo_cli import __version__, credentials 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: _wrote(path, path.stat().st_size) +# Matched to the separation service's own ceiling on top of the generation +# wait: separation runs after generation, typically adds 2-6 minutes and gives +# up after 30 (1800s), so the SDK's generic DEFAULT_WAIT_TIMEOUT of 600s would +# abandon a stems task the user has already been charged the generation for — +# the same reasoning as DUBBING_WAIT_TIMEOUT below. +STEMS_WAIT_TIMEOUT = 2400.0 + +_MUSIC_STEMS = ("drums", "bass", "vocals", "other") + + +def _save_music_stems(result: Any, out: str) -> None: + """Save the separated stems of an async music result next to `out`, + `take.m4a` becoming `take.drums.m4a` etc. — the same transform --stem + applies on the sound commands. With several variants the stem files pick + up the entry's own stream_index (`take.0.drums.m4a`), matching the + indexed file _save_music_variants wrote for that stream. + + Entries are matched by stream_index, never list position — the list can + be shorter than `audio`. `stems_error` is a warning, not a failure, and + never suppresses the partial stems that did come back: the music itself + succeeded and separation is free, so incomplete stems must not turn the + whole command into an error exit.""" + if result.stems_error: + sys.stderr.write(f"sonilo: stem separation incomplete: {result.stems_error}\n") + entries = result.stems or [] + if not entries: + if not result.stems_error: + sys.stderr.write("sonilo: task succeeded but returned no stems\n") + return + multi = len(result.audio or []) > 1 + for entry in entries: + base = _variant_path(out, entry.stream_index) if multi else out + for stem in _MUSIC_STEMS: + media = getattr(entry, stem, None) + if media is None: + continue + path = result.save_stem( + _stem_path(base, stem, media), + which=stem, stream_index=entry.stream_index, + ) + _wrote(path, path.stat().st_size) + + def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None: fmt = args.format multi = args.variants is not None and args.variants > 1 - use_async = args.use_async or fmt != "m4a" or multi + use_async = args.use_async or fmt != "m4a" or multi or args.stems out = _music_output(args, fmt) segments = _segments(args) if use_async: @@ -304,8 +348,12 @@ def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None: segments=segments, output_format=fmt if fmt != "m4a" else None, variants_num=args.variants, + stems=True if args.stems else None, + timeout=STEMS_WAIT_TIMEOUT if args.stems else DEFAULT_WAIT_TIMEOUT, ) _save_music_variants(result, out) + if args.stems: + _save_music_stems(result, out) else: track = client.text_to_music.generate( prompt=args.prompt, duration=args.duration, segments=segments @@ -318,7 +366,8 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None: fmt = args.format multi = args.variants is not None and args.variants > 1 use_async = ( - args.use_async or fmt != "m4a" or args.isolate_vocals or args.preserve_speech or multi + args.use_async or fmt != "m4a" or args.isolate_vocals or args.preserve_speech + or multi or args.stems ) out = _music_output(args, fmt) segments = _segments(args) @@ -333,8 +382,12 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None: output_format=fmt if fmt != "m4a" else None, variants_num=args.variants, prompt_influence=args.prompt_influence, + stems=True if args.stems else None, + timeout=STEMS_WAIT_TIMEOUT if args.stems else DEFAULT_WAIT_TIMEOUT, ) _save_music_variants(result, out) + if args.stems: + _save_music_stems(result, out) else: # prompt_influence rides the streaming path too — it is a generation # parameter, not a finalize-time one, so it never forces async. @@ -675,6 +728,21 @@ def _add_variants(parser: argparse.ArgumentParser) -> None: ) +def _add_stems(parser: argparse.ArgumentParser) -> None: + # Only the two music-generation commands take this — the API accepts it + # nowhere else. Not to be confused with --stem on the sound commands, + # which saves layers those endpoints already return; --stems *requests* + # a separation the API would not otherwise run. + parser.add_argument( + "--stems", action="store_true", + help="Also split the generated music into drums/bass/vocals/other " + "stems, saved next to the output (output.drums.m4a, ...). Free " + "of charge. Forces async, and separation typically adds 2-6 " + "minutes to the wait. Streams that fail to separate are " + "reported on stderr; the rest are still saved.", + ) + + def build_parser() -> argparse.ArgumentParser: parser = _Parser(prog="sonilo", description="Command-line interface for the Sonilo API") parser.add_argument("--version", action="version", version=__version__) @@ -728,6 +796,7 @@ def build_parser() -> argparse.ArgumentParser: p_t2m.add_argument("--async", dest="use_async", action="store_true", help="Submit and poll instead of streaming.") _add_variants(p_t2m) + _add_stems(p_t2m) p_t2m.set_defaults(func=cmd_text_to_music) p_v2m = sub.add_parser("video-to-music", help="Generate music matched to a video") @@ -750,6 +819,7 @@ def build_parser() -> argparse.ArgumentParser: help="Submit and poll instead of streaming.") _add_variants(p_v2m) _add_prompt_influence(p_v2m) + _add_stems(p_v2m) p_v2m.set_defaults(func=cmd_video_to_music) p_t2s = sub.add_parser("text-to-sfx", help="Generate a sound effect from a text prompt") diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index 0625e19..49b6b6c 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -1435,3 +1435,175 @@ def test_video_analysis_requires_a_video_source(capsys): # Asserted on the message, not just the exit code: an unknown command # also exits 1, so the code alone would pass before the command exists. assert "--video" in capsys.readouterr().err + + +# ---------- --stems (music stem separation) ---------- + + +def _stems_entry(i, base="https://r2.example.com"): + return { + "stream_index": i, + "drums": {"url": f"{base}/s{i}.drums.m4a"}, + "bass": {"url": f"{base}/s{i}.bass.m4a"}, + "vocals": {"url": f"{base}/s{i}.vocals.m4a"}, + "other": {"url": f"{base}/s{i}.other.m4a"}, + } + + +def _mock_stem_downloads(*indices): + for i in indices: + for stem in ("drums", "bass", "vocals", "other"): + respx.get(f"https://r2.example.com/s{i}.{stem}.m4a").mock( + return_value=httpx.Response(200, content=f"{stem}{i}".encode()) + ) + + +@respx.mock +def test_text_to_music_stems_forces_async_and_saves_stem_files(tmp_path, capsys): + submit = respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response(200, json={"task_id": "ts1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/ts1").mock( + return_value=httpx.Response(200, json={ + "task_id": "ts1", "type": "text_to_music", "status": "succeeded", + "audio": [{"stream_index": 0, "url": "https://r2.example.com/ts1.m4a"}], + "stems": [_stems_entry(0)], + }) + ) + respx.get("https://r2.example.com/ts1.m4a").mock( + return_value=httpx.Response(200, content=b"MIX") + ) + _mock_stem_downloads(0) + out = tmp_path / "take.m4a" + run(["text-to-music", "--prompt", "lofi", "--duration", "10", + "--stems", "--output", str(out)]) + # --stems must force the async submit-and-poll path, same as --format wav. + assert submit.called + body = submit.calls.last.request.content.decode() + assert "stems=true" in body + assert out.read_bytes() == b"MIX" + for stem in ("drums", "bass", "vocals", "other"): + assert (tmp_path / f"take.{stem}.m4a").read_bytes() == f"{stem}0".encode() + # A clean separation warns about nothing. + assert capsys.readouterr().err == "" + + +@respx.mock +def test_text_to_music_omits_stems_when_flag_unset(tmp_path): + # Async for another reason (--format wav): the field must stay off the + # wire entirely rather than pinning an explicit false. + submit = respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response(200, json={"task_id": "tn1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/tn1").mock( + return_value=httpx.Response(200, json={ + "task_id": "tn1", "type": "text_to_music", "status": "succeeded", + "audio": [{"stream_index": 0, "url": "https://r2.example.com/tn1.wav"}], + }) + ) + respx.get("https://r2.example.com/tn1.wav").mock( + return_value=httpx.Response(200, content=b"RIF") + ) + run(["text-to-music", "--prompt", "lofi", "--duration", "10", + "--format", "wav", "--output", str(tmp_path / "t.wav")]) + assert b"stems" not in submit.calls.last.request.content + + +@respx.mock +def test_video_to_music_stems_partial_failure_warns_but_saves_the_rest(tmp_path, capsys): + """stems_error accompanies a PARTIAL stems list: the warning goes to + stderr, the stems that DID come back are still written (named by their + own stream_index, not list position), and the run is not an error — + the music itself succeeded and separation is free.""" + submit = respx.post(f"{BASE}/v1/video-to-music").mock( + return_value=httpx.Response(200, json={"task_id": "vs1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/vs1").mock( + return_value=httpx.Response(200, json={ + "task_id": "vs1", "type": "video_to_music", "status": "succeeded", + "variants_num": 2, + "audio": [ + {"stream_index": 0, "url": "https://r2.example.com/vs1.0.m4a"}, + {"stream_index": 1, "url": "https://r2.example.com/vs1.1.m4a"}, + ], + # Only stream 1 separated; positional lookup would misfile these. + "stems": [_stems_entry(1)], + "stems_error": "stream 0 failed to separate", + }) + ) + respx.get("https://r2.example.com/vs1.0.m4a").mock( + return_value=httpx.Response(200, content=b"A0") + ) + respx.get("https://r2.example.com/vs1.1.m4a").mock( + return_value=httpx.Response(200, content=b"A1") + ) + _mock_stem_downloads(1) + out = tmp_path / "take.m4a" + run(["video-to-music", "--video-url", "http://x/y.mp4", + "--variants", "2", "--stems", "--output", str(out)]) + assert "stems=true" in submit.calls.last.request.content.decode() + assert (tmp_path / "take.0.m4a").read_bytes() == b"A0" + assert (tmp_path / "take.1.m4a").read_bytes() == b"A1" + # Stem files carry stream 1's index; stream 0 has none. + assert (tmp_path / "take.1.drums.m4a").read_bytes() == b"drums1" + assert (tmp_path / "take.1.other.m4a").read_bytes() == b"other1" + assert not (tmp_path / "take.0.drums.m4a").exists() + err = capsys.readouterr().err + assert "stream 0 failed to separate" in err + + +def test_stems_passes_the_long_timeout_and_tri_state(tmp_path, monkeypatch): + """--stems switches the wait to STEMS_WAIT_TIMEOUT — the SDK's 600s + default would abandon a separation that legitimately runs up to 30 + minutes past generation — while a stems-less async run keeps the + default and sends no stems field at all.""" + from sonilo.resources.text_to_music import TextToMusic + from sonilo.types import MusicAudioMedia, MusicResult + + calls = [] + + def fake_generate_async(self, **kwargs): + calls.append(kwargs) + return MusicResult( + task_id="t", status="succeeded", + audio=[MusicAudioMedia(stream_index=0, url="https://r2.example.com/t.m4a")], + stems=kwargs.get("stems") and [], + stems_error="separation skipped" if kwargs.get("stems") else None, + ) + + monkeypatch.setattr(TextToMusic, "generate_async", fake_generate_async) + with respx.mock: + respx.get("https://r2.example.com/t.m4a").mock( + return_value=httpx.Response(200, content=b"A") + ) + run(["text-to-music", "--prompt", "x", "--duration", "10", + "--stems", "--output", str(tmp_path / "a.m4a")]) + run(["text-to-music", "--prompt", "x", "--duration", "10", + "--format", "wav", "--output", str(tmp_path / "b.wav")]) + assert calls[0]["stems"] is True + assert calls[0]["timeout"] == 2400.0 + assert calls[1]["stems"] is None + assert calls[1]["timeout"] == 600.0 + + +def test_stems_warns_when_none_come_back(tmp_path, monkeypatch, capsys): + from sonilo.resources.text_to_music import TextToMusic + from sonilo.types import MusicAudioMedia, MusicResult + + def fake_generate_async(self, **kwargs): + return MusicResult( + task_id="t", status="succeeded", + audio=[MusicAudioMedia(stream_index=0, url="https://r2.example.com/t.m4a")], + ) + + monkeypatch.setattr(TextToMusic, "generate_async", fake_generate_async) + with respx.mock: + respx.get("https://r2.example.com/t.m4a").mock( + return_value=httpx.Response(200, content=b"A") + ) + run(["text-to-music", "--prompt", "x", "--duration", "10", + "--stems", "--output", str(tmp_path / "a.m4a")]) + # The main output was written and the absence of stems is a warning, + # never a failure exit. + assert (tmp_path / "a.m4a").read_bytes() == b"A" + assert "no stems" in capsys.readouterr().err diff --git a/src/sonilo/__init__.py b/src/sonilo/__init__.py index e6e6972..4f390c0 100644 --- a/src/sonilo/__init__.py +++ b/src/sonilo/__init__.py @@ -20,6 +20,7 @@ DubbingResult, MusicAudioMedia, MusicResult, + MusicStems, MusicTitle, Segment, SfxMedia, @@ -45,6 +46,7 @@ "GenerationError", "MusicAudioMedia", "MusicResult", + "MusicStems", "MusicTitle", "PaymentRequiredError", "RateLimitError", diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index 3765f7b..d36e58a 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -41,6 +41,7 @@ def build_t2m_async_data( mode: Optional[str], output_format: Optional[str], variants_num: Optional[int] = None, + stems: Optional[bool] = None, ) -> Dict[str, str]: data = build_t2m_data(prompt, duration, segments) resolved = mode or "async" @@ -51,6 +52,10 @@ def build_t2m_async_data( data["output_format"] = output_format if variants_num is not None: data["variants_num"] = str(variants_num) + # stems requires mode='async', which this whole builder already enforces + # above — no per-field guard needed, unlike build_v2m_async_parts. + if stems is not None: + data["stems"] = "true" if stems else "false" return data @@ -254,12 +259,13 @@ def _resolve_music_mode( output_format: Optional[str] = None, ducking: Optional[bool] = None, variants_num: Optional[int] = None, + stems: Optional[bool] = None, ) -> str: """isolate_vocals/preserve_speech/ducking/a non-m4a output_format/ - variants_num>1 only work with async processing: auto-select mode "async" - when the caller didn't specify one, but fail fast if they explicitly - asked for anything else. submit() also needs an async response (a - task_id ack, not a stream), so "async" is the default regardless. + variants_num>1/stems only work with async processing: auto-select mode + "async" when the caller didn't specify one, but fail fast if they + explicitly asked for anything else. submit() also needs an async response + (a task_id ack, not a stream), so "async" is the default regardless. """ needs_async = ( bool(isolate_vocals) @@ -269,12 +275,16 @@ def _resolve_music_mode( # formats are added (mp3 landed after the original check). or (output_format is not None and output_format != "m4a") or ducking is not None + # bool(), not `is not None`: an explicit stems=False asks for nothing + # finalize-time, so it must not force async the way requesting + # separation does — same shape as isolate_vocals/preserve_speech. + or bool(stems) or (variants_num is not None and variants_num > 1) ) if needs_async and mode is not None and mode != "async": raise SoniloError( "isolate_vocals/preserve_speech/ducking/output_format other " - "than 'm4a'/variants_num>1 require mode='async'" + "than 'm4a'/variants_num>1/stems require mode='async'" ) return "async" if needs_async else (mode or "async") @@ -291,6 +301,7 @@ def build_v2m_async_parts( ducking: Optional[bool] = None, variants_num: Optional[int] = None, prompt_influence: Optional[float] = None, + stems: Optional[bool] = None, ) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: """Like build_v2m_parts, plus the async-only fields for the video-to-music submit()/generate_async() path. @@ -299,7 +310,8 @@ def build_v2m_async_parts( parameter, valid on stream and async alike — so it lives in build_v2m_parts and takes no part in _resolve_music_mode.""" resolved_mode = _resolve_music_mode( - mode, isolate_vocals, preserve_speech, output_format, ducking, variants_num + mode, isolate_vocals, preserve_speech, output_format, ducking, variants_num, + stems=stems, ) data, files, opened = build_v2m_parts( video, video_url, prompt, segments, prompt_influence=prompt_influence @@ -313,10 +325,10 @@ def build_v2m_async_parts( data["output_format"] = output_format if ducking is not None: data["ducking"] = "true" if ducking else "false" - if output_format is not None: - data["output_format"] = output_format if variants_num is not None: data["variants_num"] = str(variants_num) + if stems is not None: + data["stems"] = "true" if stems else "false" return data, files, opened diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index 9e78220..9da2f8f 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.14.0" +__version__ = "0.15.0" diff --git a/src/sonilo/resources/tasks.py b/src/sonilo/resources/tasks.py index cf9adba..e2922ce 100644 --- a/src/sonilo/resources/tasks.py +++ b/src/sonilo/resources/tasks.py @@ -12,6 +12,7 @@ DubbingResult, MusicAudioMedia, MusicResult, + MusicStems, MusicTitle, SfxMedia, SfxResult, @@ -108,6 +109,26 @@ def _music_audio_list_from(data: Any) -> Optional[List[MusicAudioMedia]]: return items +def _music_stems_from(data: Any) -> Optional[MusicStems]: + if not isinstance(data, dict) or "stream_index" not in data: + return None + return MusicStems( + stream_index=data["stream_index"], + drums=_media_from(data.get("drums")), + bass=_media_from(data.get("bass")), + vocals=_media_from(data.get("vocals")), + other=_media_from(data.get("other")), + ) + + +def _music_stems_list_from(data: Any) -> Optional[List[MusicStems]]: + if not isinstance(data, list): + return None + return [ + item for item in (_music_stems_from(entry) for entry in data) if item is not None + ] + + def _media_list_from(data: Any) -> Optional[List[SfxMedia]]: if not isinstance(data, list): return None @@ -143,7 +164,10 @@ def parse_music_result(body: Dict[str, Any]) -> MusicResult: MusicResult; unknown fields are ignored. `audio` is always a list; `vocals`/`mux` are only populated when the - task was submitted with isolate_vocals=True. + task was submitted with isolate_vocals=True, `stems`/`stems_error` when + it was submitted with stems=True. The two stems fields are independent: + `stems_error` can accompany a partial `stems` list, so both are parsed + unconditionally rather than one gating the other. """ try: return MusicResult( @@ -160,6 +184,8 @@ def parse_music_result(body: Dict[str, Any]) -> MusicResult: error=body.get("error"), refunded=body.get("refunded"), variants_num=body.get("variants_num"), + stems=_music_stems_list_from(body.get("stems")), + stems_error=body.get("stems_error"), ) except KeyError as e: raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e diff --git a/src/sonilo/resources/text_to_music.py b/src/sonilo/resources/text_to_music.py index edace9d..20362b7 100644 --- a/src/sonilo/resources/text_to_music.py +++ b/src/sonilo/resources/text_to_music.py @@ -51,6 +51,7 @@ def submit( mode: Optional[str] = None, output_format: Optional[str] = None, variants_num: Optional[int] = None, + stems: Optional[bool] = None, ) -> SfxTask: """Submit an async text-to-music task; poll with `client.tasks.wait(task_id, parser=sonilo.resources.tasks.parse_music_result)`. @@ -61,9 +62,16 @@ def submit( variants in one request; the result's `audio` gets one entry per variant. Cost scales linearly, and values above 1 are never covered by the free trial. + + `stems=True` (free of charge) also splits the generated music into + drums/bass/vocals/other — the result gains a `stems` list (looked up + by `stream_index`, never position) and possibly a `stems_error`. + Separation runs after generation and typically adds 2-6 minutes, + giving up after 30; when polling yourself, pass tasks.wait() a + `timeout` well above its 600-second default (2400 covers the ceiling). """ data = build_t2m_async_data( - prompt, duration, segments, mode, output_format, variants_num + prompt, duration, segments, mode, output_format, variants_num, stems ) return parse_sfx_task(self._client._post_json(PATH, data=data)) @@ -76,13 +84,20 @@ def generate_async( mode: Optional[str] = None, output_format: Optional[str] = None, variants_num: Optional[int] = None, + stems: Optional[bool] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> MusicResult: - """submit() + tasks.wait(), returning the parsed MusicResult.""" + """submit() + tasks.wait(), returning the parsed MusicResult. + + With `stems=True`, pass a `timeout` well above the 600-second default + (2400 covers the separation service's 30-minute ceiling) — see + submit(). + """ task = self.submit( prompt=prompt, duration=duration, segments=segments, mode=mode, output_format=output_format, variants_num=variants_num, + stems=stems, ) return self._client.tasks.wait( task.task_id, poll_interval=poll_interval, timeout=timeout, @@ -124,14 +139,18 @@ async def submit( mode: Optional[str] = None, output_format: Optional[str] = None, variants_num: Optional[int] = None, + stems: Optional[bool] = None, ) -> SfxTask: """Submit an async text-to-music task; poll with `client.tasks.wait(task_id, parser=sonilo.resources.tasks.parse_music_result)`. Required for output_format="wav" and for variants_num > 1. `stream()`/`generate()` remain the streaming path. + + `stems=True` (free) also splits the generated music into + drums/bass/vocals/other — see the sync TextToMusic.submit(). """ data = build_t2m_async_data( - prompt, duration, segments, mode, output_format, variants_num + prompt, duration, segments, mode, output_format, variants_num, stems ) return parse_sfx_task(await self._client._post_json(PATH, data=data)) @@ -144,13 +163,19 @@ async def generate_async( mode: Optional[str] = None, output_format: Optional[str] = None, variants_num: Optional[int] = None, + stems: Optional[bool] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> MusicResult: - """submit() + tasks.wait(), returning the parsed MusicResult.""" + """submit() + tasks.wait(), returning the parsed MusicResult. + + With `stems=True`, pass a `timeout` well above the 600-second default + (2400 covers the separation service's 30-minute ceiling). + """ task = await self.submit( prompt=prompt, duration=duration, segments=segments, mode=mode, output_format=output_format, variants_num=variants_num, + stems=stems, ) return await self._client.tasks.wait( task.task_id, poll_interval=poll_interval, timeout=timeout, diff --git a/src/sonilo/resources/video_to_music.py b/src/sonilo/resources/video_to_music.py index 50ebdf0..a35a5bb 100644 --- a/src/sonilo/resources/video_to_music.py +++ b/src/sonilo/resources/video_to_music.py @@ -75,11 +75,12 @@ def submit( ducking: Optional[bool] = None, variants_num: Optional[int] = None, prompt_influence: Optional[float] = None, + stems: Optional[bool] = None, ) -> SfxTask: """Submit an async video-to-music task and return its ack. isolate_vocals/preserve_speech/ducking/output_format="wav"/ - variants_num>1 require mode="async" (auto-selected if `mode` is + variants_num>1/stems require mode="async" (auto-selected if `mode` is omitted); passing an explicit non-async mode alongside any of them raises a SoniloError before any request is made. Poll with `client.tasks.wait(task_id, parser=sonilo.resources.tasks.parse_music_result)` @@ -95,6 +96,14 @@ def submit( higher values follow the prompt more literally. Free of charge and not async-only — stream()/generate() take it too. Out-of-range values are rejected by the API with a 422. + + `stems=True` (free of charge) also splits the GENERATED music — + never the video's own audio — into drums/bass/vocals/other: the + result gains a `stems` list (looked up by `stream_index`, never + position) and possibly a `stems_error`. Separation runs after + generation and typically adds 2-6 minutes, giving up after 30; when + polling yourself, pass tasks.wait() a `timeout` well above its + 600-second default (2400 covers the ceiling). """ data, files, opened = build_v2m_async_parts( video, video_url, prompt, segments, mode, isolate_vocals, @@ -103,6 +112,7 @@ def submit( ducking=ducking, variants_num=variants_num, prompt_influence=prompt_influence, + stems=stems, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -123,10 +133,16 @@ def generate_async( ducking: Optional[bool] = None, variants_num: Optional[int] = None, prompt_influence: Optional[float] = None, + stems: Optional[bool] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> MusicResult: - """submit() + tasks.wait(), returning the parsed MusicResult.""" + """submit() + tasks.wait(), returning the parsed MusicResult. + + With `stems=True`, pass a `timeout` well above the 600-second default + (2400 covers the separation service's 30-minute ceiling) — see + submit(). + """ task = self.submit( video=video, video_url=video_url, @@ -139,6 +155,7 @@ def generate_async( ducking=ducking, variants_num=variants_num, prompt_influence=prompt_influence, + stems=stems, ) return self._client.tasks.wait( task.task_id, @@ -204,11 +221,12 @@ async def submit( ducking: Optional[bool] = None, variants_num: Optional[int] = None, prompt_influence: Optional[float] = None, + stems: Optional[bool] = None, ) -> SfxTask: """Submit an async video-to-music task and return its ack. isolate_vocals/preserve_speech/ducking/output_format="wav"/ - variants_num>1 require mode="async" (auto-selected if `mode` is + variants_num>1/stems require mode="async" (auto-selected if `mode` is omitted); passing an explicit non-async mode alongside any of them raises a SoniloError before any request is made. @@ -216,6 +234,10 @@ async def submit( generated music follows the prompt: lower values let the video lead; higher values follow the prompt more literally. Free of charge and not async-only — stream()/generate() take it too. + + `stems=True` (free) also splits the GENERATED music — never the + video's own audio — into drums/bass/vocals/other; see the sync + VideoToMusic.submit(). """ data, files, opened = build_v2m_async_parts( video, video_url, prompt, segments, mode, isolate_vocals, @@ -224,6 +246,7 @@ async def submit( ducking=ducking, variants_num=variants_num, prompt_influence=prompt_influence, + stems=stems, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -246,10 +269,15 @@ async def generate_async( ducking: Optional[bool] = None, variants_num: Optional[int] = None, prompt_influence: Optional[float] = None, + stems: Optional[bool] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> MusicResult: - """submit() + tasks.wait(), returning the parsed MusicResult.""" + """submit() + tasks.wait(), returning the parsed MusicResult. + + With `stems=True`, pass a `timeout` well above the 600-second default + (2400 covers the separation service's 30-minute ceiling). + """ task = await self.submit( video=video, video_url=video_url, @@ -262,6 +290,7 @@ async def generate_async( ducking=ducking, variants_num=variants_num, prompt_influence=prompt_influence, + stems=stems, ) return await self._client.tasks.wait( task.task_id, diff --git a/src/sonilo/types.py b/src/sonilo/types.py index 69c8e83..50dd5e5 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -177,6 +177,29 @@ class MusicAudioMedia: title: Optional[MusicTitle] = None +_MUSIC_STEM_NAMES = ("drums", "bass", "vocals", "other") + + +@dataclass +class MusicStems: + """One entry of a music task's `stems` array — the four separated stems + for one generated stream, present only for streams that separated + successfully. + + Look entries up by `stream_index`, never by list position: the list can + be shorter than `audio` when some streams failed to separate (use + `MusicResult.stems_for`). The stems normally follow the task's + `output_format`; each stem's `content_type` reports what was actually + delivered. + """ + + stream_index: int + drums: Optional[SfxMedia] = None + bass: Optional[SfxMedia] = None + vocals: Optional[SfxMedia] = None + other: Optional[SfxMedia] = None + + @dataclass class MusicResult: """State of an async video-to-music task (`tasks.get`) or its final @@ -184,7 +207,8 @@ class MusicResult: `audio` is always a list for async video-to-music. `vocals` (a single file) and `mux` (a list) are only populated when the task was submitted - with `isolate_vocals=True`. + with `isolate_vocals=True`; `stems`/`stems_error` only when it was + submitted with `stems=True`. """ task_id: str @@ -204,6 +228,14 @@ class MusicResult: when present) then holds one entry per variant instead of one entry per stream of a single generation; `title` stays an alias for `audio[0]`'s title.""" + stems: Optional[List[MusicStems]] = None + """One entry per stream that separated successfully — looked up by + `stream_index`, never list position, because the list can be shorter than + `audio`. Only populated when the task was submitted with `stems=True`.""" + stems_error: Optional[str] = None + """Present when stem separation failed wholly or in part, or was skipped. + It can appear ALONGSIDE a partial `stems` list, so never treat it as + "no stems" — check `stems` itself for what did come back.""" def _media(self, which: str, index: int) -> Union[SfxMedia, MusicAudioMedia]: if which == "vocals": @@ -261,6 +293,65 @@ async def asave( p.write_bytes(response.content) return p + def stems_for(self, stream_index: int) -> Optional[MusicStems]: + """The stems entry for one stream, or None when that stream did not + separate. Entries are matched on their `stream_index` field, never on + list position — the `stems` list can be shorter than `audio`, so + `stems[i]` would silently pair the wrong stems with a track.""" + for entry in self.stems or []: + if entry.stream_index == stream_index: + return entry + return None + + def _separated_stem(self, which: str, stream_index: int) -> SfxMedia: + if which not in _MUSIC_STEM_NAMES: + raise SoniloError( + f"Unknown stem {which!r}; expected one of {', '.join(_MUSIC_STEM_NAMES)}" + ) + entry = self.stems_for(stream_index) + if entry is None: + # stems_error is the API's own account of why separation came up + # short — surface it here so a missing entry explains itself. + hint = f"; separation reported: {self.stems_error}" if self.stems_error else "" + raise SoniloError( + f"No stems for stream {stream_index} on this result " + f"(status={self.status}{hint})" + ) + media = getattr(entry, which) + if media is None: + raise SoniloError( + f"No {which} stem for stream {stream_index} on this result " + f"(status={self.status})" + ) + return media + + def save_stem( + self, + path: Union[str, Path], + *, + which: str, + stream_index: int = 0, + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Path: + """Download one separated stem ("drums", "bass", "vocals" or "other") + to `path` and return it. `stream_index` selects the stream/variant the + stems belong to, matched by the entries' own `stream_index` field — + never list position. The URL is presigned — no API key is sent.""" + return _download_to(self._separated_stem(which, stream_index).url, path, timeout) + + async def asave_stem( + self, + path: Union[str, Path], + *, + which: str, + stream_index: int = 0, + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Path: + """Async variant of save_stem().""" + return await _adownload_to( + self._separated_stem(which, stream_index).url, path, timeout + ) + @dataclass class VideoResult: diff --git a/tests/test_stems.py b/tests/test_stems.py new file mode 100644 index 0000000..5d77572 --- /dev/null +++ b/tests/test_stems.py @@ -0,0 +1,263 @@ +"""Covers the `stems` option on text-to-music and video-to-music. + +`stems=True` (free of charge) asks the API to also split the GENERATED music +into drums/bass/vocals/other after generation. These tests pin the request +side — the field is omitted unless explicitly passed, an explicit False is +sent, and requesting stems is async-only with the same local fail-fast as the +other finalize-time options — and the result side, whose two fields are +independent by contract: `stems` entries are looked up by `stream_index` +(never list position — the list can be shorter than `audio`), and +`stems_error` can accompany a PARTIAL `stems` list, so it must never be +treated as "no stems". +""" +import inspect + +import httpx +import pytest +import respx + +from sonilo import MusicStems, SoniloError +from sonilo._requests import build_t2m_async_data, build_v2m_async_parts +from sonilo.resources.audio_ducking import AsyncAudioDucking, AudioDucking +from sonilo.resources.dubbing import AsyncDubbing, Dubbing +from sonilo.resources.tasks import parse_music_result +from sonilo.resources.text_to_music import AsyncTextToMusic, TextToMusic +from sonilo.resources.video_to_music import AsyncVideoToMusic, VideoToMusic +from sonilo.resources.video_to_sfx import AsyncVideoToSfx, VideoToSfx +from sonilo.resources.video_to_sound import AsyncVideoToSound, VideoToSound +from sonilo.resources.video_to_video_music import ( + AsyncVideoToVideoMusic, + VideoToVideoMusic, +) +from sonilo.resources.video_to_video_sound import ( + AsyncVideoToVideoSound, + VideoToVideoSound, +) +from sonilo.types import MusicResult, SfxMedia + + +# --- the request: text-to-music ---------------------------------------------- + +def test_t2m_async_data_sends_stems(): + data = build_t2m_async_data("lofi", 30, None, None, None, stems=True) + assert data["stems"] == "true" + + +def test_t2m_async_data_sends_explicit_false(): + # Same tri-state as ducking: None means unset, False is a real value that + # goes on the wire. + data = build_t2m_async_data("lofi", 30, None, None, None, stems=False) + assert data["stems"] == "false" + + +def test_t2m_async_data_omits_stems_when_unset(): + data = build_t2m_async_data("lofi", 30, None, None, None) + assert "stems" not in data + + +# --- the request: video-to-music --------------------------------------------- + +def test_v2m_async_parts_sends_stems_and_auto_selects_async(): + data, _, _ = build_v2m_async_parts( + None, "https://x/v.mp4", None, None, None, None, stems=True + ) + assert data["stems"] == "true" + assert data["mode"] == "async" + + +def test_v2m_async_parts_omits_stems_when_unset(): + data, _, _ = build_v2m_async_parts( + None, "https://x/v.mp4", None, None, None, None + ) + assert "stems" not in data + + +def test_v2m_stems_with_explicit_non_async_mode_raises_locally(): + # The API answers 400 "stems requires mode=async"; the SDK fails fast + # before any request instead, same as the other async-only options. + with pytest.raises(SoniloError, match="stems"): + build_v2m_async_parts( + None, "https://x/v.mp4", None, None, "sync", None, stems=True + ) + + +def test_v2m_explicit_false_stems_does_not_force_async(): + # bool(stems), not `is not None`: stems=False requests nothing + # finalize-time, so it must not hijack an explicitly chosen mode. + data, _, _ = build_v2m_async_parts( + None, "https://x/v.mp4", None, None, "sync", None, stems=False + ) + assert data["mode"] == "sync" + assert data["stems"] == "false" + + +# --- the result: parsing ------------------------------------------------------ + +def _body(**overrides): + body = { + "task_id": "t1", + "type": "text_to_music", + "status": "succeeded", + "audio": [ + {"stream_index": 0, "url": "https://r2.example.com/a0.m4a"}, + {"stream_index": 1, "url": "https://r2.example.com/a1.m4a"}, + ], + } + body.update(overrides) + return body + + +def _stems_entry(i): + return { + "stream_index": i, + "drums": {"url": f"https://r2.example.com/{i}.drums.m4a", + "content_type": "audio/mp4", "file_size": 4}, + "bass": {"url": f"https://r2.example.com/{i}.bass.m4a"}, + "vocals": {"url": f"https://r2.example.com/{i}.vocals.m4a"}, + "other": {"url": f"https://r2.example.com/{i}.other.m4a"}, + } + + +def test_parse_music_result_parses_stems(): + result = parse_music_result(_body(stems=[_stems_entry(0), _stems_entry(1)])) + assert result.stems is not None and len(result.stems) == 2 + entry = result.stems[0] + assert isinstance(entry, MusicStems) + assert entry.stream_index == 0 + assert isinstance(entry.drums, SfxMedia) + assert entry.drums.url == "https://r2.example.com/0.drums.m4a" + assert entry.drums.content_type == "audio/mp4" + assert entry.bass.url == "https://r2.example.com/0.bass.m4a" + assert result.stems_error is None + + +def test_parse_music_result_without_stems_fields(): + result = parse_music_result(_body()) + assert result.stems is None + assert result.stems_error is None + + +def test_stems_error_alongside_partial_stems(): + """The two fields are independent: a stems_error must never hide the + entries that DID separate.""" + result = parse_music_result( + _body(stems=[_stems_entry(1)], stems_error="stream 0 failed to separate") + ) + assert result.stems_error == "stream 0 failed to separate" + assert result.stems is not None and len(result.stems) == 1 + assert result.stems[0].stream_index == 1 + + +def test_malformed_stems_entries_are_dropped(): + # Same coercion policy as parse_dubbing_result's outputs: a + # differently-shaped entry from a later backend change surfaces as a + # missing item, not an AttributeError deep in the caller's loop. + result = parse_music_result( + _body(stems=["nope", {"no_stream_index": True}, _stems_entry(0)]) + ) + assert [e.stream_index for e in result.stems] == [0] + + +def test_non_list_stems_parses_as_none(): + result = parse_music_result(_body(stems={"stream_index": 0})) + assert result.stems is None + + +# --- the result: stream_index lookup and save_stem --------------------------- + +def _partial_result(): + """Only stream 1 separated — the list is shorter than audio, so a + positional stems[0] would silently hand back the wrong stream's stems.""" + return parse_music_result( + _body(stems=[_stems_entry(1)], stems_error="stream 0 failed to separate") + ) + + +def test_stems_for_matches_stream_index_not_position(): + result = _partial_result() + assert result.stems_for(1) is result.stems[0] + assert result.stems_for(0) is None + + +@respx.mock +def test_save_stem_looks_up_by_stream_index(tmp_path): + respx.get("https://r2.example.com/1.drums.m4a").mock( + return_value=httpx.Response(200, content=b"drumbytes") + ) + out = _partial_result().save_stem( + tmp_path / "drums.m4a", which="drums", stream_index=1 + ) + assert out.read_bytes() == b"drumbytes" + assert "authorization" not in respx.calls.last.request.headers + + +def test_save_stem_missing_stream_names_the_stems_error(tmp_path): + with pytest.raises(SoniloError, match="stream 0 failed to separate"): + _partial_result().save_stem(tmp_path / "x.m4a", which="drums", stream_index=0) + + +def test_save_stem_rejects_unknown_which(tmp_path): + with pytest.raises(SoniloError, match="drums, bass, vocals, other"): + _partial_result().save_stem(tmp_path / "x.m4a", which="mux", stream_index=1) + + +def test_save_stem_missing_media_raises(tmp_path): + result = MusicResult( + task_id="t1", status="succeeded", + stems=[MusicStems(stream_index=0)], + ) + with pytest.raises(SoniloError, match="No drums stem"): + result.save_stem(tmp_path / "x.m4a", which="drums") + + +async def test_asave_stem_matches_save_stem(tmp_path): + with respx.mock: + respx.get("https://r2.example.com/1.vocals.m4a").mock( + return_value=httpx.Response(200, content=b"vox") + ) + out = await _partial_result().asave_stem( + tmp_path / "vocals.m4a", which="vocals", stream_index=1 + ) + assert out.read_bytes() == b"vox" + + +# --- the public signatures ---------------------------------------------------- + +def test_music_async_paths_expose_stems(): + for cls in (TextToMusic, AsyncTextToMusic, VideoToMusic, AsyncVideoToMusic): + for method in ("submit", "generate_async"): + params = inspect.signature(getattr(cls, method)).parameters + assert "stems" in params, f"{cls.__name__}.{method}" + + +def test_streaming_paths_do_not_expose_stems(): + """stems is finalize-time and async-only — the streaming methods must not + grow it.""" + for cls in (TextToMusic, AsyncTextToMusic, VideoToMusic, AsyncVideoToMusic): + for method in ("stream", "generate"): + params = inspect.signature(getattr(cls, method)).parameters + assert "stems" not in params, f"{cls.__name__}.{method}" + + +def test_other_endpoints_do_not_expose_stems(): + """The API accepts stems on text-to-music and video-to-music only — not + the video-out endpoints, not the sound combos, not dubbing or ducking. + Asserted on the public signatures so adding it by reflex would fail + here.""" + for cls in ( + VideoToSfx, + AsyncVideoToSfx, + VideoToSound, + AsyncVideoToSound, + VideoToVideoMusic, + AsyncVideoToVideoMusic, + VideoToVideoSound, + AsyncVideoToVideoSound, + Dubbing, + AsyncDubbing, + AudioDucking, + AsyncAudioDucking, + ): + for method in ("submit", "generate"): + params = inspect.signature(getattr(cls, method)).parameters + assert "stems" not in params, f"{cls.__name__}.{method}"