diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5c5d05..2081a30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - run: sudo apt-get update && sudo apt-get install -y ffmpeg - - run: pip install -e ".[dev]" -e "./sonilo-video-kit[dev]" + - run: pip install -e ".[dev]" -e "./sonilo-video-kit[dev]" -e "./sonilo-cli[dev]" - run: pytest - run: pytest sonilo-video-kit + - run: pytest sonilo-cli diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml new file mode 100644 index 0000000..91cdef1 --- /dev/null +++ b/.github/workflows/publish-cli.yml @@ -0,0 +1,40 @@ +name: Publish sonilo-cli to PyPI + +on: + workflow_dispatch: + push: + tags: ["sonilo-cli-v*"] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + # PyPI Trusted Publishing (OIDC). No PYPI_TOKEN secret needed. + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e ".[dev]" -e "./sonilo-cli[dev]" build + - run: pytest sonilo-cli + - name: Verify tag matches pyproject version + if: github.ref_type == 'tag' + run: | + TAG_VERSION="${GITHUB_REF_NAME#sonilo-cli-v}" + PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('sonilo-cli/pyproject.toml','rb'))['project']['version'])") + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "Tag version ($TAG_VERSION) does not match pyproject version ($PKG_VERSION)" + exit 1 + fi + - run: python -m build sonilo-cli + - name: Publish to PyPI + # Authenticates via OIDC Trusted Publishing — a pending publisher for + # project `sonilo-cli` (repo sonilo-python, workflow publish-cli.yml) + # must be configured on PyPI before the first tag push. Tag refs only. + if: github.ref_type == 'tag' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: sonilo-cli/dist + skip-existing: true diff --git a/.gitignore b/.gitignore index 8389dca..4e02642 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ build/ .pytest_cache/ .DS_Store /docs/ +.superpowers/ diff --git a/README.md b/README.md index 1d2d253..766cb2c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,16 @@ Python ≥ 3.9. Sync and async clients included. pip install sonilo ``` +## Command-line interface + +Prefer a terminal over Python? [`sonilo-cli`](./sonilo-cli/) wraps this client +in a `sonilo` command for music and SFX generation: + +```bash +pip install sonilo-cli +sonilo text-to-music --prompt "warm lo-fi piano, rain" --duration 30 +``` + ## Authentication Create an API key in your [Sonilo dashboard](https://platform.sonilo.com/dashboard/api-keys), diff --git a/context7.json b/context7.json new file mode 100644 index 0000000..3d0558d --- /dev/null +++ b/context7.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://context7.com/schema/context7.json", + "projectTitle": "Sonilo", + "description": "Official Python client and CLI for the Sonilo API — generate music, sound effects, and combined soundtracks from text or video.", + "excludeFolders": ["**/dist/**", "**/.venv/**", "**/__pycache__/**", ".superpowers", ".pytest_cache"], + "rules": [ + "Read the API key from the SONILO_API_KEY environment variable by default; only pass api_key= explicitly when the caller has a reason to override it.", + "For text-to-music and video-to-music: use client.text_to_music.generate() / client.video_to_music.generate() (streaming) for short synchronous tracks. Switch to generate_async() (or submit() + client.tasks.wait(parser=parse_music_result)) when the caller needs output_format=\"wav\" — and, for video-to-music only, isolate_vocals, preserve_speech, or ducking. The streaming path supports none of these.", + "For text-to-sfx and video-to-sfx, generation is always async: submit() returns a task, and generate() (or tasks.wait()) polls it to completion. There is no streaming variant for these two endpoints.", + "client.video_to_sound and client.video_to_video_sound score one clip with a music bed AND sound effects in a single call — prefer them over chaining video-to-music with video-to-sfx, which costs two charges. Both are async-only and take identical options: music_prompt and sfx_prompt (NOT a single prompt), preserve_speech, and ducking. video_to_sound returns mixed audio (output_type \"audio\"); video_to_video_sound returns the source video with that audio muxed in (output_type \"video\").", + "ducking is default-ON server-side and the request builder only sends a boolean when it is not None. Leave ducking unset (None) to keep the default; pass ducking=False ONLY to explicitly opt out. Never forward a default of False — that silently disables the server default. The same tri-state applies to preserve_speech.", + "A SoundResult exposes the combined render as the bare presigned output_url — save it with result.save(path). The separate layers come back as the music, music_processed, and sfx stems, saved with result.save_stem(path, which=\"music\"). music_processed is present only when preserve_speech or ducking altered the music bed, and save_stem raises SoniloError for an absent stem.", + "Result media (.url) is a short-lived presigned URL, not the API's own domain — download it with the result's .save() helper; do not send the Authorization header to it.", + "Wrap calls in try/except for AuthenticationError (401), PaymentRequiredError (402), RateLimitError (429), and TaskFailedError (task reached status \"failed\") rather than a single generic except — callers usually want to handle these differently.", + "video / video_url parameters accept exactly one of the two, never both and never neither — check for that before constructing a request.", + "Self-serve accounts start with free runs per endpoint (2 each for text-to-music, text-to-sfx and audio-ducking; 1 each for the video endpoints), after which calls bill at the normal rate — so a first call succeeding is not evidence that the account has billing set up." + ] +} diff --git a/sonilo-cli/LICENSE b/sonilo-cli/LICENSE new file mode 100644 index 0000000..e62a1a5 --- /dev/null +++ b/sonilo-cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sonilo AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sonilo-cli/README.md b/sonilo-cli/README.md new file mode 100644 index 0000000..5f13e85 --- /dev/null +++ b/sonilo-cli/README.md @@ -0,0 +1,71 @@ +# sonilo-cli + +Command-line interface for the [Sonilo API](https://github.com/sonilo-ai/sonilo-python) — generate music and sound effects from text or video. + +## Install + + pip install sonilo-cli + +## Auth + +Set your API key once: + + export SONILO_API_KEY=sk-... + +or pass `--api-key sk-...` on any command. + +## Commands + + sonilo account # plan limits and available services + sonilo usage --days 7 # usage summary + sonilo text-to-music --prompt "warm lo-fi piano, rain" --duration 30 + sonilo video-to-music --video clip.mp4 --prompt "tense synths" --format wav + sonilo text-to-sfx --prompt "glass shattering on concrete" --duration 3 + sonilo video-to-sfx --video clip.mp4 --output whoosh.wav + sonilo video-to-sound --video clip.mp4 \ + --music-prompt "uplifting orchestral score" --sfx-prompt "match the on-screen action" + sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths" + sonilo tasks get + sonilo tasks wait --poll-interval 2 --timeout 600 + +### Notes + +- `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`, + `--isolate-vocals`, and `--preserve-speech` 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`. + +### Combined soundtracks + +`video-to-sound` and `video-to-video-sound` score a clip with a music bed *and* sound effects in one +call (one charge, instead of chaining two requests). Both are async-only and take the same options — +they differ only in what comes back: `video-to-sound` writes the mixed **audio** (default +`output.wav`), `video-to-video-sound` writes the **source video with that audio muxed in** (default +`output.mp4`). + + sonilo video-to-sound --video clip.mp4 \ + --music-prompt "uplifting orchestral score" \ + --sfx-prompt "match the on-screen action" \ + --output soundtrack.wav --stem music --stem sfx + +- `--music-prompt` / `--sfx-prompt` steer the two layers separately; both are optional. +- `--preserve-speech` keeps speech from the source video in the mix. +- **Ducking is on by default** (music dips under speech). Pass `--no-ducking` to opt out — omitting + the flag leaves the server default untouched. +- `--stem` is repeatable (`music`, `music_processed`, `sfx`) and saves the individual layers next to + the combined output, so you can re-balance the mix yourself. With `--output soundtrack.wav`, the + music stem lands at `soundtrack.music.m4a`. `music_processed` exists only when `--preserve-speech` + or ducking altered the music bed. + +## Free trial + +Accounts created through self-serve signup start with free runs on every endpoint — no card +required: + +| Free runs | Endpoints | +| --- | --- | +| 2 each | text-to-music, text-to-sfx, audio-ducking | +| 1 each | video-to-music, video-to-sfx, video-to-video-music, video-to-video-sfx, video-to-sound, video-to-video-sound | + +Once an endpoint's free runs are used up, calls to it bill at the normal rate. `sonilo account` +shows the services available to your key. diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml new file mode 100644 index 0000000..2be4520 --- /dev/null +++ b/sonilo-cli/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "sonilo-cli" +version = "0.1.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.5,<0.6"] +keywords = ["sonilo", "cli", "music", "sfx", "text-to-music", "video-to-music", "ai"] + +[project.urls] +Repository = "https://github.com/sonilo-ai/sonilo-python" + +[project.scripts] +sonilo = "sonilo_cli.__main__:main" + +[project.optional-dependencies] +dev = ["pytest>=8", "respx>=0.21"] + +[tool.hatch.build.targets.wheel] +packages = ["src/sonilo_cli"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/sonilo-cli/src/sonilo_cli/__init__.py b/sonilo-cli/src/sonilo_cli/__init__.py new file mode 100644 index 0000000..de48b44 --- /dev/null +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -0,0 +1,3 @@ +__version__ = "0.1.0" + +__all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py new file mode 100644 index 0000000..29fce0e --- /dev/null +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from pathlib import Path +from typing import Any, List, NoReturn, Optional +from urllib.parse import urlparse + +from sonilo import Sonilo +from sonilo.errors import SoniloError + +from sonilo_cli import __version__ + + +class _Parser(argparse.ArgumentParser): + """ArgumentParser that fails with `sonilo: ` and exit code 1.""" + + def error(self, message: str) -> NoReturn: # noqa: D401 + sys.stderr.write(f"sonilo: {message}\n") + raise SystemExit(1) + + +def _fail(message: str) -> NoReturn: + sys.stderr.write(f"sonilo: {message}\n") + raise SystemExit(1) + + +def _print_json(value: Any) -> None: + print(json.dumps(value, indent=2)) + + +def _wrote(path: Any, size: int) -> None: + print(f"Wrote {path} ({size:,} bytes)") + + +def build_client(api_key: Optional[str]) -> Sonilo: + key = api_key or os.environ.get("SONILO_API_KEY") + if not key: + _fail( + "no API key — pass --api-key or set the " + "SONILO_API_KEY environment variable" + ) + return Sonilo(api_key=key) + + +def cmd_account(client: Sonilo, args: argparse.Namespace) -> None: + _print_json(client.account.services()) + + +def cmd_usage(client: Sonilo, args: argparse.Namespace) -> None: + _print_json(client.account.usage(days=args.days)) + + +def _music_output(args: argparse.Namespace, fmt: str) -> str: + return args.output if args.output is not None else f"output.{fmt}" + + +def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None: + fmt = args.format + use_async = args.use_async or fmt == "wav" + out = _music_output(args, fmt) + if use_async: + result = client.text_to_music.generate_async( + prompt=args.prompt, + duration=args.duration, + output_format="wav" if fmt == "wav" else None, + ) + path = result.save(out) + _wrote(path, path.stat().st_size) + else: + track = client.text_to_music.generate(prompt=args.prompt, duration=args.duration) + path = track.save(out) + _wrote(path, len(track.audio)) + + +def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None: + fmt = args.format + use_async = args.use_async or fmt == "wav" or args.isolate_vocals or args.preserve_speech + out = _music_output(args, fmt) + if use_async: + result = client.video_to_music.generate_async( + video=args.video, + video_url=args.video_url, + prompt=args.prompt, + isolate_vocals=args.isolate_vocals or None, + preserve_speech=args.preserve_speech or None, + output_format="wav" if fmt == "wav" else None, + ) + path = result.save(out) + _wrote(path, path.stat().st_size) + else: + track = client.video_to_music.generate( + video=args.video, video_url=args.video_url, prompt=args.prompt + ) + path = track.save(out) + _wrote(path, len(track.audio)) + + +_SFX_FORMATS = ["wav", "mp3", "aac", "flac"] + + +def cmd_text_to_sfx(client: Sonilo, args: argparse.Namespace) -> None: + out = args.output if args.output is not None else f"output.{args.format}" + result = client.text_to_sfx.generate( + prompt=args.prompt, duration=args.duration, audio_format=args.format + ) + path = result.save(out) + _wrote(path, path.stat().st_size) + + +def cmd_video_to_sfx(client: Sonilo, args: argparse.Namespace) -> None: + out = args.output if args.output is not None else f"output.{args.format}" + result = client.video_to_sfx.generate( + video=args.video, video_url=args.video_url, + prompt=args.prompt, audio_format=args.format, + ) + path = result.save(out) + _wrote(path, path.stat().st_size) + + +_SOUND_STEMS = ["music", "music_processed", "sfx"] + + +def _stem_path(out: str, stem: str, media: Any) -> str: + base = Path(out) + ext = "" + if media is not None and getattr(media, "url", None): + ext = Path(urlparse(media.url).path).suffix + return str(base.with_name(f"{base.stem}.{stem}{ext or base.suffix}")) + + +def _run_sound(client: Sonilo, args: argparse.Namespace, resource: Any, default_ext: str) -> None: + out = args.output if args.output is not None else f"output.{default_ext}" + result = resource.generate( + video=args.video, + video_url=args.video_url, + music_prompt=args.music_prompt, + sfx_prompt=args.sfx_prompt, + preserve_speech=True if args.preserve_speech else None, + ducking=False if args.no_ducking else None, + ) + path = result.save(out) + _wrote(path, path.stat().st_size) + for stem in args.stems or []: + stem_path = _stem_path(out, stem, getattr(result, stem, None)) + saved = result.save_stem(stem_path, which=stem) + _wrote(saved, saved.stat().st_size) + + +def cmd_video_to_sound(client: Sonilo, args: argparse.Namespace) -> None: + _run_sound(client, args, client.video_to_sound, "wav") + + +def cmd_video_to_video_sound(client: Sonilo, args: argparse.Namespace) -> None: + _run_sound(client, args, client.video_to_video_sound, "mp4") + + +def _identity(body: Any) -> Any: + return body + + +def cmd_tasks_get(client: Sonilo, args: argparse.Namespace) -> None: + _print_json(client.tasks.get(args.task_id, parser=_identity)) + + +def cmd_tasks_wait(client: Sonilo, args: argparse.Namespace) -> None: + deadline = time.monotonic() + args.timeout + while True: + body = client.tasks.get(args.task_id, parser=_identity) + status = body.get("status") if isinstance(body, dict) else None + if status == "succeeded": + _print_json(body) + return + if status == "failed": + _print_json(body) + raise SystemExit(1) + remaining = deadline - time.monotonic() + if remaining <= 0: + _fail(f"timed out after {args.timeout}s waiting for task {args.task_id}") + time.sleep(min(args.poll_interval, max(0.0, remaining))) + + +def _add_global(parser: argparse.ArgumentParser) -> None: + # default=SUPPRESS (not None) is required here: argparse subparsers parse + # into a *fresh* namespace and then copy every key back onto the parent + # namespace (see cpython's _SubParsersAction.__call__), so a subparser + # default of None would clobber an --api-key already set on the parent + # parser (e.g. `sonilo --api-key X account`). With SUPPRESS, the key is + # only ever set when the flag is actually given, so it never overwrites + # a value set elsewhere with a default. + parser.add_argument( + "--api-key", + dest="api_key", + default=argparse.SUPPRESS, + help="Overrides the SONILO_API_KEY environment variable.", + ) + + +def _add_video_source(parser: argparse.ArgumentParser) -> None: + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--video", default=None, help="Local video file to score.") + group.add_argument("--video-url", dest="video_url", default=None, + help="Remote video URL to score.") + + +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__) + _add_global(parser) + sub = parser.add_subparsers(dest="command", metavar="") + + p_account = sub.add_parser("account", help="Show plan limits and available services") + _add_global(p_account) + p_account.set_defaults(func=cmd_account) + + p_usage = sub.add_parser("usage", help="Show usage summary") + _add_global(p_usage) + p_usage.add_argument("--days", type=int, default=None, help="Look-back window in days.") + p_usage.set_defaults(func=cmd_usage) + + 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("--output", default=None, help="Where to save the audio.") + p_t2m.add_argument("--format", choices=["m4a", "wav"], default="m4a", + help="Output container. wav forces async. Default: m4a") + p_t2m.add_argument("--async", dest="use_async", action="store_true", + help="Submit and poll instead of streaming.") + p_t2m.set_defaults(func=cmd_text_to_music) + + p_v2m = sub.add_parser("video-to-music", help="Generate music matched to a video") + _add_global(p_v2m) + _add_video_source(p_v2m) + p_v2m.add_argument("--prompt", default=None, help="Optional creative direction.") + p_v2m.add_argument("--output", default=None, help="Where to save the audio.") + p_v2m.add_argument("--format", choices=["m4a", "wav"], default="m4a", + help="Output container. wav forces async.") + p_v2m.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true", + help="Split out a vocals-only stem. Forces async.") + p_v2m.add_argument("--preserve-speech", dest="preserve_speech", action="store_true", + help="Keep source speech in the mix. Forces async.") + p_v2m.add_argument("--async", dest="use_async", action="store_true", + help="Submit and poll instead of streaming.") + 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") + _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("--output", default=None, help="Where to save the audio.") + p_t2s.add_argument("--format", choices=_SFX_FORMATS, default="wav", + help="Output format. Default: wav") + p_t2s.set_defaults(func=cmd_text_to_sfx) + + p_v2s = sub.add_parser("video-to-sfx", help="Generate a sound effect matched to a video") + _add_global(p_v2s) + _add_video_source(p_v2s) + p_v2s.add_argument("--prompt", default=None, help="Optional creative direction.") + p_v2s.add_argument("--output", default=None, help="Where to save the audio.") + p_v2s.add_argument("--format", choices=_SFX_FORMATS, default="wav", + help="Output format. Default: wav") + p_v2s.set_defaults(func=cmd_video_to_sfx) + + p_v2sd = sub.add_parser( + "video-to-sound", help="Generate matched music+sfx audio for a video" + ) + _add_global(p_v2sd) + _add_video_source(p_v2sd) + p_v2sd.add_argument("--music-prompt", dest="music_prompt", default=None, + help="Optional creative direction for the music bed.") + p_v2sd.add_argument("--sfx-prompt", dest="sfx_prompt", default=None, + help="Optional creative direction for the sound effects.") + p_v2sd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true", + help="Keep source speech in the mix.") + p_v2sd.add_argument("--no-ducking", dest="no_ducking", action="store_true", + help="Disable automatic ducking (on by default server-side).") + p_v2sd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS, + default=None, help="Also save an individual stem. Repeatable.") + p_v2sd.add_argument("--output", default=None, help="Where to save the combined audio.") + p_v2sd.set_defaults(func=cmd_video_to_sound) + + p_v2vsd = sub.add_parser( + "video-to-video-sound", help="Generate matched music+sfx muxed into the source video" + ) + _add_global(p_v2vsd) + _add_video_source(p_v2vsd) + p_v2vsd.add_argument("--music-prompt", dest="music_prompt", default=None, + help="Optional creative direction for the music bed.") + p_v2vsd.add_argument("--sfx-prompt", dest="sfx_prompt", default=None, + help="Optional creative direction for the sound effects.") + p_v2vsd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true", + help="Keep source speech in the mix.") + p_v2vsd.add_argument("--no-ducking", dest="no_ducking", action="store_true", + help="Disable automatic ducking (on by default server-side).") + p_v2vsd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS, + default=None, help="Also save an individual stem. Repeatable.") + p_v2vsd.add_argument("--output", default=None, help="Where to save the combined video.") + p_v2vsd.set_defaults(func=cmd_video_to_video_sound) + + p_tasks = sub.add_parser("tasks", help="Inspect async tasks") + _add_global(p_tasks) + tsub = p_tasks.add_subparsers(dest="tasks_command", metavar="") + + p_get = tsub.add_parser("get", help="Fetch the current state of an async task") + _add_global(p_get) + p_get.add_argument("task_id", help="The task id to fetch.") + p_get.set_defaults(func=cmd_tasks_get) + + p_wait = tsub.add_parser("wait", help="Poll an async task until it finishes") + _add_global(p_wait) + p_wait.add_argument("task_id", help="The task id to poll.") + p_wait.add_argument("--poll-interval", dest="poll_interval", type=float, default=2.0, + help="Seconds between polls. Default: 2") + p_wait.add_argument("--timeout", type=float, default=600.0, + help="Give up after this many seconds. Default: 600") + p_wait.set_defaults(func=cmd_tasks_wait) + + return parser + + +def main(argv: Optional[List[str]] = None) -> None: + parser = build_parser() + args = parser.parse_args(argv) + func = getattr(args, "func", None) + if func is None: + parser.error("missing command (try `sonilo --help`)") + client = build_client(getattr(args, "api_key", None)) + try: + func(client, args) + except SoniloError as exc: + _fail(str(exc)) + finally: + client.close() + + +if __name__ == "__main__": + main() diff --git a/sonilo-cli/tests/__init__.py b/sonilo-cli/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py new file mode 100644 index 0000000..9d7d5ff --- /dev/null +++ b/sonilo-cli/tests/test_cli.py @@ -0,0 +1,371 @@ +import json + +import httpx +import pytest +import respx + +from sonilo_cli.__main__ import main + +BASE = "https://api.sonilo.com" + + +def run(argv, api_key="sk-test"): + full = (["--api-key", api_key] if api_key is not None else []) + argv + main(full) + + +@respx.mock +def test_account_prints_json(capsys): + respx.get(f"{BASE}/v1/account/services").mock( + return_value=httpx.Response(200, json={"plan": "pro"}) + ) + run(["account"]) + out = json.loads(capsys.readouterr().out) + assert out == {"plan": "pro"} + + +@respx.mock +def test_usage_passes_days(capsys): + route = respx.get(f"{BASE}/v1/account/usage").mock( + return_value=httpx.Response(200, json={"days": 7}) + ) + run(["usage", "--days", "7"]) + assert route.calls.last.request.url.params["days"] == "7" + + +def test_missing_api_key_exits_1(capsys, monkeypatch): + monkeypatch.delenv("SONILO_API_KEY", raising=False) + with pytest.raises(SystemExit) as exc: + main(["account"]) # no --api-key, no env + assert exc.value.code == 1 + assert "no API key" in capsys.readouterr().err + + +def test_unknown_command_exits_1(capsys): + with pytest.raises(SystemExit) as exc: + main(["--api-key", "sk-test", "frobnicate"]) + assert exc.value.code == 1 + assert "sonilo:" in capsys.readouterr().err + + +@respx.mock +def test_api_error_has_no_traceback(capsys): + respx.get(f"{BASE}/v1/account/services").mock( + return_value=httpx.Response(401, json={"error": {"message": "bad key"}}) + ) + with pytest.raises(SystemExit) as exc: + run(["account"]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert err.startswith("sonilo:") + assert "Traceback" not in err + + +def _music_stream_body(): + # Minimal NDJSON stream matching sonilo._streaming.collect_track: an + # audio_chunk event (base64 "data", decoded by iter_events) followed by + # the terminal "complete" event. Confirmed against tests/test_streaming.py. + import base64 + + chunk = {"type": "audio_chunk", "data": base64.b64encode(b"ID3xx").decode()} + done = {"type": "complete"} + return "\n".join(json.dumps(e) for e in (chunk, done)) + "\n" + + +@respx.mock +def test_text_to_music_streaming_saves_m4a(tmp_path, capsys): + respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response(200, text=_music_stream_body()) + ) + out = tmp_path / "song.m4a" + run(["text-to-music", "--prompt", "lofi", "--duration", "10", "--output", str(out)]) + assert out.read_bytes() == b"ID3xx" + assert "Wrote" in capsys.readouterr().out + + +@respx.mock +def test_text_to_music_wav_forces_async(tmp_path): + submit = respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response(200, json={"task_id": "t1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/t1").mock( + return_value=httpx.Response(200, json={ + "task_id": "t1", "type": "text_to_music", "status": "succeeded", + "audio": [{"stream_index": 0, "url": "https://r2.example.com/a.wav", + "content_type": "audio/wav", "file_size": 3}], + }) + ) + respx.get("https://r2.example.com/a.wav").mock( + return_value=httpx.Response(200, content=b"RIF") + ) + out = tmp_path / "song.wav" + run(["text-to-music", "--prompt", "lofi", "--duration", "10", + "--format", "wav", "--output", str(out)]) + # Async path used: a submit POST happened AND polling GET happened. + assert submit.called + assert out.read_bytes() == b"RIF" + + +def test_video_to_music_requires_a_video_source(): + with pytest.raises(SystemExit) as exc: + run(["video-to-music", "--prompt", "x"]) # neither --video nor --video-url + assert exc.value.code == 1 + + +def test_video_to_music_rejects_both_sources(): + with pytest.raises(SystemExit) as exc: + run(["video-to-music", "--video", "a.mp4", "--video-url", "http://x/y.mp4"]) + assert exc.value.code == 1 + + +@respx.mock +def test_text_to_sfx_saves_wav(tmp_path): + respx.post(f"{BASE}/v1/text-to-sfx").mock( + return_value=httpx.Response(200, json={"task_id": "s1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/s1").mock( + return_value=httpx.Response(200, json={ + "task_id": "s1", "type": "text_to_sfx", "status": "succeeded", + "audio": {"url": "https://r2.example.com/s.wav", + "content_type": "audio/wav", "file_size": 3}, + }) + ) + respx.get("https://r2.example.com/s.wav").mock( + return_value=httpx.Response(200, content=b"RIF") + ) + out = tmp_path / "fx.wav" + run(["text-to-sfx", "--prompt", "glass break", "--duration", "3", "--output", str(out)]) + assert out.read_bytes() == b"RIF" + + +@respx.mock +def test_text_to_sfx_format_maps_to_audio_format(tmp_path): + route = respx.post(f"{BASE}/v1/text-to-sfx").mock( + return_value=httpx.Response(200, json={"task_id": "s2", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/s2").mock( + return_value=httpx.Response(200, json={ + "task_id": "s2", "type": "text_to_sfx", "status": "succeeded", + "audio": {"url": "https://r2.example.com/s.mp3", + "content_type": "audio/mpeg", "file_size": 3}, + }) + ) + respx.get("https://r2.example.com/s.mp3").mock( + return_value=httpx.Response(200, content=b"ID3") + ) + run(["text-to-sfx", "--prompt", "x", "--duration", "2", + "--format", "mp3", "--output", str(tmp_path / "fx.mp3")]) + # The request body is form-encoded (per build_sfx_t2s_data/_post_json, and + # confirmed against tests/test_sfx.py::test_text_to_sfx_submit_posts_form), + # not JSON. It carries the chosen format under audio_format. + body = route.calls.last.request.content.decode() + assert "audio_format=mp3" in body + + +def test_video_to_sfx_requires_a_video_source(): + with pytest.raises(SystemExit) as exc: + run(["video-to-sfx", "--prompt", "x"]) + assert exc.value.code == 1 + + +@respx.mock +def test_tasks_get_prints_raw_json(capsys): + respx.get(f"{BASE}/v1/tasks/abc").mock( + return_value=httpx.Response(200, json={"task_id": "abc", "status": "processing"}) + ) + run(["tasks", "get", "abc"]) + assert json.loads(capsys.readouterr().out) == {"task_id": "abc", "status": "processing"} + + +@respx.mock +def test_tasks_wait_polls_until_succeeded(capsys): + respx.get(f"{BASE}/v1/tasks/abc").mock( + side_effect=[ + httpx.Response(200, json={"task_id": "abc", "status": "processing"}), + httpx.Response(200, json={"task_id": "abc", "status": "succeeded"}), + ] + ) + run(["tasks", "wait", "abc", "--poll-interval", "0"]) + assert json.loads(capsys.readouterr().out)["status"] == "succeeded" + + +@respx.mock +def test_tasks_wait_failed_exits_1(capsys): + respx.get(f"{BASE}/v1/tasks/abc").mock( + return_value=httpx.Response(200, json={"task_id": "abc", "status": "failed"}) + ) + with pytest.raises(SystemExit) as exc: + run(["tasks", "wait", "abc", "--poll-interval", "0"]) + assert exc.value.code == 1 + + +def test_tasks_unknown_subcommand_exits_1(capsys): + with pytest.raises(SystemExit) as exc: + run(["tasks", "frob", "abc"]) + assert exc.value.code == 1 + + +# --- video-to-sound / video-to-video-sound ------------------------------- +# +# Fixture shape confirmed against tests/test_video_to_sound.py::SUCCESS_BODY +# in the SDK repo. + +SOUND_SUCCESS_BODY = { + "task_id": "sd1", + "type": "video_to_sound", + "status": "succeeded", + "output_url": "https://r2.example.com/sound.wav", + "output_type": "audio", + "output_bytes": 5, + "music": {"url": "https://r2.example.com/sound.music.m4a", + "content_type": "audio/mp4", "file_size": 5}, + "sfx": {"url": "https://r2.example.com/sound.sfx.wav", + "content_type": "audio/wav", "file_size": 3}, +} + + +def _sound_body(task_id, **overrides): + return {**SOUND_SUCCESS_BODY, "task_id": task_id, **overrides} + + +@respx.mock +def test_video_to_sound_saves_combined_output(tmp_path): + respx.post(f"{BASE}/v1/video-to-sound").mock( + return_value=httpx.Response(200, json={"task_id": "sd1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/sd1").mock( + return_value=httpx.Response(200, json=_sound_body("sd1")) + ) + respx.get("https://r2.example.com/sound.wav").mock( + return_value=httpx.Response(200, content=b"MIXED") + ) + out = tmp_path / "s.wav" + run(["video-to-sound", "--video-url", "http://x/y.mp4", "--output", str(out)]) + assert out.read_bytes() == b"MIXED" + + +@respx.mock +def test_video_to_sound_stem_flag_saves_stems_alongside(tmp_path): + respx.post(f"{BASE}/v1/video-to-sound").mock( + return_value=httpx.Response(200, json={"task_id": "sd2", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/sd2").mock( + return_value=httpx.Response(200, json=_sound_body("sd2")) + ) + respx.get("https://r2.example.com/sound.wav").mock( + return_value=httpx.Response(200, content=b"MIXED") + ) + respx.get("https://r2.example.com/sound.music.m4a").mock( + return_value=httpx.Response(200, content=b"MUSIC") + ) + respx.get("https://r2.example.com/sound.sfx.wav").mock( + return_value=httpx.Response(200, content=b"SFX") + ) + out = tmp_path / "s.wav" + run(["video-to-sound", "--video-url", "http://x/y.mp4", "--output", str(out), + "--stem", "music", "--stem", "sfx"]) + assert (tmp_path / "s.music.m4a").read_bytes() == b"MUSIC" + assert (tmp_path / "s.sfx.wav").read_bytes() == b"SFX" + + +@respx.mock +def test_video_to_sound_ducking_absent_omits_field(tmp_path): + route = respx.post(f"{BASE}/v1/video-to-sound").mock( + return_value=httpx.Response(200, json={"task_id": "sd3", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/sd3").mock( + return_value=httpx.Response(200, json=_sound_body("sd3")) + ) + respx.get("https://r2.example.com/sound.wav").mock( + return_value=httpx.Response(200, content=b"MIXED") + ) + run(["video-to-sound", "--video-url", "http://x/y.mp4", + "--output", str(tmp_path / "s.wav")]) + # ducking is default-ON server-side: an unset --no-ducking must forward + # `None`, not `False`, so the field must be entirely absent from the + # form-encoded body (per build_v2s_parts). + body = route.calls.last.request.content.decode() + assert "ducking=" not in body + + +@respx.mock +def test_video_to_sound_no_ducking_sets_false(tmp_path): + route = respx.post(f"{BASE}/v1/video-to-sound").mock( + return_value=httpx.Response(200, json={"task_id": "sd4", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/sd4").mock( + return_value=httpx.Response(200, json=_sound_body("sd4")) + ) + respx.get("https://r2.example.com/sound.wav").mock( + return_value=httpx.Response(200, content=b"MIXED") + ) + run(["video-to-sound", "--video-url", "http://x/y.mp4", + "--output", str(tmp_path / "s.wav"), "--no-ducking"]) + body = route.calls.last.request.content.decode() + assert "ducking=false" in body + + +@respx.mock +def test_video_to_sound_preserve_speech_absent_omits_field(tmp_path): + route = respx.post(f"{BASE}/v1/video-to-sound").mock( + return_value=httpx.Response(200, json={"task_id": "sd5", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/sd5").mock( + return_value=httpx.Response(200, json=_sound_body("sd5")) + ) + respx.get("https://r2.example.com/sound.wav").mock( + return_value=httpx.Response(200, content=b"MIXED") + ) + run(["video-to-sound", "--video-url", "http://x/y.mp4", + "--output", str(tmp_path / "s.wav")]) + body = route.calls.last.request.content.decode() + assert "preserve_speech=" not in body + + +@respx.mock +def test_video_to_sound_preserve_speech_flag_sets_true(tmp_path): + route = respx.post(f"{BASE}/v1/video-to-sound").mock( + return_value=httpx.Response(200, json={"task_id": "sd6", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/sd6").mock( + return_value=httpx.Response(200, json=_sound_body("sd6")) + ) + respx.get("https://r2.example.com/sound.wav").mock( + return_value=httpx.Response(200, content=b"MIXED") + ) + run(["video-to-sound", "--video-url", "http://x/y.mp4", + "--output", str(tmp_path / "s.wav"), "--preserve-speech"]) + body = route.calls.last.request.content.decode() + assert "preserve_speech=true" in body + + +@respx.mock +def test_video_to_video_sound_defaults_to_mp4(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + route = respx.post(f"{BASE}/v1/video-to-video-sound").mock( + return_value=httpx.Response(200, json={"task_id": "sd7", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/sd7").mock( + return_value=httpx.Response(200, json=_sound_body( + "sd7", type="video_to_video_sound", output_type="video", + output_url="https://r2.example.com/sound.mp4", + )) + ) + respx.get("https://r2.example.com/sound.mp4").mock( + return_value=httpx.Response(200, content=b"MP4DATA") + ) + run(["video-to-video-sound", "--video-url", "http://x/y.mp4"]) + assert route.called + assert (tmp_path / "output.mp4").read_bytes() == b"MP4DATA" + + +def test_video_to_sound_requires_a_video_source(): + with pytest.raises(SystemExit) as exc: + run(["video-to-sound"]) + assert exc.value.code == 1 + + +def test_video_to_video_sound_requires_a_video_source(): + with pytest.raises(SystemExit) as exc: + run(["video-to-video-sound"]) + assert exc.value.code == 1 diff --git a/sonilo-cli/tests/test_context7.py b/sonilo-cli/tests/test_context7.py new file mode 100644 index 0000000..a7968b7 --- /dev/null +++ b/sonilo-cli/tests/test_context7.py @@ -0,0 +1,12 @@ +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_context7_json_is_valid_and_complete(): + data = json.loads((ROOT / "context7.json").read_text()) + assert data["projectTitle"] == "Sonilo" + assert data["$schema"] == "https://context7.com/schema/context7.json" + assert "**/__pycache__/**" in data["excludeFolders"] + assert isinstance(data["rules"], list) and len(data["rules"]) >= 5 diff --git a/sonilo-cli/tests/test_smoke.py b/sonilo-cli/tests/test_smoke.py new file mode 100644 index 0000000..e7e1129 --- /dev/null +++ b/sonilo-cli/tests/test_smoke.py @@ -0,0 +1,16 @@ +import pytest + +import sonilo_cli +from sonilo_cli.__main__ import main + + +def test_version_is_a_string(): + assert isinstance(sonilo_cli.__version__, str) + assert sonilo_cli.__version__.count(".") == 2 + + +def test_version_flag_prints_and_exits_zero(capsys): + with pytest.raises(SystemExit) as exc: + main(["--version"]) + assert exc.value.code == 0 + assert sonilo_cli.__version__ in capsys.readouterr().out