From 25b305b7f24990d9be78be36277801dd3382f61e Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Mon, 14 Sep 2026 19:40:11 -0700 Subject: [PATCH] feat: make duration optional on text-to-music and text-to-sfx The API resolves an omitted duration itself, so the client's job is to leave the field out rather than invent a number: * text-to-music infers the length from the prompt, or from `segments` when they are given (the last segment's start plus 30 seconds). * text-to-sfx generates its own default length (8 seconds). Both builders sent `duration` unconditionally, so passing None would have put the literal string "None" on the wire; they now omit the field. text-to-sfx's duration is also a float now, not an int: the API's minimum is 0.5 seconds, and the shortest effects -- a latch, a click, a single footstep -- run well under a second. sonilo 0.17.0 -> 0.18.0, sonilo-cli 0.16.0 -> 0.17.0, with the CLI's narrow core pin widened to match. `--duration` is optional on both CLI commands. --- README.md | 10 +++++++++ pyproject.toml | 2 +- sonilo-cli/pyproject.toml | 4 ++-- sonilo-cli/src/sonilo_cli/__init__.py | 2 +- sonilo-cli/src/sonilo_cli/__main__.py | 11 ++++++++-- src/sonilo/_requests.py | 21 ++++++++++++++----- src/sonilo/_version.py | 2 +- src/sonilo/resources/text_to_music.py | 16 +++++++------- src/sonilo/resources/text_to_sfx.py | 10 +++++---- tests/test_requests.py | 30 +++++++++++++++++++++++++++ 10 files changed, 84 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 2fac3f4..18b738f 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,16 @@ track.save("output.mp3") print(track.title) ``` +`duration` is optional. Leave it out and the API picks the length from your +prompt — or from `segments` when you pass them, in which case the track runs to +the last segment's start plus 30 seconds. `text_to_sfx` takes an optional +`duration` too (from 0.5 seconds, defaulting to 8), so a sound effect can be +requested with nothing but a prompt: + +```python +effect = client.text_to_sfx.generate(prompt="a small metal door latch clicking shut") +``` + ## Video to music ```python diff --git a/pyproject.toml b/pyproject.toml index da6e919..1c6993d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.17.0" +version = "0.18.0" description = "Official Python client for the Sonilo API" readme = "README.md" license = "MIT" diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index 79dd49d..5b475f7 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.16.0" +version = "0.17.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.17.0,<0.18"] +dependencies = ["sonilo>=0.18.0,<0.19"] 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 f8c20f6..27d358b 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.16.0" +__version__ = "0.17.0" __all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index d7cb394..c490a6a 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -856,7 +856,11 @@ def build_parser() -> argparse.ArgumentParser: p_t2m = sub.add_parser("text-to-music", help="Generate music from a text prompt") _add_global(p_t2m) p_t2m.add_argument("--prompt", required=True, help="What the music should sound like.") - p_t2m.add_argument("--duration", type=int, required=True, help="Track length in seconds.") + p_t2m.add_argument( + "--duration", type=int, default=None, + help="Track length in seconds. Omit to let the length follow the prompt " + "(or --segments).", + ) _add_segments(p_t2m, MUSIC_SHAPE) p_t2m.add_argument("--output", default=None, help="Where to save the audio.") p_t2m.add_argument("--format", choices=["m4a", "wav", "mp3"], default="m4a", @@ -893,7 +897,10 @@ def build_parser() -> argparse.ArgumentParser: p_t2s = sub.add_parser("text-to-sfx", help="Generate a sound effect from a text prompt") _add_global(p_t2s) p_t2s.add_argument("--prompt", required=True, help="What the sound effect should be.") - p_t2s.add_argument("--duration", type=int, required=True, help="Effect length in seconds.") + p_t2s.add_argument( + "--duration", type=float, default=None, + help="Effect length in seconds, from 0.5. Omit for the default length.", + ) p_t2s.add_argument("--output", default=None, help="Where to save the audio.") p_t2s.add_argument("--format", choices=_SFX_FORMATS, default="wav", help="Output format. Default: wav") diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index 7d67b79..3008dd4 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -26,9 +26,15 @@ def close(self) -> None: def build_t2m_data( - prompt: str, duration: int, segments: Optional[List[Segment]] + prompt: str, duration: Optional[int], segments: Optional[List[Segment]] ) -> Dict[str, str]: - data = {"prompt": prompt, "duration": str(duration)} + """`duration` is optional: left out, the API resolves the length itself -- + from the caller's segments when there are any, otherwise from the prompt. + Omit the field rather than sending a stand-in, which is the only way to + ask for that.""" + data = {"prompt": prompt} + if duration is not None: + data["duration"] = str(duration) if segments is not None: data["segments"] = json.dumps(segments) return data @@ -36,7 +42,7 @@ def build_t2m_data( def build_t2m_async_data( prompt: str, - duration: int, + duration: Optional[int], segments: Optional[List[Segment]], mode: Optional[str], output_format: Optional[str], @@ -509,9 +515,14 @@ def build_v2s_parts( def build_sfx_t2s_data( - prompt: str, duration: int, audio_format: Optional[str] + prompt: str, duration: Optional[float], audio_format: Optional[str] ) -> Dict[str, str]: - data = {"prompt": prompt, "duration": str(duration)} + """`duration` is optional (the API generates its own default length when + it is absent) and fractional -- the shortest effects run well under a + second, so this is a number, not an integer.""" + data = {"prompt": prompt} + if duration is not None: + data["duration"] = str(duration) if audio_format is not None: data["audio_format"] = audio_format return data diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index fd86b3e..1317d75 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.17.0" +__version__ = "0.18.0" diff --git a/src/sonilo/resources/text_to_music.py b/src/sonilo/resources/text_to_music.py index 20362b7..a7a5940 100644 --- a/src/sonilo/resources/text_to_music.py +++ b/src/sonilo/resources/text_to_music.py @@ -27,7 +27,7 @@ def stream( self, *, prompt: str, - duration: int, + duration: Optional[int] = None, segments: Optional[List[Segment]] = None, ) -> Iterator[StreamEvent]: data = build_t2m_data(prompt, duration, segments) @@ -37,7 +37,7 @@ def generate( self, *, prompt: str, - duration: int, + duration: Optional[int] = None, segments: Optional[List[Segment]] = None, ) -> Track: return collect_track(self.stream(prompt=prompt, duration=duration, segments=segments)) @@ -46,7 +46,7 @@ def submit( self, *, prompt: str, - duration: int, + duration: Optional[int] = None, segments: Optional[List[Segment]] = None, mode: Optional[str] = None, output_format: Optional[str] = None, @@ -79,7 +79,7 @@ def generate_async( self, *, prompt: str, - duration: int, + duration: Optional[int] = None, segments: Optional[List[Segment]] = None, mode: Optional[str] = None, output_format: Optional[str] = None, @@ -113,7 +113,7 @@ def stream( self, *, prompt: str, - duration: int, + duration: Optional[int] = None, segments: Optional[List[Segment]] = None, ) -> AsyncIterator[StreamEvent]: data = build_t2m_data(prompt, duration, segments) @@ -123,7 +123,7 @@ async def generate( self, *, prompt: str, - duration: int, + duration: Optional[int] = None, segments: Optional[List[Segment]] = None, ) -> Track: return await acollect_track( @@ -134,7 +134,7 @@ async def submit( self, *, prompt: str, - duration: int, + duration: Optional[int] = None, segments: Optional[List[Segment]] = None, mode: Optional[str] = None, output_format: Optional[str] = None, @@ -158,7 +158,7 @@ async def generate_async( self, *, prompt: str, - duration: int, + duration: Optional[int] = None, segments: Optional[List[Segment]] = None, mode: Optional[str] = None, output_format: Optional[str] = None, diff --git a/src/sonilo/resources/text_to_sfx.py b/src/sonilo/resources/text_to_sfx.py index 2717a24..1f41f91 100644 --- a/src/sonilo/resources/text_to_sfx.py +++ b/src/sonilo/resources/text_to_sfx.py @@ -18,7 +18,8 @@ def __init__(self, client: "Sonilo") -> None: self._client = client def submit( - self, *, prompt: str, duration: int, audio_format: Optional[str] = None + self, *, prompt: str, duration: Optional[float] = None, + audio_format: Optional[str] = None ) -> SfxTask: data = build_sfx_t2s_data(prompt, duration, audio_format) return parse_sfx_task(self._client._post_json(PATH, data=data)) @@ -27,7 +28,7 @@ def generate( self, *, prompt: str, - duration: int, + duration: Optional[float] = None, audio_format: Optional[str] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, @@ -43,7 +44,8 @@ def __init__(self, client: "AsyncSonilo") -> None: self._client = client async def submit( - self, *, prompt: str, duration: int, audio_format: Optional[str] = None + self, *, prompt: str, duration: Optional[float] = None, + audio_format: Optional[str] = None ) -> SfxTask: data = build_sfx_t2s_data(prompt, duration, audio_format) return parse_sfx_task(await self._client._post_json(PATH, data=data)) @@ -52,7 +54,7 @@ async def generate( self, *, prompt: str, - duration: int, + duration: Optional[float] = None, audio_format: Optional[str] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, diff --git a/tests/test_requests.py b/tests/test_requests.py index 4ce5de7..f0e869e 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -323,3 +323,33 @@ def test_build_dubbing_parts_sends_export_srt_with_subtitles(): ) assert data["export_srt"] == "true" + + +# --- optional duration ------------------------------------------------------ +# The API resolves an omitted duration itself: text-to-music infers a length +# from the prompt (or the caller's segments), text-to-sfx uses its own default. +# The client's job is only to leave the field out rather than invent a number. + +def test_build_t2m_data_omits_an_absent_duration(): + assert "duration" not in build_t2m_data("lofi beat", None, None) + + +def test_build_t2m_async_data_omits_an_absent_duration(): + from sonilo._requests import build_t2m_async_data + + data = build_t2m_async_data("lofi beat", None, None, "async", None, None, None) + assert "duration" not in data + + +def test_build_sfx_t2s_data_omits_an_absent_duration(): + from sonilo._requests import build_sfx_t2s_data + + assert "duration" not in build_sfx_t2s_data("a door latch", None, None) + + +def test_build_sfx_t2s_data_keeps_a_fractional_duration(): + """The shortest effects are well under a second, so duration is a number, + not an integer — 0.5 must not be rounded or stringified as an int.""" + from sonilo._requests import build_sfx_t2s_data + + assert build_sfx_t2s_data("a door latch", 0.5, None)["duration"] == "0.5"