venice-cli 0.14.0__py3-none-any.whl

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.
venice/commands/sfx.py ADDED
@@ -0,0 +1,199 @@
1
+ """`venice sfx` -- generate a sound effect via Venice's async audio queue."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .. import audio_post, billing, config
8
+ from ..client import VeniceAPIError
9
+ from . import _audio, _queue, _shared
10
+
11
+ SFX_MODELS = {
12
+ "elevenlabs-sound-effects-v2": (22, "mp3"),
13
+ "mmaudio-v2-text-to-audio": (30, "mp3"),
14
+ }
15
+ DEFAULT_SFX_MODEL = "elevenlabs-sound-effects-v2"
16
+ DEFAULT_DURATION = 5
17
+
18
+
19
+ def register(subparsers) -> None:
20
+ p = subparsers.add_parser(
21
+ "sfx",
22
+ help="Generate a sound effect via Venice audio queue.",
23
+ description=(
24
+ "Generates a sound effect. Async flow: quote -> queue -> poll -> "
25
+ "save. Use --dry-run to see only the cost quote. To fetch a "
26
+ "backgrounded job by its queue_id, use `venice sfx-status`."
27
+ ),
28
+ )
29
+ p.add_argument("prompt", nargs="?", help="Sound description (e.g. 'thunderstorm rolling in').")
30
+ p.add_argument(
31
+ "--model",
32
+ choices=sorted(SFX_MODELS),
33
+ default=DEFAULT_SFX_MODEL,
34
+ )
35
+ p.add_argument("--duration", type=int, default=DEFAULT_DURATION)
36
+ p.add_argument("--output", "-o", type=Path, default=None)
37
+ play_grp = p.add_mutually_exclusive_group()
38
+ play_grp.add_argument("--play", dest="play", action="store_true", default=None)
39
+ play_grp.add_argument("--no-play", dest="play", action="store_false")
40
+ p.add_argument("--yes", "-y", action="store_true")
41
+ p.add_argument("--background", action="store_true")
42
+ p.add_argument("--dry-run", action="store_true")
43
+ p.add_argument("--no-cleanup", action="store_true")
44
+ p.add_argument(
45
+ "--max-spend",
46
+ type=float,
47
+ default=None,
48
+ metavar="USD",
49
+ help="Refuse to queue if the quote exceeds this USD cap.",
50
+ )
51
+ p.add_argument(
52
+ "--no-balance",
53
+ action="store_true",
54
+ help="Skip the upfront balance display.",
55
+ )
56
+ p.add_argument("--poll-interval", type=float, default=config.SFX_POLL_INTERVAL_SEC)
57
+ p.add_argument("--max-wait", type=float, default=config.SFX_POLL_MAX_WAIT_SEC)
58
+ audio_post.add_master_flags(p, include_toggle=True)
59
+ p.set_defaults(handler=_run_generate)
60
+
61
+
62
+ def register_status(subparsers) -> None:
63
+ sp = subparsers.add_parser(
64
+ "sfx-status",
65
+ help="Fetch a previously-backgrounded SFX job by queue_id.",
66
+ description=(
67
+ "Polls /audio/retrieve for an already-queued job (typically from "
68
+ "`venice sfx ... --background`) and downloads the audio when ready."
69
+ ),
70
+ )
71
+ sp.add_argument("queue_id")
72
+ sp.add_argument(
73
+ "--model",
74
+ choices=sorted(SFX_MODELS),
75
+ default=DEFAULT_SFX_MODEL,
76
+ )
77
+ sp.add_argument("--output", "-o", type=Path, default=None)
78
+ sp.add_argument("--play", action="store_true")
79
+ sp.add_argument("--no-cleanup", action="store_true")
80
+ sp.add_argument("--poll-interval", type=float, default=config.SFX_POLL_INTERVAL_SEC)
81
+ sp.add_argument("--max-wait", type=float, default=config.SFX_POLL_MAX_WAIT_SEC)
82
+ sp.set_defaults(handler=_run_status)
83
+
84
+
85
+ def _clamp_duration(model: str, duration: int) -> int:
86
+ max_dur, _ = SFX_MODELS[model]
87
+ if duration <= 0:
88
+ print(f"sfx: --duration must be > 0; using {DEFAULT_DURATION}", file=sys.stderr)
89
+ return DEFAULT_DURATION
90
+ if duration > max_dur:
91
+ print(
92
+ f"sfx: --duration {duration}s exceeds {model} max {max_dur}s; clamping",
93
+ file=sys.stderr,
94
+ )
95
+ return max_dur
96
+ return duration
97
+
98
+
99
+ def _run_generate(args) -> int:
100
+ if not args.prompt:
101
+ print("sfx: prompt required (or use: venice sfx-status <id>)", file=sys.stderr)
102
+ return 2
103
+
104
+ if args.master and not audio_post.has_ffmpeg():
105
+ print("sfx: --master requires ffmpeg on PATH; install it or drop --master",
106
+ file=sys.stderr)
107
+ return 2
108
+
109
+ client, rc = _queue.build_client()
110
+ if rc != 0:
111
+ return rc
112
+
113
+ duration = _clamp_duration(args.model, args.duration)
114
+
115
+ try:
116
+ quote = client.post_json(
117
+ "/audio/quote",
118
+ {"model": args.model, "duration_seconds": duration},
119
+ )
120
+ except VeniceAPIError as e:
121
+ print(f"quote rejected: {e}", file=sys.stderr)
122
+ return _queue.status_to_exit(e)
123
+
124
+ quote_value = quote.get("quote", quote)
125
+ _shared.print_estimate(quote_value, f"model={args.model}, duration={duration}s")
126
+ _shared.print_balance_and_remaining(client, quote_value, show=not args.no_balance)
127
+
128
+ if _shared.over_budget(quote_value, args.max_spend):
129
+ print(
130
+ f"sfx: quote {billing.format_usd(quote_value)} exceeds "
131
+ f"--max-spend {billing.format_usd(args.max_spend)}; aborting",
132
+ file=sys.stderr,
133
+ )
134
+ return 1
135
+
136
+ if args.dry_run:
137
+ return 0
138
+
139
+ if not args.background:
140
+ rc = _shared.confirm_or_exit(args.yes)
141
+ if rc is not None:
142
+ return rc
143
+
144
+ try:
145
+ queued = client.post_json(
146
+ "/audio/queue",
147
+ {"model": args.model, "prompt": args.prompt, "duration_seconds": duration},
148
+ )
149
+ except VeniceAPIError as e:
150
+ print(f"queue failed: {e}", file=sys.stderr)
151
+ return _queue.status_to_exit(e)
152
+
153
+ queue_id = queued.get("queue_id") or queued.get("id") or ""
154
+ if not queue_id:
155
+ print(f"queue response missing queue_id: {queued!r}", file=sys.stderr)
156
+ return 5
157
+
158
+ if args.background:
159
+ sys.stdout.write(queue_id + "\n")
160
+ sys.stdout.flush()
161
+ print(
162
+ f"queued as {queue_id}; fetch with: venice sfx-status {queue_id}",
163
+ file=sys.stderr,
164
+ )
165
+ return 0
166
+
167
+ post = audio_post.master_hook(args) if args.master else None
168
+ return _audio.retrieve_and_save(
169
+ client,
170
+ args.model,
171
+ queue_id,
172
+ args.output,
173
+ args.poll_interval,
174
+ args.max_wait,
175
+ args.no_cleanup,
176
+ args.play,
177
+ name_prefix="venice-sfx",
178
+ retry_hint=f"venice sfx-status {queue_id}",
179
+ post_process=post,
180
+ )
181
+
182
+
183
+ def _run_status(args) -> int:
184
+ client, rc = _queue.build_client()
185
+ if rc != 0:
186
+ return rc
187
+ want_play = True if args.play else None
188
+ return _audio.retrieve_and_save(
189
+ client,
190
+ args.model,
191
+ args.queue_id,
192
+ args.output,
193
+ args.poll_interval,
194
+ args.max_wait,
195
+ args.no_cleanup,
196
+ want_play,
197
+ name_prefix="venice-sfx",
198
+ retry_hint=f"venice sfx-status {args.queue_id}",
199
+ )
venice/commands/tts.py ADDED
@@ -0,0 +1,362 @@
1
+ """`venice tts` -- synthesize speech via Venice's /audio/speech endpoint.
2
+
3
+ Sync flow (no queue). Pricing is per 1M characters; the command fetches
4
+ the live per-model rate from /models?type=tts and shows the estimate
5
+ upfront alongside the current balance. Mirrors the SFX UX where
6
+ sensible (--yes, --max-spend, --no-balance, --dry-run, --output, --play).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Optional, Tuple
14
+
15
+ from .. import audio_player, auth, billing, config
16
+ from ..client import VeniceAPIError, build_client_from_auth
17
+
18
+ # Slugs verified against /models?type=tts on 2026-05-22.
19
+ TTS_MODELS = (
20
+ "tts-kokoro",
21
+ "tts-qwen3-0-6b",
22
+ "tts-qwen3-1-7b",
23
+ "tts-xai-v1",
24
+ "tts-inworld-1-5-max",
25
+ "tts-chatterbox-hd",
26
+ "tts-orpheus",
27
+ "tts-elevenlabs-turbo-v2-5",
28
+ "tts-minimax-speech-02-hd",
29
+ "tts-gemini-3-1-flash",
30
+ )
31
+ DEFAULT_TTS_MODEL = "tts-kokoro" # cheapest ($3.50/1M chars), 54 voices
32
+
33
+ FORMATS = ("mp3", "opus", "aac", "flac", "wav", "pcm")
34
+ DEFAULT_FORMAT = "mp3"
35
+
36
+ EXT_BY_FORMAT = {
37
+ "mp3": ".mp3",
38
+ "opus": ".opus",
39
+ "aac": ".aac",
40
+ "flac": ".flac",
41
+ "wav": ".wav",
42
+ "pcm": ".pcm",
43
+ }
44
+
45
+
46
+ def register(subparsers) -> None:
47
+ p = subparsers.add_parser(
48
+ "tts",
49
+ help="Synthesize speech via /audio/speech (sync).",
50
+ description=(
51
+ "Synthesizes speech. Input from positional text, --from-file, "
52
+ "or --stdin. Pricing is per 1M characters; cost is estimated "
53
+ "from the live model rate. Use `venice models <model-slug>` to "
54
+ "see the voice list for a given TTS model."
55
+ ),
56
+ )
57
+ p.add_argument(
58
+ "text",
59
+ nargs="?",
60
+ help="Text to speak. Use '-' for stdin, or omit and pass --from-file/--stdin.",
61
+ )
62
+ src = p.add_mutually_exclusive_group()
63
+ src.add_argument(
64
+ "--from-file",
65
+ type=Path,
66
+ default=None,
67
+ metavar="PATH",
68
+ help="Read the input text from PATH instead of the positional arg.",
69
+ )
70
+ src.add_argument(
71
+ "--stdin",
72
+ action="store_true",
73
+ help="Read the input text from stdin until EOF.",
74
+ )
75
+ p.add_argument(
76
+ "--model",
77
+ choices=TTS_MODELS,
78
+ default=DEFAULT_TTS_MODEL,
79
+ )
80
+ p.add_argument(
81
+ "--voice",
82
+ default=None,
83
+ help="Voice id (model-specific). If omitted, Venice uses the model default.",
84
+ )
85
+ p.add_argument(
86
+ "--format",
87
+ choices=FORMATS,
88
+ default=DEFAULT_FORMAT,
89
+ help=f"Output audio format (default {DEFAULT_FORMAT}).",
90
+ )
91
+ p.add_argument(
92
+ "--speed",
93
+ type=float,
94
+ default=None,
95
+ metavar="N",
96
+ help="Playback speed (0.25-4.0). Omit to use server default (1.0).",
97
+ )
98
+ p.add_argument("--output", "-o", type=Path, default=None)
99
+ play_grp = p.add_mutually_exclusive_group()
100
+ play_grp.add_argument("--play", dest="play", action="store_true", default=None)
101
+ play_grp.add_argument("--no-play", dest="play", action="store_false")
102
+ p.add_argument("--yes", "-y", action="store_true")
103
+ p.add_argument("--dry-run", action="store_true",
104
+ help="Estimate cost and exit; don't call /audio/speech.")
105
+ p.add_argument(
106
+ "--max-spend",
107
+ type=float,
108
+ default=None,
109
+ metavar="USD",
110
+ help="Refuse to synthesize if the estimated cost exceeds this USD cap.",
111
+ )
112
+ p.add_argument(
113
+ "--no-balance",
114
+ action="store_true",
115
+ help="Skip the upfront balance display.",
116
+ )
117
+ p.set_defaults(handler=_run)
118
+
119
+
120
+ # ---- input source resolution -------------------------------------------------
121
+
122
+ def _read_input(args) -> Tuple[Optional[str], int]:
123
+ """Resolve the input text. Returns (text, exit_code). text=None on error."""
124
+ sources = sum(
125
+ 1 for v in (args.text and args.text != "-", args.from_file, args.stdin) if v
126
+ )
127
+ use_stdin = bool(args.stdin) or args.text == "-"
128
+
129
+ if args.from_file:
130
+ if args.text is not None and args.text != "-":
131
+ print("tts: cannot combine positional text with --from-file", file=sys.stderr)
132
+ return None, 2
133
+ try:
134
+ text = args.from_file.read_text(encoding="utf-8")
135
+ except OSError as e:
136
+ print(f"tts: cannot read {args.from_file}: {e}", file=sys.stderr)
137
+ return None, 2
138
+ elif use_stdin:
139
+ text = sys.stdin.read()
140
+ elif args.text:
141
+ text = args.text
142
+ else:
143
+ print(
144
+ "tts: input required (positional text, --from-file PATH, or --stdin)",
145
+ file=sys.stderr,
146
+ )
147
+ return None, 2
148
+
149
+ text = text.strip()
150
+ if not text:
151
+ print("tts: input is empty", file=sys.stderr)
152
+ return None, 2
153
+ return text, 0
154
+
155
+
156
+ # ---- pricing + cost estimation ----------------------------------------------
157
+
158
+ def _fetch_tts_price_per_million(client, model: str) -> Optional[float]:
159
+ """Look up `model_spec.pricing.input.usd` for the given TTS model. Best-effort."""
160
+ try:
161
+ doc = client.get_json("/models", params={"type": "tts"})
162
+ except VeniceAPIError:
163
+ return None
164
+ data = doc.get("data") if isinstance(doc, dict) else None
165
+ if not isinstance(data, list):
166
+ return None
167
+ for m in data:
168
+ if isinstance(m, dict) and m.get("id") == model:
169
+ try:
170
+ return float(m["model_spec"]["pricing"]["input"]["usd"])
171
+ except (KeyError, TypeError, ValueError):
172
+ return None
173
+ return None
174
+
175
+
176
+ def _estimate_cost(char_count: int, price_per_million: Optional[float]) -> Optional[float]:
177
+ if price_per_million is None:
178
+ return None
179
+ return (char_count / 1_000_000.0) * price_per_million
180
+
181
+
182
+ # ---- output path -------------------------------------------------------------
183
+
184
+ def _short_id(text: str, model: str, voice: Optional[str]) -> str:
185
+ """Stable 8-char hex tag derived from inputs; useful as a filename suffix."""
186
+ h = hashlib.sha1()
187
+ h.update(text.encode("utf-8"))
188
+ h.update(model.encode("utf-8"))
189
+ if voice:
190
+ h.update(voice.encode("utf-8"))
191
+ return h.hexdigest()[:8]
192
+
193
+
194
+ def _resolve_output_path(arg_output: Optional[Path], short: str, fmt: str) -> Path:
195
+ ext = EXT_BY_FORMAT.get(fmt, ".bin")
196
+ default_name = f"venice-tts-{short}{ext}"
197
+ if arg_output is None:
198
+ return Path.cwd() / default_name
199
+ if arg_output.is_dir():
200
+ return arg_output / default_name
201
+ return arg_output
202
+
203
+
204
+ # ---- main flow ---------------------------------------------------------------
205
+
206
+ def _print_estimate(cost: Optional[float], char_count: int, model: str) -> None:
207
+ if cost is None:
208
+ print(
209
+ f"Estimated cost: (unknown — could not fetch {model} pricing) "
210
+ f"[{char_count} chars]",
211
+ file=sys.stderr,
212
+ )
213
+ else:
214
+ print(
215
+ f"Estimated cost: {billing.format_usd(cost)} "
216
+ f"({char_count} chars, model={model})",
217
+ file=sys.stderr,
218
+ )
219
+
220
+
221
+ def _print_balance_and_remaining(client, cost: Optional[float], *, show: bool) -> None:
222
+ if not show:
223
+ return
224
+ info = None
225
+ try:
226
+ info = billing.fetch_balance(client)
227
+ except VeniceAPIError:
228
+ info = None
229
+ if not info or info.get("total") is None:
230
+ return
231
+ print(
232
+ f"Balance: {billing.format_balance_breakdown(info)}",
233
+ file=sys.stderr,
234
+ )
235
+ if cost is not None:
236
+ try:
237
+ remaining = float(info["total"]) - float(cost)
238
+ print(f"After charge: {billing.format_usd(remaining)}", file=sys.stderr)
239
+ except (TypeError, ValueError):
240
+ pass
241
+
242
+
243
+ def _confirm_or_exit(yes: bool) -> Optional[int]:
244
+ if yes:
245
+ return None
246
+ if not sys.stdin.isatty():
247
+ print("non-interactive; pass --yes to confirm the charge.", file=sys.stderr)
248
+ return 1
249
+ try:
250
+ ans = input("Proceed? [y/N] ").strip().lower()
251
+ except EOFError:
252
+ ans = ""
253
+ if ans not in ("y", "yes"):
254
+ print("aborted by user", file=sys.stderr)
255
+ return 1
256
+ return None
257
+
258
+
259
+ def _status_to_exit(err: VeniceAPIError) -> int:
260
+ s = err.status
261
+ if s == 422:
262
+ return 3
263
+ if s == 429:
264
+ return 4
265
+ if 500 <= s < 600:
266
+ return 5
267
+ if s == 404:
268
+ return 6
269
+ if s == 0:
270
+ return 8
271
+ return 2
272
+
273
+
274
+ def _validate_speed(speed: Optional[float]) -> Optional[int]:
275
+ if speed is None:
276
+ return None
277
+ if not (0.25 <= speed <= 4.0):
278
+ print(f"tts: --speed {speed} out of range (0.25-4.0)", file=sys.stderr)
279
+ return 2
280
+ return None
281
+
282
+
283
+ def _run(args) -> int:
284
+ rc = _validate_speed(args.speed)
285
+ if rc is not None:
286
+ return rc
287
+
288
+ text, rc = _read_input(args)
289
+ if text is None:
290
+ return rc
291
+
292
+ try:
293
+ client = build_client_from_auth()
294
+ except auth.AuthError as e:
295
+ print(str(e), file=sys.stderr)
296
+ return 2
297
+
298
+ price = _fetch_tts_price_per_million(client, args.model)
299
+ cost = _estimate_cost(len(text), price)
300
+ _print_estimate(cost, len(text), args.model)
301
+ _print_balance_and_remaining(client, cost, show=not args.no_balance)
302
+
303
+ if args.max_spend is not None and cost is not None:
304
+ try:
305
+ if float(cost) > float(args.max_spend):
306
+ print(
307
+ f"tts: estimate {billing.format_usd(cost)} exceeds "
308
+ f"--max-spend {billing.format_usd(args.max_spend)}; aborting",
309
+ file=sys.stderr,
310
+ )
311
+ return 1
312
+ except (TypeError, ValueError):
313
+ pass
314
+
315
+ if args.dry_run:
316
+ return 0
317
+
318
+ rc = _confirm_or_exit(args.yes)
319
+ if rc is not None:
320
+ return rc
321
+
322
+ body: dict = {
323
+ "input": text,
324
+ "model": args.model,
325
+ "response_format": args.format,
326
+ }
327
+ if args.voice:
328
+ body["voice"] = args.voice
329
+ if args.speed is not None:
330
+ body["speed"] = args.speed
331
+
332
+ try:
333
+ status, ctype, audio = client.request(
334
+ "POST", "/audio/speech", json_body=body
335
+ )
336
+ except VeniceAPIError as e:
337
+ print(f"tts failed: {e}", file=sys.stderr)
338
+ return _status_to_exit(e)
339
+
340
+ if not audio:
341
+ print("tts: server returned empty body", file=sys.stderr)
342
+ return 5
343
+
344
+ short = _short_id(text, args.model, args.voice)
345
+ out_path = _resolve_output_path(args.output, short, args.format)
346
+
347
+ try:
348
+ out_path.write_bytes(audio)
349
+ except OSError as e:
350
+ print(f"could not write {out_path}: {e}", file=sys.stderr)
351
+ return 9
352
+
353
+ abs_path = out_path.resolve()
354
+ print(str(abs_path))
355
+ print(f"wrote {len(audio)} bytes to {abs_path}", file=sys.stderr)
356
+
357
+ should_play = args.play
358
+ if should_play is None:
359
+ should_play = sys.stdout.isatty() and audio_player.has_player()
360
+ if should_play:
361
+ audio_player.play(out_path)
362
+ return 0