Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "sonilo"
version = "0.3.0"
version = "0.4.0"
description = "Official Python client for the Sonilo API"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion sonilo-video-kit/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ readme = "README.md"
license = "MIT"
requires-python = ">=3.9"
authors = [{ name = "Sonilo AI" }]
dependencies = ["sonilo>=0.3,<0.4"]
dependencies = ["sonilo>=0.3,<0.5"]
keywords = ["sonilo", "video", "music", "ffmpeg", "soundtrack", "ducking", "ai"]

[project.urls]
Expand Down
2 changes: 2 additions & 0 deletions src/sonilo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
SfxTask,
StreamEvent,
Track,
VideoResult,
)

__all__ = [
Expand All @@ -47,5 +48,6 @@
"TaskFailedError",
"TaskTimeoutError",
"Track",
"VideoResult",
"__version__",
]
4 changes: 4 additions & 0 deletions src/sonilo/_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from sonilo.resources.text_to_sfx import AsyncTextToSfx
from sonilo.resources.video_to_music import AsyncVideoToMusic
from sonilo.resources.video_to_sfx import AsyncVideoToSfx
from sonilo.resources.video_to_video_music import AsyncVideoToVideoMusic
from sonilo.resources.video_to_video_sfx import AsyncVideoToVideoSfx
from sonilo.types import StreamEvent


Expand All @@ -35,6 +37,8 @@ def __init__(
self.video_to_music = AsyncVideoToMusic(self)
self.text_to_sfx = AsyncTextToSfx(self)
self.video_to_sfx = AsyncVideoToSfx(self)
self.video_to_video_music = AsyncVideoToVideoMusic(self)
self.video_to_video_sfx = AsyncVideoToVideoSfx(self)
self.account = AsyncAccount(self)
self.tasks = AsyncTasks(self)

Expand Down
4 changes: 4 additions & 0 deletions src/sonilo/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from sonilo.resources.text_to_sfx import TextToSfx
from sonilo.resources.video_to_music import VideoToMusic
from sonilo.resources.video_to_sfx import VideoToSfx
from sonilo.resources.video_to_video_music import VideoToVideoMusic
from sonilo.resources.video_to_video_sfx import VideoToVideoSfx
from sonilo.types import StreamEvent

DEFAULT_BASE_URL = "https://api.sonilo.com"
Expand Down Expand Up @@ -56,6 +58,8 @@ def __init__(
self.video_to_music = VideoToMusic(self)
self.text_to_sfx = TextToSfx(self)
self.video_to_sfx = VideoToSfx(self)
self.video_to_video_music = VideoToVideoMusic(self)
self.video_to_video_sfx = VideoToVideoSfx(self)
self.account = Account(self)
self.tasks = Tasks(self)

Expand Down
94 changes: 80 additions & 14 deletions src/sonilo/_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ def build_t2m_data(
return data


def build_t2m_async_data(
prompt: str,
duration: int,
segments: Optional[List[Segment]],
mode: Optional[str],
output_format: Optional[str],
) -> Dict[str, str]:
data = build_t2m_data(prompt, duration, segments)
resolved = mode or "async"
if resolved != "async":
raise SoniloError("text-to-music submit() requires mode='async'")
data["mode"] = resolved
if output_format is not None:
data["output_format"] = output_format
return data


def normalize_video(video: Any) -> Tuple[str, Any, bool]:
"""Normalize a video input into (filename, httpx-uploadable, opened_here).

Expand Down Expand Up @@ -66,18 +83,31 @@ 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.
def _resolve_music_mode(
mode: Optional[str],
isolate_vocals: Optional[bool],
preserve_speech: Optional[bool] = None,
output_format: Optional[str] = None,
ducking: Optional[bool] = None,
) -> str:
"""isolate_vocals/preserve_speech/ducking/output_format='wav' 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.
"""
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"
needs_async = (
bool(isolate_vocals)
or bool(preserve_speech)
or output_format == "wav"
or ducking is not None
)
if needs_async and mode is not None and mode != "async":
raise SoniloError(
"isolate_vocals/preserve_speech/ducking/output_format='wav' "
"require mode='async'"
)
return "async" if needs_async else (mode or "async")


def build_v2m_async_parts(
Expand All @@ -87,17 +117,53 @@ def build_v2m_async_parts(
segments: Optional[List[Segment]],
mode: Optional[str],
isolate_vocals: Optional[bool],
preserve_speech: Optional[bool] = None,
output_format: Optional[str] = None,
ducking: Optional[bool] = None,
) -> 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)
"""Like build_v2m_parts, plus the async-only fields for the
video-to-music submit()/generate_async() path."""
resolved_mode = _resolve_music_mode(
mode, isolate_vocals, preserve_speech, output_format, ducking
)
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"
if preserve_speech is not None:
data["preserve_speech"] = "true" if preserve_speech else "false"
if output_format is not None:
data["output_format"] = output_format
if ducking is not None:
data["ducking"] = "true" if ducking else "false"
return data, files, opened


def build_v2v_music_parts(
video: Any,
video_url: Optional[str],
prompt: Optional[str],
preserve_speech: Optional[bool],
isolate_vocals: Optional[bool],
) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]:
data, files, opened = build_v2m_parts(video, video_url, prompt, None)
if preserve_speech is not None:
data["preserve_speech"] = "true" if preserve_speech else "false"
if isolate_vocals is not None:
data["isolate_vocals"] = "true" if isolate_vocals else "false"
return data, files, opened


def build_v2v_sfx_parts(
video: Any,
video_url: Optional[str],
prompt: Optional[str],
segments: Optional[List[Segment]],
) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]:
# build_v2m_parts JSON-serializes `segments` — fine for SFX start/end segments too.
return build_v2m_parts(video, video_url, prompt, segments)


def build_sfx_t2s_data(
prompt: str, duration: int, audio_format: Optional[str]
) -> Dict[str, str]:
Expand Down
2 changes: 1 addition & 1 deletion src/sonilo/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.3.0"
__version__ = "0.4.0"
29 changes: 28 additions & 1 deletion src/sonilo/resources/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@
from urllib.parse import quote

from sonilo.errors import SoniloError, TaskFailedError, TaskTimeoutError
from sonilo.types import MusicAudioMedia, MusicResult, MusicTitle, SfxMedia, SfxResult, SfxTask
from sonilo.types import (
MusicAudioMedia,
MusicResult,
MusicTitle,
SfxMedia,
SfxResult,
SfxTask,
VideoResult,
)

if TYPE_CHECKING:
from sonilo._async_client import AsyncSonilo
Expand Down Expand Up @@ -106,6 +114,7 @@ def parse_music_result(body: Dict[str, Any]) -> MusicResult:
audio=_music_audio_list_from(body.get("audio")),
vocals=_media_from(body.get("vocals")),
mux=_music_audio_list_from(body.get("mux")),
ducked=_music_audio_list_from(body.get("ducked")),
title=_music_title_from(body.get("title")),
duration_seconds=body.get("duration_seconds"),
cost=body.get("cost"),
Expand All @@ -116,6 +125,24 @@ def parse_music_result(body: Dict[str, Any]) -> MusicResult:
raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e


def parse_video_result(body: Dict[str, Any]) -> "VideoResult":
"""Map a GET /v1/tasks/{id} body for a video-to-video task to VideoResult;
unknown fields are ignored."""
try:
return VideoResult(
task_id=body["task_id"],
status=body["status"],
type=body.get("type"),
video=_media_from(body.get("video")),
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:
Expand Down
86 changes: 84 additions & 2 deletions src/sonilo/resources/text_to_music.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@

from typing import TYPE_CHECKING, AsyncIterator, Iterator, List, Optional

from sonilo._requests import build_t2m_data
from sonilo._requests import build_t2m_async_data, build_t2m_data
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
Expand Down Expand Up @@ -36,6 +42,44 @@ def generate(
) -> Track:
return collect_track(self.stream(prompt=prompt, duration=duration, segments=segments))

def submit(
self,
*,
prompt: str,
duration: int,
segments: Optional[List[Segment]] = None,
mode: Optional[str] = None,
output_format: Optional[str] = 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". `stream()`/`generate()` remain the
streaming path.
"""
data = build_t2m_async_data(prompt, duration, segments, mode, output_format)
return parse_sfx_task(self._client._post_json(PATH, data=data))

def generate_async(
self,
*,
prompt: str,
duration: int,
segments: Optional[List[Segment]] = None,
mode: Optional[str] = None,
output_format: 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(
prompt=prompt, duration=duration, segments=segments,
mode=mode, output_format=output_format,
)
return self._client.tasks.wait(
task.task_id, poll_interval=poll_interval, timeout=timeout,
parser=parse_music_result,
)


class AsyncTextToMusic:
def __init__(self, client: "AsyncSonilo") -> None:
Expand All @@ -61,3 +105,41 @@ async def generate(
return await acollect_track(
self.stream(prompt=prompt, duration=duration, segments=segments)
)

async def submit(
self,
*,
prompt: str,
duration: int,
segments: Optional[List[Segment]] = None,
mode: Optional[str] = None,
output_format: Optional[str] = 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". `stream()`/`generate()` remain the
streaming path.
"""
data = build_t2m_async_data(prompt, duration, segments, mode, output_format)
return parse_sfx_task(await self._client._post_json(PATH, data=data))

async def generate_async(
self,
*,
prompt: str,
duration: int,
segments: Optional[List[Segment]] = None,
mode: Optional[str] = None,
output_format: 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(
prompt=prompt, duration=duration, segments=segments,
mode=mode, output_format=output_format,
)
return await self._client.tasks.wait(
task.task_id, poll_interval=poll_interval, timeout=timeout,
parser=parse_music_result,
)
Loading
Loading