From 11b539333816c33e0877e8a43567585fa4ca1db2 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Thu, 16 Jul 2026 14:40:22 -0700 Subject: [PATCH 1/2] feat(video-to-music): add async isolate_vocals support Add an async submit()/generate_async() path to video-to-music, mirroring video-to-sfx's submit()+tasks.wait(). isolate_vocals=True requires mode="async" (auto-selected when mode is omitted); an explicit non-async mode with isolate_vocals raises SoniloError locally before any request. - New MusicAudioMedia/MusicTitle/MusicResult types for async music task results, since audio is a list for music (unlike SFX's single object); vocals reuses SfxMedia since its shape matches exactly. - Genericize Tasks/AsyncTasks.get()/wait() with an optional `parser`, defaulting to parse_sfx_result for back-compat, so callers can pass parse_music_result without duplicating the polling loop. - Existing stream()/generate() (plain video-to-music) are unchanged. --- README.md | 33 ++++ src/sonilo/__init__.py | 6 + src/sonilo/_requests.py | 32 ++++ src/sonilo/resources/tasks.py | 120 +++++++++++-- src/sonilo/resources/video_to_music.py | 118 ++++++++++++- src/sonilo/types.py | 107 +++++++++++- tests/test_async_client.py | 123 ++++++++++++++ tests/test_sync_client.py | 224 +++++++++++++++++++++++++ 8 files changed, 743 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index dca9bf1..42891d0 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,39 @@ track = client.video_to_music.generate(video="my_video.mp4", prompt="upbeat") track = client.video_to_music.generate(video_url="https://example.com/clip.mp4") ``` +### Vocal isolation (async) + +Pass `isolate_vocals=True` to also get an isolated vocal stem and a muxed +video, alongside the scored audio. This requires async processing — submit +returns a `task_id` immediately, and `generate_async()` wraps submit + poll: + +```python +result = client.video_to_music.generate_async( + video="my_video.mp4", + prompt="upbeat", + isolate_vocals=True, # implies mode="async"; omit mode to let it auto-select +) +result.save("mix.m4a") # result.audio[0] — the full mix +result.save("vocals.m4a", which="vocals") +result.save("video.mp4", which="mux") # video muxed with the generated audio +print(result.title.title if result.title else None) +``` + +Or control submission and polling yourself: + +```python +from sonilo.resources.tasks import parse_music_result + +task = client.video_to_music.submit(video_url="https://example.com/clip.mp4", isolate_vocals=True) +result = client.tasks.wait( + task.task_id, + parser=parse_music_result, # required: tasks.wait()/get() default to the SFX parser +) +``` + +`isolate_vocals=True` with an explicit non-async `mode` raises `SoniloError` +locally before any request is sent. + ## Streaming ```python diff --git a/src/sonilo/__init__.py b/src/sonilo/__init__.py index c90d9e5..023b8a9 100644 --- a/src/sonilo/__init__.py +++ b/src/sonilo/__init__.py @@ -13,6 +13,9 @@ TaskTimeoutError, ) from sonilo.types import ( + MusicAudioMedia, + MusicResult, + MusicTitle, Segment, SfxMedia, SfxResult, @@ -28,6 +31,9 @@ "AuthenticationError", "BadRequestError", "GenerationError", + "MusicAudioMedia", + "MusicResult", + "MusicTitle", "PaymentRequiredError", "RateLimitError", "Segment", diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index 14a153b..9ce13c8 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -66,6 +66,38 @@ def build_v2m_parts( return data, files, opened +def _resolve_music_mode(mode: Optional[str], isolate_vocals: Optional[bool]) -> str: + """isolate_vocals only works with async processing: auto-select mode + "async" when the caller didn't specify one, but fail fast (mirroring the + video/video_url XOR check above) if they explicitly asked for anything + else. Without isolate_vocals, submit() still needs an async response + (a task_id ack, not a stream), so "async" is also the default there. + """ + if isolate_vocals: + if mode is not None and mode != "async": + raise SoniloError("isolate_vocals=True requires mode='async'") + return "async" + return mode or "async" + + +def build_v2m_async_parts( + video: Any, + video_url: Optional[str], + prompt: Optional[str], + segments: Optional[List[Segment]], + mode: Optional[str], + isolate_vocals: Optional[bool], +) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: + """Like build_v2m_parts, plus the async-only `mode`/`isolate_vocals` + fields used by the video-to-music submit()/generate_async() path.""" + resolved_mode = _resolve_music_mode(mode, isolate_vocals) + data, files, opened = build_v2m_parts(video, video_url, prompt, segments) + data["mode"] = resolved_mode + if isolate_vocals is not None: + data["isolate_vocals"] = "true" if isolate_vocals else "false" + return data, files, opened + + def build_sfx_t2s_data( prompt: str, duration: int, audio_format: Optional[str] ) -> Dict[str, str]: diff --git a/src/sonilo/resources/tasks.py b/src/sonilo/resources/tasks.py index 9007387..5fe9bcc 100644 --- a/src/sonilo/resources/tasks.py +++ b/src/sonilo/resources/tasks.py @@ -2,11 +2,11 @@ import asyncio import time -from typing import TYPE_CHECKING, Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Protocol, TypeVar from urllib.parse import quote from sonilo.errors import SoniloError, TaskFailedError, TaskTimeoutError -from sonilo.types import SfxMedia, SfxResult, SfxTask +from sonilo.types import MusicAudioMedia, MusicResult, MusicTitle, SfxMedia, SfxResult, SfxTask if TYPE_CHECKING: from sonilo._async_client import AsyncSonilo @@ -21,6 +21,19 @@ _monotonic = time.monotonic +class _PollableResult(Protocol): + """Structural shape Tasks.get()/wait() need from any parsed result, + regardless of which endpoint produced it (SFX vs. music).""" + + task_id: str + status: str + error: Optional[Dict[str, Any]] + refunded: Optional[bool] + + +ResultT = TypeVar("ResultT", bound=_PollableResult) + + def _media_from(data: Any) -> Optional[SfxMedia]: if not isinstance(data, dict) or "url" not in data: return None @@ -48,6 +61,61 @@ def parse_sfx_result(body: Dict[str, Any]) -> SfxResult: raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e +def _music_audio_from(data: Any) -> Optional[MusicAudioMedia]: + if not isinstance(data, dict) or "url" not in data: + return None + return MusicAudioMedia( + stream_index=data.get("stream_index", 0), + url=data["url"], + content_type=data.get("content_type"), + file_size=data.get("file_size"), + sample_rate=data.get("sample_rate"), + channels=data.get("channels"), + ) + + +def _music_audio_list_from(data: Any) -> Optional[List[MusicAudioMedia]]: + if not isinstance(data, list): + return None + items = [item for item in (_music_audio_from(entry) for entry in data) if item is not None] + return items + + +def _music_title_from(data: Any) -> Optional[MusicTitle]: + if not isinstance(data, dict): + return None + return MusicTitle( + title=data.get("title"), + summary=data.get("summary"), + display_tags=data.get("display_tags"), + ) + + +def parse_music_result(body: Dict[str, Any]) -> MusicResult: + """Map a GET /v1/tasks/{id} body for a video-to-music task to + MusicResult; unknown fields are ignored. + + `audio` is always a list; `vocals`/`mux` are only populated when the + task was submitted with isolate_vocals=True. + """ + try: + return MusicResult( + task_id=body["task_id"], + status=body["status"], + type=body.get("type"), + audio=_music_audio_list_from(body.get("audio")), + vocals=_media_from(body.get("vocals")), + mux=_music_audio_list_from(body.get("mux")), + title=_music_title_from(body.get("title")), + duration_seconds=body.get("duration_seconds"), + cost=body.get("cost"), + error=body.get("error"), + refunded=body.get("refunded"), + ) + except KeyError as e: + raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e + + def parse_sfx_task(body: Dict[str, Any]) -> SfxTask: """Map a submission ack to SfxTask.""" try: @@ -56,7 +124,7 @@ def parse_sfx_task(body: Dict[str, Any]) -> SfxTask: raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e -def _raise_if_failed(result: SfxResult) -> None: +def _raise_if_failed(result: _PollableResult) -> None: if result.status == "failed": error = result.error if isinstance(result.error, dict) else {} message = error.get("message") or "Generation failed" @@ -87,11 +155,19 @@ class Tasks: def __init__(self, client: "Sonilo") -> None: self._client = client - def get(self, task_id: str) -> SfxResult: - """Fetch current task state. Never raises on a failed status.""" - return parse_sfx_result( - self._client._get_json(f"/v1/tasks/{quote(task_id, safe='')}") - ) + def get( + self, + task_id: str, + *, + parser: Callable[[Dict[str, Any]], ResultT] = parse_sfx_result, # type: ignore[assignment] + ) -> ResultT: + """Fetch current task state. Never raises on a failed status. + + `parser` maps the raw response body to a result type; it defaults to + the SFX parser for back-compat. Pass `parse_music_result` for + video-to-music async tasks. + """ + return parser(self._client._get_json(f"/v1/tasks/{quote(task_id, safe='')}")) def wait( self, @@ -99,12 +175,13 @@ def wait( *, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, - ) -> SfxResult: + parser: Callable[[Dict[str, Any]], ResultT] = parse_sfx_result, # type: ignore[assignment] + ) -> ResultT: """Poll until the task is terminal; raise on failure or deadline.""" _validate_wait_args(poll_interval, timeout) deadline = _monotonic() + timeout while True: - result = self.get(task_id) + result = self.get(task_id, parser=parser) if result.status == "succeeded": return result _raise_if_failed(result) @@ -118,11 +195,19 @@ class AsyncTasks: def __init__(self, client: "AsyncSonilo") -> None: self._client = client - async def get(self, task_id: str) -> SfxResult: - """Fetch current task state. Never raises on a failed status.""" - return parse_sfx_result( - await self._client._get_json(f"/v1/tasks/{quote(task_id, safe='')}") - ) + async def get( + self, + task_id: str, + *, + parser: Callable[[Dict[str, Any]], ResultT] = parse_sfx_result, # type: ignore[assignment] + ) -> ResultT: + """Fetch current task state. Never raises on a failed status. + + `parser` maps the raw response body to a result type; it defaults to + the SFX parser for back-compat. Pass `parse_music_result` for + video-to-music async tasks. + """ + return parser(await self._client._get_json(f"/v1/tasks/{quote(task_id, safe='')}")) async def wait( self, @@ -130,12 +215,13 @@ async def wait( *, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, - ) -> SfxResult: + parser: Callable[[Dict[str, Any]], ResultT] = parse_sfx_result, # type: ignore[assignment] + ) -> ResultT: """Poll until the task is terminal; raise on failure or deadline.""" _validate_wait_args(poll_interval, timeout) deadline = _monotonic() + timeout while True: - result = await self.get(task_id) + result = await self.get(task_id, parser=parser) if result.status == "succeeded": return result _raise_if_failed(result) diff --git a/src/sonilo/resources/video_to_music.py b/src/sonilo/resources/video_to_music.py index 3f159d3..87110dd 100644 --- a/src/sonilo/resources/video_to_music.py +++ b/src/sonilo/resources/video_to_music.py @@ -2,9 +2,15 @@ from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, List, Optional -from sonilo._requests import build_v2m_parts +from sonilo._requests import build_v2m_async_parts, build_v2m_parts from sonilo._streaming import acollect_track, collect_track -from sonilo.types import Segment, StreamEvent, Track +from sonilo.resources.tasks import ( + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIMEOUT, + parse_music_result, + parse_sfx_task, +) +from sonilo.types import MusicResult, Segment, SfxTask, StreamEvent, Track if TYPE_CHECKING: from sonilo._async_client import AsyncSonilo @@ -41,6 +47,60 @@ def generate( self.stream(video=video, video_url=video_url, prompt=prompt, segments=segments) ) + def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + segments: Optional[List[Segment]] = None, + isolate_vocals: Optional[bool] = None, + mode: Optional[str] = None, + ) -> SfxTask: + """Submit an async video-to-music task and return its ack. + + isolate_vocals=True requires mode="async" (auto-selected if `mode` + is omitted); passing an explicit non-async mode alongside + isolate_vocals raises a SoniloError before any request is made. Poll + with `client.tasks.wait(task_id, parser=sonilo.resources.tasks.parse_music_result)` + or use `generate_async()` to submit and wait in one call. + """ + data, files, opened = build_v2m_async_parts( + video, video_url, prompt, segments, mode, isolate_vocals + ) + close_after = files["video"][1] if files is not None and opened else None + return parse_sfx_task( + self._client._post_json(PATH, data=data, files=files, close_after=close_after) + ) + + def generate_async( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + segments: Optional[List[Segment]] = None, + isolate_vocals: Optional[bool] = None, + mode: Optional[str] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> MusicResult: + """submit() + tasks.wait(), returning the parsed MusicResult.""" + task = self.submit( + video=video, + video_url=video_url, + prompt=prompt, + segments=segments, + isolate_vocals=isolate_vocals, + mode=mode, + ) + return self._client.tasks.wait( + task.task_id, + poll_interval=poll_interval, + timeout=timeout, + parser=parse_music_result, + ) + class AsyncVideoToMusic: def __init__(self, client: "AsyncSonilo") -> None: @@ -69,3 +129,57 @@ async def generate( return await acollect_track( self.stream(video=video, video_url=video_url, prompt=prompt, segments=segments) ) + + async def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + segments: Optional[List[Segment]] = None, + isolate_vocals: Optional[bool] = None, + mode: Optional[str] = None, + ) -> SfxTask: + """Submit an async video-to-music task and return its ack. + + isolate_vocals=True requires mode="async" (auto-selected if `mode` + is omitted); passing an explicit non-async mode alongside + isolate_vocals raises a SoniloError before any request is made. + """ + data, files, opened = build_v2m_async_parts( + video, video_url, prompt, segments, mode, isolate_vocals + ) + close_after = files["video"][1] if files is not None and opened else None + return parse_sfx_task( + await self._client._post_json( + PATH, data=data, files=files, close_after=close_after + ) + ) + + async def generate_async( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + segments: Optional[List[Segment]] = None, + isolate_vocals: Optional[bool] = None, + mode: Optional[str] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> MusicResult: + """submit() + tasks.wait(), returning the parsed MusicResult.""" + task = await self.submit( + video=video, + video_url=video_url, + prompt=prompt, + segments=segments, + isolate_vocals=isolate_vocals, + mode=mode, + ) + return await self._client.tasks.wait( + task.task_id, + poll_interval=poll_interval, + timeout=timeout, + parser=parse_music_result, + ) diff --git a/src/sonilo/types.py b/src/sonilo/types.py index 816d11f..3bcecc8 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -3,7 +3,7 @@ import httpx from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union from sonilo.errors import SoniloError @@ -110,3 +110,108 @@ async def asave( p = Path(path) p.write_bytes(response.content) return p + + +@dataclass +class MusicAudioMedia: + """One entry of a music task's `audio` or `mux` array. + + Unlike SfxMedia (used for single-media fields such as `vocals`), array + entries carry a `stream_index` and — for `audio` specifically — + `sample_rate`/`channels`, which `mux` entries don't populate. + """ + + stream_index: int + url: str + content_type: Optional[str] = None + file_size: Optional[int] = None + sample_rate: Optional[int] = None + channels: Optional[int] = None + + +@dataclass +class MusicTitle: + """The `title` object on a succeeded music task.""" + + title: Optional[str] = None + summary: Optional[str] = None + display_tags: Optional[List[str]] = None + + +@dataclass +class MusicResult: + """State of an async video-to-music task (`tasks.get`) or its final + result (`tasks.wait` / `video_to_music.generate_async`). + + `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`. + """ + + task_id: str + status: str + type: Optional[str] = None + audio: Optional[List[MusicAudioMedia]] = None + vocals: Optional[SfxMedia] = None + mux: Optional[List[MusicAudioMedia]] = None + title: Optional[MusicTitle] = None + duration_seconds: Optional[float] = None + cost: Optional[float] = None + error: Optional[Dict[str, Any]] = None + refunded: Optional[bool] = None + + def _media(self, which: str, index: int) -> Union[SfxMedia, MusicAudioMedia]: + if which == "vocals": + if self.vocals is None: + raise SoniloError(f"No vocals on this result (status={self.status})") + return self.vocals + if which not in ("audio", "mux"): + raise SoniloError('which must be "audio", "vocals", or "mux"') + items = self.audio if which == "audio" else self.mux + if not items: + raise SoniloError(f"No {which} on this result (status={self.status})") + try: + return items[index] + except IndexError: + raise SoniloError( + f"No {which} track at index {index} (have {len(items)})" + ) from None + + def save( + self, + path: Union[str, Path], + *, + which: str = "audio", + index: int = 0, + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Path: + """Download a track (`which="audio"|"vocals"|"mux"`, `index` selects + within `audio`/`mux`) to `path` and return it. + + The URL is presigned — no API key is sent. + """ + media = self._media(which, index) + response = httpx.get(media.url, follow_redirects=True, timeout=timeout) + if response.status_code >= 400: + raise SoniloError(f"Download failed: HTTP {response.status_code}") + p = Path(path) + p.write_bytes(response.content) + return p + + async def asave( + self, + path: Union[str, Path], + *, + which: str = "audio", + index: int = 0, + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Path: + """Async variant of save().""" + media = self._media(which, index) + async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as http: + response = await http.get(media.url) + if response.status_code >= 400: + raise SoniloError(f"Download failed: HTTP {response.status_code}") + p = Path(path) + p.write_bytes(response.content) + return p diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 26a1db8..9e683ca 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -5,11 +5,49 @@ import pytest import respx +import sonilo.resources.tasks as tasks_module from sonilo import AsyncSonilo from sonilo.errors import AuthenticationError, GenerationError, SoniloError +from sonilo.resources.tasks import parse_music_result +from sonilo.types import MusicResult, SfxMedia BASE = "https://api.sonilo.com" +MUSIC_SUCCEEDED = { + "task_id": "m1", + "type": "video_to_music", + "status": "succeeded", + "audio": [ + { + "stream_index": 0, + "url": "https://r2.example.com/audio0.m4a", + "content_type": "audio/mp4", + "sample_rate": 44100, + "channels": 2, + "file_size": 123, + } + ], + "vocals": { + "url": "https://r2.example.com/vocals.m4a", + "content_type": "audio/mp4", + "file_size": 456, + }, + "mux": [ + { + "stream_index": 0, + "url": "https://r2.example.com/mux0.mp4", + "content_type": "audio/mp4", + "file_size": 789, + } + ], + "title": { + "title": "Skyline", + "summary": "An upbeat track", + "display_tags": ["upbeat", "cinematic"], + }, + "duration_seconds": 92.5, +} + def b64(data: bytes) -> str: return base64.b64encode(data).decode() @@ -74,6 +112,91 @@ async def test_video_xor_validation(): await client.video_to_music.generate() +# --- video-to-music async (isolate_vocals) -------------------------------- + + +async def test_async_video_to_music_submit_xor_validation(): + async with AsyncSonilo(api_key="sk_test") as client: + with pytest.raises(SoniloError): + await client.video_to_music.submit(video=b"x", video_url="https://e.com/v.mp4") + with pytest.raises(SoniloError): + await client.video_to_music.submit() + + +@respx.mock +async def test_async_video_to_music_submit_serializes_mode_and_isolate_vocals(): + route = respx.post(f"{BASE}/v1/video-to-music").mock( + return_value=httpx.Response(202, json={"task_id": "m1", "status": "processing"}) + ) + async with AsyncSonilo(api_key="sk_test") as client: + task = await client.video_to_music.submit( + video_url="https://e.com/v.mp4", isolate_vocals=True + ) + assert task.task_id == "m1" + body = route.calls.last.request.content.decode() + assert "mode=async" in body + assert "isolate_vocals=true" in body + + +async def test_async_video_to_music_isolate_vocals_requires_async_mode(): + async with AsyncSonilo(api_key="sk_test") as client: + with pytest.raises(SoniloError): + await client.video_to_music.submit( + video_url="https://e.com/v.mp4", isolate_vocals=True, mode="stream" + ) + with pytest.raises(SoniloError): + await client.video_to_music.generate_async( + video_url="https://e.com/v.mp4", isolate_vocals=True, mode="stream" + ) + + +@respx.mock +async def test_async_video_to_music_generate_async_submits_and_waits(monkeypatch): + async def no_sleep(seconds): + return None + + monkeypatch.setattr(tasks_module, "_async_sleep", no_sleep) + respx.post(f"{BASE}/v1/video-to-music").mock( + return_value=httpx.Response(202, json={"task_id": "m1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/m1").mock( + side_effect=[ + httpx.Response(200, json={"task_id": "m1", "status": "processing"}), + httpx.Response(200, json=MUSIC_SUCCEEDED), + ] + ) + async with AsyncSonilo(api_key="sk_test") as client: + result = await client.video_to_music.generate_async( + video_url="https://e.com/v.mp4", isolate_vocals=True + ) + assert isinstance(result, MusicResult) + assert result.status == "succeeded" + assert result.vocals == SfxMedia( + url="https://r2.example.com/vocals.m4a", content_type="audio/mp4", file_size=456 + ) + assert result.mux[0].stream_index == 0 + assert result.title.title == "Skyline" + + +@respx.mock +async def test_async_tasks_get_accepts_custom_parser_for_music(): + respx.get(f"{BASE}/v1/tasks/m1").mock(return_value=httpx.Response(200, json=MUSIC_SUCCEEDED)) + async with AsyncSonilo(api_key="sk_test") as client: + result = await client.tasks.get("m1", parser=parse_music_result) + assert isinstance(result, MusicResult) + assert result.audio[0].sample_rate == 44100 + + +@respx.mock +async def test_async_music_result_asave_downloads_vocals(tmp_path): + respx.get("https://r2.example.com/vocals.m4a").mock( + return_value=httpx.Response(200, content=b"vocalbytes") + ) + result = parse_music_result(MUSIC_SUCCEEDED) + out = await result.asave(tmp_path / "v.m4a", which="vocals") + assert out.read_bytes() == b"vocalbytes" + + @respx.mock async def test_account_endpoints(): respx.get(f"{BASE}/v1/account/services").mock( diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py index a7f0f3d..ce0e5ce 100644 --- a/tests/test_sync_client.py +++ b/tests/test_sync_client.py @@ -5,12 +5,50 @@ import pytest import respx +import sonilo.resources.tasks as tasks_module from sonilo import Sonilo from sonilo._version import __version__ from sonilo.errors import AuthenticationError, GenerationError, SoniloError +from sonilo.resources.tasks import parse_music_result +from sonilo.types import MusicAudioMedia, MusicResult, MusicTitle, SfxMedia BASE = "https://api.sonilo.com" +MUSIC_SUCCEEDED = { + "task_id": "m1", + "type": "video_to_music", + "status": "succeeded", + "audio": [ + { + "stream_index": 0, + "url": "https://r2.example.com/audio0.m4a", + "content_type": "audio/mp4", + "sample_rate": 44100, + "channels": 2, + "file_size": 123, + } + ], + "vocals": { + "url": "https://r2.example.com/vocals.m4a", + "content_type": "audio/mp4", + "file_size": 456, + }, + "mux": [ + { + "stream_index": 0, + "url": "https://r2.example.com/mux0.mp4", + "content_type": "audio/mp4", + "file_size": 789, + } + ], + "title": { + "title": "Skyline", + "summary": "An upbeat track", + "display_tags": ["upbeat", "cinematic"], + }, + "duration_seconds": 92.5, +} + def b64(data: bytes) -> str: return base64.b64encode(data).decode() @@ -146,6 +184,192 @@ def test_video_xor_validation(): list(client.video_to_music.stream()) +# --- video-to-music async (isolate_vocals) -------------------------------- + + +def test_video_to_music_submit_xor_validation(): + with make_client() as client: + with pytest.raises(SoniloError): + client.video_to_music.submit(video=b"x", video_url="https://e.com/v.mp4") + with pytest.raises(SoniloError): + client.video_to_music.submit() + + +@respx.mock +def test_video_to_music_submit_serializes_mode_and_isolate_vocals(): + route = respx.post(f"{BASE}/v1/video-to-music").mock( + return_value=httpx.Response(202, json={"task_id": "m1", "status": "processing"}) + ) + with make_client() as client: + task = client.video_to_music.submit( + video_url="https://e.com/v.mp4", isolate_vocals=True + ) + assert task.task_id == "m1" + assert task.status == "processing" + body = route.calls.last.request.content.decode() + assert "mode=async" in body + assert "isolate_vocals=true" in body + + +@respx.mock +def test_video_to_music_submit_uploads_multipart_with_isolate_vocals(tmp_path): + path = tmp_path / "clip.mp4" + path.write_bytes(b"fakevideo") + route = respx.post(f"{BASE}/v1/video-to-music").mock( + return_value=httpx.Response(202, json={"task_id": "m1", "status": "processing"}) + ) + with make_client() as client: + task = client.video_to_music.submit(video=str(path), isolate_vocals=True) + assert task.task_id == "m1" + body = route.calls.last.request.content + assert b"clip.mp4" in body + assert b'name="mode"' in body and b"async" in body + assert b'name="isolate_vocals"' in body and b"true" in body + + +@respx.mock +def test_video_to_music_submit_defaults_mode_async_without_isolate_vocals(): + route = respx.post(f"{BASE}/v1/video-to-music").mock( + return_value=httpx.Response(202, json={"task_id": "m1", "status": "processing"}) + ) + with make_client() as client: + client.video_to_music.submit(video_url="https://e.com/v.mp4") + body = route.calls.last.request.content.decode() + assert "mode=async" in body + assert "isolate_vocals" not in body + + +def test_video_to_music_isolate_vocals_requires_async_mode(): + with make_client() as client: + with pytest.raises(SoniloError): + client.video_to_music.submit( + video_url="https://e.com/v.mp4", isolate_vocals=True, mode="stream" + ) + with pytest.raises(SoniloError): + client.video_to_music.generate_async( + video_url="https://e.com/v.mp4", isolate_vocals=True, mode="stream" + ) + + +@respx.mock +def test_video_to_music_generate_async_submits_and_waits(monkeypatch): + monkeypatch.setattr(tasks_module, "_sleep", lambda s: None) + respx.post(f"{BASE}/v1/video-to-music").mock( + return_value=httpx.Response(202, json={"task_id": "m1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/m1").mock( + side_effect=[ + httpx.Response(200, json={"task_id": "m1", "status": "processing"}), + httpx.Response(200, json=MUSIC_SUCCEEDED), + ] + ) + with make_client() as client: + result = client.video_to_music.generate_async( + video_url="https://e.com/v.mp4", isolate_vocals=True + ) + assert isinstance(result, MusicResult) + assert result.status == "succeeded" + assert result.type == "video_to_music" + assert result.audio == [ + MusicAudioMedia( + stream_index=0, + url="https://r2.example.com/audio0.m4a", + content_type="audio/mp4", + file_size=123, + sample_rate=44100, + channels=2, + ) + ] + assert result.vocals == SfxMedia( + url="https://r2.example.com/vocals.m4a", content_type="audio/mp4", file_size=456 + ) + assert result.mux == [ + MusicAudioMedia( + stream_index=0, + url="https://r2.example.com/mux0.mp4", + content_type="audio/mp4", + file_size=789, + ) + ] + assert result.title == MusicTitle( + title="Skyline", summary="An upbeat track", display_tags=["upbeat", "cinematic"] + ) + assert result.duration_seconds == 92.5 + + +@respx.mock +def test_video_to_music_async_without_isolate_vocals_still_returns_audio_list(): + """`audio` is always an array for async video-to-music, even when + isolate_vocals wasn't requested; `vocals`/`mux` stay absent.""" + body = { + "task_id": "m2", + "type": "video_to_music", + "status": "succeeded", + "audio": [ + { + "stream_index": 0, + "url": "https://r2.example.com/a.m4a", + "content_type": "audio/mp4", + "file_size": 10, + } + ], + } + respx.post(f"{BASE}/v1/video-to-music").mock( + return_value=httpx.Response(202, json={"task_id": "m2", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/m2").mock(return_value=httpx.Response(200, json=body)) + with make_client() as client: + result = client.video_to_music.generate_async(video_url="https://e.com/v.mp4") + assert isinstance(result.audio, list) + assert result.audio[0].stream_index == 0 + assert result.vocals is None + assert result.mux is None + assert result.title is None + + +@respx.mock +def test_tasks_get_accepts_custom_parser_for_music(): + respx.get(f"{BASE}/v1/tasks/m1").mock(return_value=httpx.Response(200, json=MUSIC_SUCCEEDED)) + with make_client() as client: + result = client.tasks.get("m1", parser=parse_music_result) + assert isinstance(result, MusicResult) + assert result.audio[0].sample_rate == 44100 + assert result.audio[0].channels == 2 + + +@respx.mock +def test_music_result_save_downloads_first_audio_track_by_default(tmp_path): + respx.get("https://r2.example.com/audio0.m4a").mock( + return_value=httpx.Response(200, content=b"musicbytes") + ) + result = parse_music_result(MUSIC_SUCCEEDED) + out = result.save(tmp_path / "out.m4a") + assert out.read_bytes() == b"musicbytes" + + +@respx.mock +def test_music_result_save_which_vocals_and_mux(tmp_path): + respx.get("https://r2.example.com/vocals.m4a").mock( + return_value=httpx.Response(200, content=b"vocalbytes") + ) + respx.get("https://r2.example.com/mux0.mp4").mock( + return_value=httpx.Response(200, content=b"muxbytes") + ) + result = parse_music_result(MUSIC_SUCCEEDED) + assert result.save(tmp_path / "v.m4a", which="vocals").read_bytes() == b"vocalbytes" + assert result.save(tmp_path / "m.mp4", which="mux").read_bytes() == b"muxbytes" + + +def test_music_result_save_missing_media_raises(tmp_path): + result = MusicResult(task_id="m1", status="processing") + with pytest.raises(SoniloError): + result.save(tmp_path / "out.m4a") + with pytest.raises(SoniloError): + result.save(tmp_path / "out.m4a", which="vocals") + with pytest.raises(SoniloError): + result.save(tmp_path / "out.m4a", which="bogus") + + @respx.mock def test_stream_yields_error_event_without_raising(): respx.post(f"{BASE}/v1/text-to-music").mock( From e9897f5a95cc36cad494f6d70492bbdf66834afe Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Thu, 16 Jul 2026 15:00:06 -0700 Subject: [PATCH 2/2] chore(release): bump version to 0.3.0 --- pyproject.toml | 2 +- src/sonilo/_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5cb981d..0bc88bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.2.1" +version = "0.3.0" description = "Official Python client for the Sonilo API" readme = "README.md" license = "MIT" diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index d3ec452..493f741 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.3.0"