sonilo 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.9", "3.12"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: pip install -e ".[dev]"
20
+ - run: pytest
@@ -0,0 +1,41 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ push:
6
+ tags: ["v*"]
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ # PyPI Trusted Publishing (OIDC). No PYPI_TOKEN secret needed.
14
+ id-token: write
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.12"
20
+ - run: pip install -e ".[dev]" build
21
+ - run: pytest
22
+ - name: Verify tag matches pyproject version
23
+ if: github.ref_type == 'tag'
24
+ run: |
25
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
26
+ PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
27
+ if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
28
+ echo "Tag version ($TAG_VERSION) does not match pyproject version ($PKG_VERSION)"
29
+ exit 1
30
+ fi
31
+ - run: python -m build
32
+ - name: Publish to PyPI
33
+ # Authenticates via OIDC Trusted Publishing — configured as a pending
34
+ # publisher for project `sonilo` (repo sonilo-python, workflow publish.yml).
35
+ # Tag refs only: a workflow_dispatch on a branch runs tests/build but
36
+ # never publishes. To retry a failed publish, dispatch on the tag:
37
+ # gh workflow run publish.yml --ref vX.Y.Z
38
+ if: github.ref_type == 'tag'
39
+ uses: pypa/gh-action-pypi-publish@release/v1
40
+ with:
41
+ skip-existing: true
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .DS_Store
sonilo-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sonilo AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
sonilo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: sonilo
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the Sonilo API
5
+ Project-URL: Repository, https://github.com/sonilo-ai/sonilo-python
6
+ Author: Sonilo AI
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: ai,generation,music,sonilo,text-to-music,video-to-music
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: httpx>=0.27
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
14
+ Requires-Dist: pytest>=8; extra == 'dev'
15
+ Requires-Dist: respx>=0.21; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # sonilo
19
+
20
+ Official Python client for the [Sonilo](https://sonilo.com) API.
21
+ Python ≥ 3.9. Sync and async clients included.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install sonilo
27
+ ```
28
+
29
+ ## Quickstart
30
+
31
+ ```python
32
+ from sonilo import Sonilo
33
+
34
+ client = Sonilo() # reads SONILO_API_KEY
35
+
36
+ track = client.text_to_music.generate(
37
+ prompt="cinematic orchestral score",
38
+ duration=60,
39
+ )
40
+ track.save("output.mp3")
41
+ print(track.title)
42
+ ```
43
+
44
+ ## Video to music
45
+
46
+ ```python
47
+ track = client.video_to_music.generate(video="my_video.mp4", prompt="upbeat")
48
+ # or bytes / an open binary file, or a hosted URL:
49
+ track = client.video_to_music.generate(video_url="https://example.com/clip.mp4")
50
+ ```
51
+
52
+ ## Streaming
53
+
54
+ ```python
55
+ for event in client.text_to_music.stream(prompt="lofi", duration=30):
56
+ if event["type"] == "audio_chunk":
57
+ handle(event["data"]) # bytes, as they arrive
58
+ ```
59
+
60
+ ## Async
61
+
62
+ ```python
63
+ from sonilo import AsyncSonilo
64
+
65
+ async with AsyncSonilo() as client:
66
+ track = await client.text_to_music.generate(prompt="lofi", duration=30)
67
+ async for event in client.text_to_music.stream(prompt="lofi", duration=30):
68
+ ...
69
+ ```
70
+
71
+ ## Segments
72
+
73
+ Shape the composition with start-only contiguous segments (each ends where
74
+ the next begins):
75
+
76
+ ```python
77
+ client.text_to_music.generate(
78
+ prompt="epic trailer",
79
+ duration=60,
80
+ segments=[
81
+ {"start": 0, "prompt": "soft intro", "label": "intro"},
82
+ {"start": 20, "prompt": "building tension", "label": "verse"},
83
+ {"start": 40, "prompt": "full orchestra", "label": "chorus"},
84
+ ],
85
+ )
86
+ ```
87
+
88
+ ## Account
89
+
90
+ ```python
91
+ client.account.services()
92
+ client.account.usage(days=7)
93
+ ```
94
+
95
+ ## Errors
96
+
97
+ All errors extend `SoniloError`: `AuthenticationError` (401),
98
+ `PaymentRequiredError` (402), `RateLimitError` (429, `.retry_after`),
99
+ `BadRequestError` (400/413/422, `.detail`), `APIError` (anything else),
100
+ and `GenerationError` for failures mid-stream.
101
+
102
+ ## License
103
+
104
+ MIT
sonilo-0.1.0/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # sonilo
2
+
3
+ Official Python client for the [Sonilo](https://sonilo.com) API.
4
+ Python ≥ 3.9. Sync and async clients included.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ pip install sonilo
10
+ ```
11
+
12
+ ## Quickstart
13
+
14
+ ```python
15
+ from sonilo import Sonilo
16
+
17
+ client = Sonilo() # reads SONILO_API_KEY
18
+
19
+ track = client.text_to_music.generate(
20
+ prompt="cinematic orchestral score",
21
+ duration=60,
22
+ )
23
+ track.save("output.mp3")
24
+ print(track.title)
25
+ ```
26
+
27
+ ## Video to music
28
+
29
+ ```python
30
+ track = client.video_to_music.generate(video="my_video.mp4", prompt="upbeat")
31
+ # or bytes / an open binary file, or a hosted URL:
32
+ track = client.video_to_music.generate(video_url="https://example.com/clip.mp4")
33
+ ```
34
+
35
+ ## Streaming
36
+
37
+ ```python
38
+ for event in client.text_to_music.stream(prompt="lofi", duration=30):
39
+ if event["type"] == "audio_chunk":
40
+ handle(event["data"]) # bytes, as they arrive
41
+ ```
42
+
43
+ ## Async
44
+
45
+ ```python
46
+ from sonilo import AsyncSonilo
47
+
48
+ async with AsyncSonilo() as client:
49
+ track = await client.text_to_music.generate(prompt="lofi", duration=30)
50
+ async for event in client.text_to_music.stream(prompt="lofi", duration=30):
51
+ ...
52
+ ```
53
+
54
+ ## Segments
55
+
56
+ Shape the composition with start-only contiguous segments (each ends where
57
+ the next begins):
58
+
59
+ ```python
60
+ client.text_to_music.generate(
61
+ prompt="epic trailer",
62
+ duration=60,
63
+ segments=[
64
+ {"start": 0, "prompt": "soft intro", "label": "intro"},
65
+ {"start": 20, "prompt": "building tension", "label": "verse"},
66
+ {"start": 40, "prompt": "full orchestra", "label": "chorus"},
67
+ ],
68
+ )
69
+ ```
70
+
71
+ ## Account
72
+
73
+ ```python
74
+ client.account.services()
75
+ client.account.usage(days=7)
76
+ ```
77
+
78
+ ## Errors
79
+
80
+ All errors extend `SoniloError`: `AuthenticationError` (401),
81
+ `PaymentRequiredError` (402), `RateLimitError` (429, `.retry_after`),
82
+ `BadRequestError` (400/413/422, `.detail`), `APIError` (anything else),
83
+ and `GenerationError` for failures mid-stream.
84
+
85
+ ## License
86
+
87
+ MIT
@@ -0,0 +1,14 @@
1
+ """Usage: SONILO_API_KEY=sk_... python examples/generate.py "lofi beat" 30"""
2
+ import sys
3
+
4
+ from sonilo import Sonilo
5
+
6
+ prompt = sys.argv[1] if len(sys.argv) > 1 else "cinematic orchestral score"
7
+ duration = int(sys.argv[2]) if len(sys.argv) > 2 else 60
8
+
9
+ with Sonilo() as client:
10
+ track = client.text_to_music.generate(prompt=prompt, duration=duration)
11
+
12
+ out = track.save("output.mp3")
13
+ title = f' — "{track.title}"' if track.title else ""
14
+ print(f"Saved {out} ({len(track.audio)} bytes){title}")
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sonilo"
7
+ version = "0.1.0"
8
+ description = "Official Python client for the Sonilo API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Sonilo AI" }]
13
+ dependencies = ["httpx>=0.27"]
14
+ keywords = ["sonilo", "music", "generation", "text-to-music", "video-to-music", "ai"]
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/sonilo-ai/sonilo-python"
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "respx>=0.21"]
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/sonilo"]
24
+
25
+ [tool.pytest.ini_options]
26
+ asyncio_mode = "auto"
27
+ testpaths = ["tests"]
@@ -0,0 +1,29 @@
1
+ from sonilo._async_client import AsyncSonilo
2
+ from sonilo._client import Sonilo
3
+ from sonilo._version import __version__
4
+ from sonilo.errors import (
5
+ APIError,
6
+ AuthenticationError,
7
+ BadRequestError,
8
+ GenerationError,
9
+ PaymentRequiredError,
10
+ RateLimitError,
11
+ SoniloError,
12
+ )
13
+ from sonilo.types import Segment, StreamEvent, Track
14
+
15
+ __all__ = [
16
+ "APIError",
17
+ "AsyncSonilo",
18
+ "AuthenticationError",
19
+ "BadRequestError",
20
+ "GenerationError",
21
+ "PaymentRequiredError",
22
+ "RateLimitError",
23
+ "Segment",
24
+ "Sonilo",
25
+ "SoniloError",
26
+ "StreamEvent",
27
+ "Track",
28
+ "__version__",
29
+ ]
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, AsyncIterator, Dict, Optional
4
+
5
+ import httpx
6
+
7
+ from sonilo._client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, _default_headers, _resolve_api_key
8
+ from sonilo._streaming import aiter_events
9
+ from sonilo.errors import error_from_response
10
+ from sonilo.resources.account import AsyncAccount
11
+ from sonilo.resources.text_to_music import AsyncTextToMusic
12
+ from sonilo.resources.video_to_music import AsyncVideoToMusic
13
+ from sonilo.types import StreamEvent
14
+
15
+
16
+ class AsyncSonilo:
17
+ """Asynchronous Sonilo API client."""
18
+
19
+ def __init__(
20
+ self,
21
+ api_key: Optional[str] = None,
22
+ base_url: Optional[str] = None,
23
+ timeout: float = DEFAULT_TIMEOUT,
24
+ ) -> None:
25
+ key = _resolve_api_key(api_key)
26
+ self._http = httpx.AsyncClient(
27
+ base_url=(base_url or DEFAULT_BASE_URL).rstrip("/"),
28
+ headers=_default_headers(key, "sdk-python"),
29
+ timeout=timeout,
30
+ )
31
+ self.text_to_music = AsyncTextToMusic(self)
32
+ self.video_to_music = AsyncVideoToMusic(self)
33
+ self.account = AsyncAccount(self)
34
+
35
+ async def close(self) -> None:
36
+ await self._http.aclose()
37
+
38
+ async def __aenter__(self) -> "AsyncSonilo":
39
+ return self
40
+
41
+ async def __aexit__(self, *exc_info: Any) -> None:
42
+ await self.close()
43
+
44
+ # -- internal transport -------------------------------------------------
45
+
46
+ async def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
47
+ response = await self._http.get(path, params=params)
48
+ if response.status_code >= 400:
49
+ raise error_from_response(response)
50
+ return response.json()
51
+
52
+ async def _stream_events(
53
+ self,
54
+ path: str,
55
+ *,
56
+ data: Dict[str, str],
57
+ files: Optional[Dict[str, tuple]] = None,
58
+ close_after: Any = None,
59
+ ) -> AsyncIterator[StreamEvent]:
60
+ try:
61
+ async with self._http.stream("POST", path, data=data, files=files) as response:
62
+ if response.status_code >= 400:
63
+ await response.aread()
64
+ raise error_from_response(response)
65
+ async for event in aiter_events(response.aiter_text()):
66
+ yield event
67
+ finally:
68
+ if close_after is not None:
69
+ close_after.close()
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any, Dict, Iterator, Optional
5
+
6
+ import httpx
7
+
8
+ from sonilo._streaming import iter_events
9
+ from sonilo._version import __version__
10
+ from sonilo.errors import SoniloError, error_from_response
11
+ from sonilo.resources.account import Account
12
+ from sonilo.resources.text_to_music import TextToMusic
13
+ from sonilo.resources.video_to_music import VideoToMusic
14
+ from sonilo.types import StreamEvent
15
+
16
+ DEFAULT_BASE_URL = "https://api.sonilo.com"
17
+ DEFAULT_TIMEOUT = 600.0
18
+
19
+
20
+ def _resolve_api_key(api_key: Optional[str]) -> str:
21
+ key = api_key or os.environ.get("SONILO_API_KEY")
22
+ if not key:
23
+ raise SoniloError(
24
+ "Missing API key: pass api_key= or set the SONILO_API_KEY environment variable"
25
+ )
26
+ return key
27
+
28
+
29
+ def _default_headers(api_key: str, client_name: str) -> Dict[str, str]:
30
+ return {
31
+ "Authorization": f"Bearer {api_key}",
32
+ "X-Sonilo-Client": client_name,
33
+ "X-Sonilo-Client-Version": __version__,
34
+ }
35
+
36
+
37
+ class Sonilo:
38
+ """Synchronous Sonilo API client."""
39
+
40
+ def __init__(
41
+ self,
42
+ api_key: Optional[str] = None,
43
+ base_url: Optional[str] = None,
44
+ timeout: float = DEFAULT_TIMEOUT,
45
+ ) -> None:
46
+ key = _resolve_api_key(api_key)
47
+ self._http = httpx.Client(
48
+ base_url=(base_url or DEFAULT_BASE_URL).rstrip("/"),
49
+ headers=_default_headers(key, "sdk-python"),
50
+ timeout=timeout,
51
+ )
52
+ self.text_to_music = TextToMusic(self)
53
+ self.video_to_music = VideoToMusic(self)
54
+ self.account = Account(self)
55
+
56
+ def close(self) -> None:
57
+ self._http.close()
58
+
59
+ def __enter__(self) -> "Sonilo":
60
+ return self
61
+
62
+ def __exit__(self, *exc_info: Any) -> None:
63
+ self.close()
64
+
65
+ # -- internal transport -------------------------------------------------
66
+
67
+ def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
68
+ response = self._http.get(path, params=params)
69
+ if response.status_code >= 400:
70
+ raise error_from_response(response)
71
+ return response.json()
72
+
73
+ def _stream_events(
74
+ self,
75
+ path: str,
76
+ *,
77
+ data: Dict[str, str],
78
+ files: Optional[Dict[str, tuple]] = None,
79
+ close_after: Any = None,
80
+ ) -> Iterator[StreamEvent]:
81
+ try:
82
+ with self._http.stream("POST", path, data=data, files=files) as response:
83
+ if response.status_code >= 400:
84
+ response.read()
85
+ raise error_from_response(response)
86
+ for event in iter_events(response.iter_text()):
87
+ yield event
88
+ finally:
89
+ if close_after is not None:
90
+ close_after.close()
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Optional, Tuple
6
+
7
+ from sonilo.errors import SoniloError
8
+ from sonilo.types import Segment
9
+
10
+ DEFAULT_FILENAME = "video.mp4"
11
+
12
+
13
+ def build_t2m_data(
14
+ prompt: str, duration: int, segments: Optional[List[Segment]]
15
+ ) -> Dict[str, str]:
16
+ data = {"prompt": prompt, "duration": str(duration)}
17
+ if segments is not None:
18
+ data["segments"] = json.dumps(segments)
19
+ return data
20
+
21
+
22
+ def normalize_video(video: Any) -> Tuple[str, Any, bool]:
23
+ """Normalize a video input into (filename, httpx-uploadable, opened_here).
24
+
25
+ Accepts a filesystem path (str/Path — opened for streaming upload; the
26
+ caller must close it, signalled by opened_here=True), raw bytes, or a
27
+ binary file-like object.
28
+ """
29
+ if isinstance(video, (str, Path)):
30
+ path = Path(video)
31
+ return path.name or DEFAULT_FILENAME, path.open("rb"), True
32
+ if isinstance(video, bytes):
33
+ return DEFAULT_FILENAME, video, False
34
+ if hasattr(video, "read"):
35
+ raw_name = getattr(video, "name", None)
36
+ filename = Path(raw_name).name if isinstance(raw_name, str) and raw_name else DEFAULT_FILENAME
37
+ return filename, video, False
38
+ raise SoniloError("Unsupported video input: pass a path, bytes, or a binary file object")
39
+
40
+
41
+ def build_v2m_parts(
42
+ video: Any,
43
+ video_url: Optional[str],
44
+ prompt: Optional[str],
45
+ segments: Optional[List[Segment]],
46
+ ) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]:
47
+ if (video is None) == (video_url is None):
48
+ raise SoniloError("Provide exactly one of video or video_url")
49
+
50
+ # Assemble data dict completely before opening any files
51
+ data: Dict[str, str] = {}
52
+ if video_url is not None:
53
+ data["video_url"] = video_url # type: ignore[assignment]
54
+ if prompt is not None:
55
+ data["prompt"] = prompt
56
+ if segments is not None:
57
+ data["segments"] = json.dumps(segments)
58
+
59
+ # Now open files (only after data is fully assembled)
60
+ files: Optional[Dict[str, tuple]] = None
61
+ opened = False
62
+ if video is not None:
63
+ filename, fileobj, opened = normalize_video(video)
64
+ files = {"video": (filename, fileobj, "video/mp4")}
65
+
66
+ return data, files, opened
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from typing import AsyncIterable, AsyncIterator, Dict, Iterable, Iterator, List, Optional
6
+
7
+ from sonilo.errors import GenerationError
8
+ from sonilo.types import StreamEvent, Track
9
+
10
+
11
+ def _parse_line(line: str) -> StreamEvent:
12
+ event = json.loads(line)
13
+ if event.get("type") == "audio_chunk" and isinstance(event.get("data"), str):
14
+ event = {**event, "data": base64.b64decode(event["data"])}
15
+ return event
16
+
17
+
18
+ class _LineBuffer:
19
+ """Accumulates text chunks and yields complete NDJSON lines."""
20
+
21
+ def __init__(self) -> None:
22
+ self._buf = ""
23
+
24
+ def feed(self, text: str) -> Iterator[StreamEvent]:
25
+ self._buf += text
26
+ while True:
27
+ idx = self._buf.find("\n")
28
+ if idx == -1:
29
+ return
30
+ line, self._buf = self._buf[:idx].strip(), self._buf[idx + 1 :]
31
+ if line:
32
+ yield _parse_line(line)
33
+
34
+ def flush(self) -> Iterator[StreamEvent]:
35
+ line, self._buf = self._buf.strip(), ""
36
+ if line:
37
+ yield _parse_line(line)
38
+
39
+
40
+ def iter_events(text_chunks: Iterable[str]) -> Iterator[StreamEvent]:
41
+ buf = _LineBuffer()
42
+ for chunk in text_chunks:
43
+ yield from buf.feed(chunk)
44
+ yield from buf.flush()
45
+
46
+
47
+ async def aiter_events(text_chunks: AsyncIterable[str]) -> AsyncIterator[StreamEvent]:
48
+ buf = _LineBuffer()
49
+ async for chunk in text_chunks:
50
+ for event in buf.feed(chunk):
51
+ yield event
52
+ for event in buf.flush():
53
+ yield event
54
+
55
+
56
+ class _TrackBuilder:
57
+ def __init__(self) -> None:
58
+ self._chunks: List[bytes] = []
59
+ self._title: Optional[str] = None
60
+ self._cost: Optional[Dict[str, str]] = None
61
+ self._complete = False
62
+
63
+ def add(self, event: StreamEvent) -> None:
64
+ event_type = event.get("type")
65
+ if event_type == "audio_chunk" and isinstance(event.get("data"), bytes):
66
+ self._chunks.append(event["data"])
67
+ elif event_type == "title" and isinstance(event.get("title"), str):
68
+ self._title = event["title"]
69
+ elif event_type == "cost":
70
+ self._cost = {k: v for k, v in event.items() if k != "type"}
71
+ elif event_type == "error":
72
+ message = event.get("message") or "generation failed"
73
+ code = event.get("code")
74
+ raise GenerationError(str(message), code=code if isinstance(code, str) else None)
75
+ elif event_type == "complete":
76
+ self._complete = True
77
+ # unknown event types: ignored
78
+
79
+ def build(self) -> Track:
80
+ if not self._complete:
81
+ raise GenerationError("stream ended before a 'complete' event (truncated response)")
82
+ return Track(audio=b"".join(self._chunks), title=self._title, cost=self._cost)
83
+
84
+
85
+ def collect_track(events: Iterable[StreamEvent]) -> Track:
86
+ builder = _TrackBuilder()
87
+ try:
88
+ for event in events:
89
+ builder.add(event)
90
+ finally:
91
+ close = getattr(events, "close", None)
92
+ if close is not None:
93
+ close()
94
+ return builder.build()
95
+
96
+
97
+ async def acollect_track(events: AsyncIterable[StreamEvent]) -> Track:
98
+ builder = _TrackBuilder()
99
+ try:
100
+ async for event in events:
101
+ builder.add(event)
102
+ finally:
103
+ aclose = getattr(events, "aclose", None)
104
+ if aclose is not None:
105
+ await aclose()
106
+ return builder.build()
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"