@petercjl/seedaudiocli 0.1.2 → 0.2.1

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @petercjl/seedaudiocli
2
2
 
3
- EvoLink Seed Audio CLI with a portable companion Skill. Requires Node.js 22+ and Python 3.9+ with SSL; no Python third-party packages are needed.
3
+ Official Volcengine Seed Audio CLI with a portable companion Skill. Requires Node.js 22+ and Python 3.9+ with SSL; no Python third-party packages are needed.
4
4
 
5
5
  ## Install and configure
6
6
 
@@ -27,7 +27,7 @@ Credentials persist outside the npm installation:
27
27
 
28
28
  macOS/Linux files use mode 0600. Windows relies on the user's profile directory ACL; administrators should keep that directory private. Import validates local structure and does not make a paid API call. `auth status` reports local availability, not remote key validity. Environment variables override saved configuration. Network errors do not clear credentials.
29
29
 
30
- An existing configuration requires `--replace`; replacement creates a recoverable sibling backup before atomic write. Backups contain credentials too. Keep them private. Package uninstall/reinstall preserves configuration and task history. Existing env configurations remain supported. Legacy `config init --import-key-file FILE` remains available for older note-format files; new distribution uses `config import`.
30
+ An existing configuration requires `--replace`; replacement creates a recoverable sibling backup before atomic write. Backups contain credentials too. Keep them private. Package uninstall/reinstall preserves configuration and task history. Official env configurations are supported. To migrate an existing installation, import the new official configuration with `--replace`. Relay endpoints and relay model IDs are rejected. Obtain a Doubao Speech API key with Seed Audio access; an Ark API key is a different credential.
31
31
 
32
32
  ## Generate and recover
33
33
 
@@ -37,7 +37,13 @@ seedaudiocli generate text --prompt "Generate five seconds of instrumental piano
37
37
  seedaudiocli task wait TASK_ID --download ./audio
38
38
  ```
39
39
 
40
- Actual generation can incur provider charges. Save the returned task ID and recover it instead of submitting again. Unknown submission outcomes require provider/history inspection. Reference modes and parameter limits are documented by `generate --help` and the bundled Skill.
40
+ Actual generation can incur provider charges. The official API returns audio synchronously. The CLI records a local ID before its single POST, then saves the response and audio privately. `task get`, `task wait` and `task download` recover saved local results without network calls. There is no remote task polling. A timeout or interrupted submission is marked unknown; inspect its local record and provider billing evidence before explicitly authorizing another attempt with `--force`. A repeated successful request reuses the cached result.
41
+
42
+ The fixed endpoint is `https://openspeech.bytedance.com/api/v3/tts/create`, using `X-Api-Key` and model `seed-audio-1.0`. Prompts accept 1–3000 characters. Use `--audio-file` for local recordings or `--audio-reference` for preset speakers/public URLs (up to three total); address them with `@音频1` through `@音频3`. Use one `--image-file` or `--image-url` for image guidance. Local files are encoded directly in the official request. References are limited to 10 MiB each; audio must be at most 30 seconds. Audio and image modes are exclusive. `--speech-rate` and `--loudness-rate` are integers from -50 to 100 (0 unchanged), and `--pitch-rate` is -12 to 12. `--subtitle` saves returned subtitle data next to the audio.
43
+
44
+ Outputs support mp3, wav, pcm and ogg_opus. Select compatible sample rates with the CLI help: mp3 excludes 40000 Hz and ogg_opus requires 48000 Hz. Output files are never silently overwritten. Old history remains on disk; official records are stored separately.
45
+
46
+ Protocol reference: [Volcengine Seed Audio HTTP documentation](https://docs.volcengine.com/docs/DoubaoVoice/audio-generation-http?lang=zh).
41
47
 
42
48
  ## Agent installation
43
49
 
package/capabilities.json CHANGED
@@ -20,6 +20,7 @@
20
20
  "task get",
21
21
  "task wait",
22
22
  "task url",
23
+ "task download",
23
24
  "task jobs",
24
25
  "payload validate",
25
26
  "payload submit",
@@ -50,7 +51,8 @@
50
51
  "import": "seedaudiocli config import FILE [--replace]",
51
52
  "side_effects": "Local file write, backup on replacement; no remote call",
52
53
  "missing": "CONFIG_REQUIRED",
53
- "secrets_in_output": false
54
+ "secrets_in_output": false,
55
+ "migration": "Import official configuration with --replace; backup is automatic; relay endpoints/models are rejected."
54
56
  },
55
57
  "update": {
56
58
  "trigger": "before-every-run",
@@ -64,10 +66,15 @@
64
66
  "managed_skills": "synchronized with backups; edited content preserved"
65
67
  },
66
68
  "generation": {
67
- "provider": "EvoLink",
68
- "model": "doubao-seed-audio-1-0",
69
+ "provider": "Volcengine",
70
+ "endpoint": "https://openspeech.bytedance.com/api/v3/tts/create",
71
+ "authentication": "X-Api-Key (Doubao Speech)",
72
+ "model": "seed-audio-1.0",
73
+ "mode": "synchronous",
69
74
  "paid": true,
70
75
  "submit_retry": false,
71
- "recovery": "Use saved task ID; unknown submit outcome requires inspection before resubmission"
76
+ "local_references": true,
77
+ "remote_polling": false,
78
+ "recovery": "Persist local record before POST; save response and audio; export locally by record ID. Unknown outcomes require inspection and explicit authorization before --force."
72
79
  }
73
80
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@petercjl/seedaudiocli",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Seed Audio CLI with portable Agent skill and persistent user credentials",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +1,9 @@
1
- __version__ = "0.1.0"
1
+ """Version follows the canonical npm package metadata."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+ try:
6
+ __version__ = json.loads((Path(__file__).resolve().parent.parent / "package.json").read_text(encoding="utf-8"))["version"]
7
+ except (OSError, ValueError, KeyError):
8
+ from importlib.metadata import version
9
+ __version__ = version("seedaudiocli")
@@ -4,12 +4,15 @@ import argparse
4
4
  import json
5
5
  import os
6
6
  import sys
7
+ import time
8
+ import uuid
9
+ import hashlib
7
10
  from pathlib import Path
8
11
  from types import SimpleNamespace
9
12
  from typing import Any
10
13
 
11
14
  from . import __version__
12
- from . import evolink
15
+ from . import official
13
16
  from .config import (
14
17
  DEFAULT_BASE_URL,
15
18
  DEFAULT_MODEL,
@@ -22,7 +25,7 @@ from .config import (
22
25
  import_config,
23
26
  ConfigError,
24
27
  )
25
- from .jobs import append_job, append_status, find_by_hash, read_jobs
28
+ from .jobs import root, job_dir, save_record, read_job, write_json, recover_audio, export_audio, find_by_hash, read_jobs, validate_output_name
26
29
  from .payloads import build_payload, read_prompt, request_hash, validate_payload
27
30
 
28
31
 
@@ -31,20 +34,19 @@ def print_json(data: Any) -> None:
31
34
 
32
35
 
33
36
  def add_generation_args(parser: argparse.ArgumentParser) -> None:
34
- parser.add_argument("--prompt", help="Prompt text, max 1500 characters.")
37
+ parser.add_argument("--prompt", help="Prompt text, max 3000 characters.")
35
38
  parser.add_argument("--prompt-file", help="Path to a UTF-8 prompt text file.")
36
- parser.add_argument("--model", help="Override model id. Default: doubao-seed-audio-1-0.")
39
+ parser.add_argument("--model", help="Override model id. Default: seed-audio-1.0.")
37
40
  parser.add_argument("--format", choices=["wav", "mp3", "pcm", "ogg_opus"], help="Output audio format.")
38
- parser.add_argument("--sample-rate", type=int, choices=[8000, 16000, 24000, 32000, 44100, 48000])
39
- parser.add_argument("--speech-rate", type=float, help="Speech-rate multiplier from 0.5 to 2.0.")
40
- parser.add_argument("--loudness-rate", type=float, help="Loudness multiplier from 0.5 to 2.0.")
41
+ parser.add_argument("--sample-rate", type=int, choices=[8000, 16000, 24000, 32000, 40000, 44100, 48000])
42
+ parser.add_argument("--speech-rate", type=int, help="Speech-rate adjustment from -50 to 100; 0 is unchanged.")
43
+ parser.add_argument("--loudness-rate", type=int, help="Loudness adjustment from -50 to 100; 0 is unchanged.")
41
44
  parser.add_argument("--pitch-rate", type=int, help="Pitch adjustment in semitones from -12 to 12.")
42
- parser.add_argument("--callback-url", help="HTTPS callback URL for task completion.")
45
+ parser.add_argument("--subtitle", action="store_true", help="Return timestamped subtitles when supported.")
43
46
  parser.add_argument("--dry-run", action="store_true", help="Print payload without creating a task.")
44
47
  parser.add_argument("--force", action="store_true", help="Create a new task even if the same request hash exists.")
45
- parser.add_argument("--wait", action="store_true", help="Wait until the task reaches a terminal state.")
46
- parser.add_argument("--interval", type=int, default=5, help="Polling interval in seconds for --wait.")
47
- parser.add_argument("--timeout", type=int, default=1800, help="Polling timeout in seconds for --wait.")
48
+ parser.add_argument("--wait", action="store_true", help="Compatibility flag: official generation always waits for its response.")
49
+ parser.add_argument("--timeout", type=int, default=300, help="HTTP request timeout in seconds; a timeout never resubmits.")
48
50
  parser.add_argument("--download", help="Download completed audio to this directory. Implies --wait.")
49
51
  parser.add_argument("--output-name", help="Filename stem for downloaded audio. Defaults to task id.")
50
52
 
@@ -63,70 +65,69 @@ def generation_payload_from_args(args: argparse.Namespace) -> dict[str, Any]:
63
65
  speech_rate=args.speech_rate,
64
66
  loudness_rate=args.loudness_rate,
65
67
  pitch_rate=args.pitch_rate,
66
- callback_url=args.callback_url,
68
+ enable_subtitle=args.subtitle,
69
+ audio_files=list(getattr(args, "audio_file", None) or []),
70
+ image_files=list(getattr(args, "image_file", None) or []),
67
71
  )
68
72
 
69
73
 
70
- def download_from_response(response: dict[str, Any], out_dir: Path, output_name: str | None = None) -> list[str]:
71
- urls = evolink.extract_audio_urls(response)
72
- if not urls:
73
- return []
74
- formats: list[str | None] = []
75
- for item in response.get("result_data") or []:
76
- if isinstance(item, dict):
77
- formats.append(item.get("format"))
78
- out_dir.mkdir(parents=True, exist_ok=True)
79
- task_id = str(response.get("id") or "audio")
80
- downloaded: list[str] = []
81
- for idx, url in enumerate(urls, 1):
82
- result_format = formats[idx - 1] if idx - 1 < len(formats) else None
83
- ext = {"ogg_opus": "ogg", "pcm": "pcm"}.get(str(result_format), str(result_format or "")) or evolink.infer_extension(url)
84
- stem = output_name or task_id
85
- suffix = "" if len(urls) == 1 else f"_{idx}"
86
- out_path = out_dir / f"{stem}{suffix}.{ext}"
87
- evolink.download_url(url, out_path)
88
- downloaded.append(str(out_path))
89
- return downloaded
90
-
91
-
92
- def submit_or_print(payload: dict[str, Any], args: argparse.Namespace) -> int:
74
+ def submit_or_print(payload, args):
93
75
  errors = validate_payload(payload)
94
76
  h = request_hash(payload)
95
77
  if errors:
96
- result: dict[str, Any] = {"ok": False, "errors": errors}
97
- if args.dry_run:
98
- result["payload"] = payload
99
- result["request_hash"] = h
100
- print_json(result)
78
+ print_json({"ok": False, "code": "PAYLOAD_INVALID", "errors": errors})
101
79
  return 2
80
+ if args.timeout <= 0:
81
+ raise ValueError("timeout must be positive.")
82
+ validate_output_name(args.output_name)
83
+ if args.download and Path(args.download).expanduser().exists() and not Path(args.download).expanduser().is_dir():
84
+ raise ValueError("download destination must be a directory.")
102
85
  if args.dry_run:
103
- print_json({"ok": True, "dry_run": True, "request_hash": h, "payload": payload})
104
- return 0
105
-
106
- existing = find_by_hash(h)
107
- if existing and not args.force:
108
- print_json({"ok": True, "deduped": True, "request_hash": h, "job": existing})
86
+ print_json({"ok": True, "dry_run": True, "request_hash": h, "payload": payload,
87
+ "provider": "Volcengine", "endpoint": DEFAULT_BASE_URL + "/api/v3/tts/create"})
109
88
  return 0
110
-
111
- response = evolink.create_task(payload)
112
- task_id = evolink.extract_task_id(response)
113
- append_job({"event": "create", "request_hash": h, "task_id": task_id, "payload": payload, "response": response})
114
-
115
- result: dict[str, Any] = {"ok": True, "request_hash": h, "task_id": task_id, "response": response}
116
- should_wait = args.wait or bool(args.download)
117
- if should_wait and task_id:
118
- final = evolink.wait_for_task(task_id, interval=args.interval, timeout=args.timeout)
119
- status = evolink.extract_status(final)
120
- append_status(task_id, status, final)
121
- result["final_status"] = status
122
- result["final_response"] = final
89
+ seedaudio_base_url()
90
+ key = seedaudio_api_key()
91
+ # Account-scoped deduplication avoids reusing another credential's result.
92
+ h = hashlib.sha256((h + (key or "")).encode()).hexdigest()
93
+ lock = root() / (h + ".lock")
94
+ try:
95
+ fd = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
96
+ except FileExistsError:
97
+ raise official.SeedAudioError("An identical request is in progress or was interrupted. Inspect task jobs before clearing its local lock.", "REQUEST_BUSY") from None
98
+ os.close(fd)
99
+ try:
100
+ existing = find_by_hash(h)
101
+ if existing and not args.force and existing["status"] != "failed":
102
+ record = recover_audio(existing)
103
+ if record["status"] != "completed":
104
+ raise official.SeedAudioError("Previous generation outcome is unknown. Inspect the local record; a new paid attempt requires explicit authorization and --force.", "SUBMISSION_UNKNOWN", details={"task_id": record["task_id"]})
105
+ result = {"ok": True, "deduped": True, "task_id": record["task_id"], "job": record}
106
+ else:
107
+ task_id = str(uuid.uuid4())
108
+ job_dir(task_id).mkdir(mode=0o700)
109
+ record = {"task_id": task_id, "request_id": task_id, "request_hash": h, "provider": "Volcengine",
110
+ "status": "submitting", "created_at": time.time(), "format": payload.get("audio_config", {}).get("format", "wav")}
111
+ save_record(record)
112
+ # Persist the local ID before sending the only billable request.
113
+ print(json.dumps({"event": "submitting", "task_id": task_id}), file=sys.stderr, flush=True)
114
+ try:
115
+ response = official.create_audio(payload, task_id, args.timeout)
116
+ write_json(job_dir(task_id) / "response.json", response)
117
+ record = recover_audio(record)
118
+ except official.SeedAudioError as e:
119
+ record.update(status="unknown" if e.code in {"SUBMISSION_UNKNOWN", "OUTPUT_CONTRACT_FAILED"} else "failed",
120
+ error_code=e.code, details=e.details)
121
+ save_record(record)
122
+ e.details.setdefault("task_id", task_id)
123
+ raise
124
+ result = {"ok": True, "task_id": task_id, "job": record}
123
125
  if args.download:
124
- downloaded = download_from_response(final, Path(args.download).expanduser(), args.output_name)
125
- result["downloaded_to"] = downloaded
126
- print_json(result)
127
- if result.get("final_status") and result["final_status"] != "completed":
128
- return 2
129
- return 0
126
+ result["downloaded_to"] = export_audio(record, args.download, args.output_name)
127
+ print_json(result)
128
+ return 0
129
+ finally:
130
+ lock.unlink()
130
131
 
131
132
 
132
133
  def cmd_config(args: argparse.Namespace) -> int:
@@ -139,7 +140,6 @@ def cmd_config(args: argparse.Namespace) -> int:
139
140
  if args.config_cmd == "init":
140
141
  path = write_default_env(
141
142
  import_current_env=args.import_env,
142
- import_key_file=args.import_key_file,
143
143
  overwrite=args.overwrite,
144
144
  )
145
145
  print(f"Wrote config: {path}")
@@ -193,45 +193,37 @@ def cmd_models(_: argparse.Namespace) -> int:
193
193
  "default": DEFAULT_MODEL,
194
194
  "configured": seedaudio_model(),
195
195
  "base_url_default": DEFAULT_BASE_URL,
196
- "notes": "EvoLink currently exposes doubao-seed-audio-1-0 for this CLI.",
196
+ "notes": "Official Doubao Speech Seed Audio; synchronous HTTP response with local result recovery.",
197
197
  }
198
198
  )
199
199
  return 0
200
200
 
201
201
 
202
202
  def cmd_generate(args: argparse.Namespace) -> int:
203
+ if args.generate_cmd == "audio-ref" and not (args.audio_reference or args.audio_file):
204
+ raise ValueError("audio-ref requires --audio-reference or --audio-file.")
205
+ if args.generate_cmd == "image-ref" and not (args.image_url or args.image_file):
206
+ raise ValueError("image-ref requires --image-url or --image-file.")
203
207
  payload = generation_payload_from_args(args)
204
208
  return submit_or_print(payload, args)
205
209
 
206
210
 
207
- def cmd_task(args: argparse.Namespace) -> int:
208
- if args.task_cmd == "get":
209
- response = evolink.get_task(args.task_id)
210
- append_status(args.task_id, evolink.extract_status(response), response)
211
- print_json(response)
212
- return 0
213
- if args.task_cmd == "wait":
214
- response = evolink.wait_for_task(args.task_id, interval=args.interval, timeout=args.timeout)
215
- status = evolink.extract_status(response)
216
- append_status(args.task_id, status, response)
217
- result: dict[str, Any] = {"task_id": args.task_id, "status": status, "response": response}
218
- if args.download:
219
- result["downloaded_to"] = download_from_response(response, Path(args.download).expanduser(), args.output_name)
220
- print_json(result)
221
- return 0 if status == "completed" else 2
222
- if args.task_cmd == "url":
223
- response = evolink.get_task(args.task_id)
224
- status = evolink.extract_status(response)
225
- append_status(args.task_id, status, response)
226
- result: dict[str, Any] = {"task_id": args.task_id, "status": status, "urls": evolink.extract_audio_urls(response)}
227
- if args.include_response:
228
- result["response"] = response
229
- print_json(result)
230
- return 0 if result["urls"] else 2
211
+ def cmd_task(args):
231
212
  if args.task_cmd == "jobs":
232
- print_json({"job_store": str(job_store_path()), "jobs": read_jobs()[-args.limit :]})
213
+ if args.limit < 1:
214
+ raise ValueError("limit must be positive.")
215
+ print_json({"ok": True, "job_store": str(root()), "jobs": read_jobs()[-args.limit:]})
233
216
  return 0
234
- raise SystemExit("unknown task command")
217
+ record = recover_audio(read_job(args.task_id))
218
+ result = {"ok": record["status"] == "completed", "task_id": args.task_id, "job": record,
219
+ "remote_polling": False}
220
+ if args.task_cmd in {"download", "wait"} and args.download:
221
+ result["downloaded_to"] = export_audio(record, args.download, args.output_name)
222
+ if args.task_cmd == "url":
223
+ result["urls"] = [record["url"]] if record.get("url") else []
224
+ result["note"] = "Provider URL may expire; cached local audio remains available."
225
+ print_json(result)
226
+ return 0 if result["ok"] else 2
235
227
 
236
228
 
237
229
  def cmd_payload(args: argparse.Namespace) -> int:
@@ -248,7 +240,6 @@ def cmd_payload(args: argparse.Namespace) -> int:
248
240
  dry_run=False,
249
241
  force=args.force,
250
242
  wait=args.wait,
251
- interval=args.interval,
252
243
  timeout=args.timeout,
253
244
  download=args.download,
254
245
  output_name=args.output_name,
@@ -258,7 +249,7 @@ def cmd_payload(args: argparse.Namespace) -> int:
258
249
 
259
250
 
260
251
  def build_parser() -> argparse.ArgumentParser:
261
- parser = argparse.ArgumentParser(prog="seedaudiocli", description="System CLI for EvoLink Doubao Seed-Audio 1.0 generation.")
252
+ parser = argparse.ArgumentParser(prog="seedaudiocli", description="Official Volcengine Seed Audio generation and local result recovery.")
262
253
  parser.add_argument("--version", action="version", version=f"seedaudiocli {__version__}")
263
254
  sub = parser.add_subparsers(dest="cmd", required=True)
264
255
 
@@ -275,7 +266,6 @@ def build_parser() -> argparse.ArgumentParser:
275
266
  config_import.add_argument("--replace", action="store_true", help="Back up existing configuration before replacement.")
276
267
  config_init = config_sub.add_parser("init", help="Create env config file.")
277
268
  config_init.add_argument("--import-env", action="store_true", help="Copy current SEEDAUDIO_* values into the env file.")
278
- config_init.add_argument("--import-key-file", help="Import Seed-Audio EvoLink key block from a local note file.")
279
269
  config_init.add_argument("--overwrite", action="store_true", help="Overwrite existing env file.")
280
270
  config.set_defaults(func=cmd_config)
281
271
 
@@ -299,28 +289,34 @@ def build_parser() -> argparse.ArgumentParser:
299
289
 
300
290
  gen_audio = gen_sub.add_parser("audio-ref", help="Reference-audio or preset-voice generation.")
301
291
  add_generation_args(gen_audio)
302
- gen_audio.add_argument("--audio-reference", action="append", required=True, help="Preset voice_type or reference-audio URL. Repeat up to 3 times.")
292
+ gen_audio.add_argument("--audio-reference", action="append", help="Preset speaker ID or audio URL; reference order precedes audio-file entries.")
293
+ gen_audio.add_argument("--audio-file", action="append", help="Local reference recording up to 10 MiB and 30 seconds; repeat up to 3 total references.")
303
294
  gen_audio.set_defaults(func=cmd_generate)
304
295
 
305
296
  gen_image = gen_sub.add_parser("image-ref", help="Reference-image-guided audio generation.")
306
297
  add_generation_args(gen_image)
307
- gen_image.add_argument("--image-url", action="append", required=True, help="Public jpeg/png/webp image URL. Exactly one is supported by the API.")
298
+ gen_image.add_argument("--image-url", action="append", help="Public jpeg/png/webp URL; exactly one image reference.")
299
+ gen_image.add_argument("--image-file", action="append", help="Local jpeg/png/webp image up to 10 MiB.")
308
300
  gen_image.set_defaults(func=cmd_generate)
309
301
 
310
- task = sub.add_parser("task", help="Task status, wait, URLs, and history.")
302
+ task = sub.add_parser("task", help="Local official generation records and cached audio; no remote polling.")
311
303
  task_sub = task.add_subparsers(dest="task_cmd", required=True)
312
- task_get = task_sub.add_parser("get", help="Fetch raw task JSON.")
304
+ task_get = task_sub.add_parser("get", help="Read a local generation record.")
313
305
  task_get.add_argument("task_id")
314
306
  task_get.set_defaults(func=cmd_task)
315
307
 
316
- task_wait = task_sub.add_parser("wait", help="Wait for task completion.")
308
+ task_wait = task_sub.add_parser("wait", help="Recover a saved response locally (compatibility alias).")
317
309
  task_wait.add_argument("task_id")
318
- task_wait.add_argument("--interval", type=int, default=5)
319
- task_wait.add_argument("--timeout", type=int, default=1800)
320
310
  task_wait.add_argument("--download", help="Download completed audio to this directory.")
321
311
  task_wait.add_argument("--output-name", help="Filename stem for downloaded audio.")
322
312
  task_wait.set_defaults(func=cmd_task)
323
313
 
314
+ task_download = task_sub.add_parser("download", help="Export cached audio without a new generation.")
315
+ task_download.add_argument("task_id")
316
+ task_download.add_argument("--download", required=True)
317
+ task_download.add_argument("--output-name")
318
+ task_download.set_defaults(func=cmd_task)
319
+
324
320
  task_url = task_sub.add_parser("url", help="Print completed audio URLs.")
325
321
  task_url.add_argument("task_id")
326
322
  task_url.add_argument("--include-response", action="store_true")
@@ -342,8 +338,7 @@ def build_parser() -> argparse.ArgumentParser:
342
338
  payload_submit.add_argument("file")
343
339
  payload_submit.add_argument("--force", action="store_true")
344
340
  payload_submit.add_argument("--wait", action="store_true")
345
- payload_submit.add_argument("--interval", type=int, default=5)
346
- payload_submit.add_argument("--timeout", type=int, default=1800)
341
+ payload_submit.add_argument("--timeout", type=int, default=300)
347
342
  payload_submit.add_argument("--download")
348
343
  payload_submit.add_argument("--output-name")
349
344
  payload_submit.set_defaults(func=cmd_payload)
@@ -356,7 +351,6 @@ def main(argv: list[str] | None = None) -> int:
356
351
  args = parser.parse_args(argv)
357
352
  try:
358
353
  network = ((args.cmd == "generate" and not args.dry_run)
359
- or (args.cmd == "task" and args.task_cmd != "jobs")
360
354
  or (args.cmd == "payload" and args.payload_cmd == "submit"))
361
355
  if network and not seedaudio_api_key():
362
356
  if sys.stdin.isatty():
@@ -367,7 +361,7 @@ def main(argv: list[str] | None = None) -> int:
367
361
  except ConfigError as e:
368
362
  print_json({"ok": False, "code": e.code, "error": str(e)})
369
363
  return 2
370
- except (ValueError, evolink.SeedAudioError) as e:
364
+ except (ValueError, official.SeedAudioError) as e:
371
365
  message = str(e)
372
366
  try:
373
367
  key = seedaudio_api_key()
@@ -375,7 +369,7 @@ def main(argv: list[str] | None = None) -> int:
375
369
  message = message.replace(key, "[REDACTED]")
376
370
  except ConfigError:
377
371
  pass
378
- print_json({"ok": False, "code": getattr(e, "code", "REQUEST_FAILED"), "error": message})
372
+ print_json({"ok": False, "code": getattr(e, "code", "REQUEST_FAILED"), "error": message, "details": getattr(e, "details", {})})
379
373
  return 1
380
374
  except (OSError, EOFError, KeyboardInterrupt):
381
375
  print_json({"ok": False, "code": "LOCAL_OPERATION_FAILED", "error": "Local operation failed or was cancelled."})
@@ -10,8 +10,8 @@ from urllib.parse import urlparse
10
10
 
11
11
 
12
12
  APP_NAME = "seedaudiocli"
13
- DEFAULT_BASE_URL = "https://api.evolink.ai"
14
- DEFAULT_MODEL = "doubao-seed-audio-1-0"
13
+ DEFAULT_BASE_URL = "https://openspeech.bytedance.com"
14
+ DEFAULT_MODEL = "seed-audio-1.0"
15
15
  DEFAULT_FORMAT = "mp3"
16
16
  DEFAULT_SAMPLE_RATE = "44100"
17
17
 
@@ -69,12 +69,14 @@ def validate_config(values: dict[str, str], require_key: bool = True) -> dict[st
69
69
  valid_url = False
70
70
  if not valid_url:
71
71
  raise ConfigError("CONFIG_INVALID", "API base URL must be HTTPS without credentials, query or fragment.")
72
+ if base.rstrip("/") != DEFAULT_BASE_URL:
73
+ raise ConfigError("CONFIG_MIGRATION_REQUIRED", "Import an official Doubao Speech configuration; this version only connects to openspeech.bytedance.com.")
72
74
  if values.get("SEEDAUDIO_DEFAULT_FORMAT", DEFAULT_FORMAT) not in {"mp3", "wav", "pcm", "ogg_opus"}:
73
75
  raise ConfigError("CONFIG_INVALID", "Unsupported audio format.")
74
- if values.get("SEEDAUDIO_DEFAULT_SAMPLE_RATE", DEFAULT_SAMPLE_RATE) not in {"8000", "16000", "24000", "32000", "44100", "48000"}:
76
+ if values.get("SEEDAUDIO_DEFAULT_SAMPLE_RATE", DEFAULT_SAMPLE_RATE) not in {"8000", "16000", "24000", "32000", "40000", "44100", "48000"}:
75
77
  raise ConfigError("CONFIG_INVALID", "Unsupported sample rate.")
76
- if not values.get("SEEDAUDIO_MODEL", DEFAULT_MODEL).strip():
77
- raise ConfigError("CONFIG_INVALID", "Model must not be empty.")
78
+ if values.get("SEEDAUDIO_MODEL", DEFAULT_MODEL) != DEFAULT_MODEL:
79
+ raise ConfigError("CONFIG_MIGRATION_REQUIRED", "Import an official configuration using model seed-audio-1.0.")
78
80
  return {**values, "SEEDAUDIO_API_KEY": key}
79
81
 
80
82
 
@@ -184,18 +186,17 @@ def seedaudio_api_key() -> str | None:
184
186
 
185
187
 
186
188
  def seedaudio_base_url() -> str:
187
- raw = get_setting("SEEDAUDIO_BASE_URL", DEFAULT_BASE_URL) or DEFAULT_BASE_URL
188
- raw = raw.rstrip("/")
189
- suffix = "/v1/audios/generations"
190
- if raw.endswith(suffix):
191
- raw = raw[: -len(suffix)]
192
- if raw.endswith("/v1"):
193
- raw = raw[: -len("/v1")]
194
- return raw.rstrip("/")
189
+ raw = (get_setting("SEEDAUDIO_BASE_URL", DEFAULT_BASE_URL) or DEFAULT_BASE_URL).rstrip("/")
190
+ if raw != DEFAULT_BASE_URL:
191
+ raise ConfigError("CONFIG_MIGRATION_REQUIRED", "Only the official Doubao Speech endpoint is supported; import the official configuration.")
192
+ return raw
195
193
 
196
194
 
197
195
  def seedaudio_model() -> str:
198
- return get_setting("SEEDAUDIO_MODEL", DEFAULT_MODEL) or DEFAULT_MODEL
196
+ model = get_setting("SEEDAUDIO_MODEL", DEFAULT_MODEL) or DEFAULT_MODEL
197
+ if model != DEFAULT_MODEL:
198
+ raise ConfigError("CONFIG_MIGRATION_REQUIRED", "The configured model must be seed-audio-1.0.")
199
+ return model
199
200
 
200
201
 
201
202
  def default_format() -> str:
@@ -207,33 +208,8 @@ def default_sample_rate() -> int:
207
208
  return int(value)
208
209
 
209
210
 
210
- def parse_evolink_seed_audio_file(path: Path) -> tuple[str | None, str | None]:
211
- if not path.exists():
212
- return None, None
213
- text = path.read_text(errors="ignore")
214
- in_seed = False
215
- api_key: str | None = None
216
- base_url: str | None = None
217
- for raw in text.splitlines():
218
- line = raw.strip()
219
- low = line.lower()
220
- if "seed-audio" in low and "evolink" in low:
221
- in_seed = True
222
- continue
223
- if not in_seed:
224
- continue
225
- norm = line.replace(":", ":")
226
- if norm.lower().startswith("base url") and ":" in norm:
227
- base_url = norm.split(":", 1)[1].strip()
228
- elif norm.lower().startswith("api key") and ":" in norm:
229
- api_key = norm.split(":", 1)[1].strip()
230
- break
231
- return api_key, base_url
232
-
233
-
234
211
  def write_default_env(
235
212
  import_current_env: bool = False,
236
- import_key_file: str | None = None,
237
213
  overwrite: bool = False,
238
214
  ) -> Path:
239
215
  path = config_path()
@@ -251,11 +227,6 @@ def write_default_env(
251
227
  else DEFAULT_SAMPLE_RATE
252
228
  )
253
229
 
254
- if import_key_file:
255
- key, base = parse_evolink_seed_audio_file(Path(import_key_file).expanduser())
256
- current_key = key or current_key
257
- current_base = base or current_base
258
-
259
230
  save_config({"SEEDAUDIO_API_KEY": current_key, "SEEDAUDIO_BASE_URL": current_base,
260
231
  "SEEDAUDIO_MODEL": current_model, "SEEDAUDIO_DEFAULT_FORMAT": current_format,
261
232
  "SEEDAUDIO_DEFAULT_SAMPLE_RATE": current_sample_rate}, replace=overwrite, require_key=False)
@@ -1,46 +1,134 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import tempfile
4
9
  import time
5
10
  from pathlib import Path
6
- from typing import Any
7
11
 
8
- from .config import job_store_path
12
+ from .config import state_dir
13
+ from .official import SeedAudioError
9
14
 
10
15
 
11
- TERMINAL_STATES = {"completed", "failed", "cancelled", "canceled", "expired"}
16
+ def root():
17
+ p = state_dir() / "official"
18
+ p.mkdir(parents=True, exist_ok=True, mode=0o700)
19
+ return p
12
20
 
13
21
 
14
- def append_job(record: dict[str, Any], path: Path | None = None) -> None:
15
- path = path or job_store_path()
16
- path.parent.mkdir(parents=True, exist_ok=True)
17
- record = {"created_at": int(time.time()), **record}
18
- with path.open("a", encoding="utf-8") as f:
19
- f.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
22
+ def job_dir(task_id):
23
+ if not re.fullmatch(r"[a-f0-9-]{36}", task_id):
24
+ raise SeedAudioError("Expected a local official generation record ID.", "TASK_NOT_FOUND")
25
+ return root() / task_id
20
26
 
21
27
 
22
- def read_jobs(path: Path | None = None) -> list[dict[str, Any]]:
23
- paths = [path or job_store_path()]
24
- jobs: list[dict[str, Any]] = []
25
- for read_path in paths:
26
- if not read_path.exists():
28
+ def write_json(path, value):
29
+ fd, tmp = tempfile.mkstemp(prefix=".record-", dir=path.parent)
30
+ try:
31
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
32
+ json.dump(value, f, ensure_ascii=False)
33
+ f.flush()
34
+ os.fsync(f.fileno())
35
+ os.replace(tmp, path)
36
+ os.chmod(path, 0o600)
37
+ finally:
38
+ if os.path.exists(tmp):
39
+ os.unlink(tmp)
40
+
41
+
42
+ def save_record(record):
43
+ write_json(job_dir(record["task_id"]) / "record.json", record)
44
+
45
+
46
+ def read_job(task_id):
47
+ p = job_dir(task_id) / "record.json"
48
+ if not p.is_file():
49
+ raise SeedAudioError("Local official generation record not found.", "TASK_NOT_FOUND")
50
+ return json.loads(p.read_text(encoding="utf-8"))
51
+
52
+
53
+ def read_jobs():
54
+ jobs = []
55
+ for p in root().glob("*/record.json"):
56
+ try:
57
+ jobs.append(json.loads(p.read_text(encoding="utf-8")))
58
+ except (ValueError, OSError):
27
59
  continue
28
- for line in read_path.read_text(encoding="utf-8").splitlines():
29
- if not line.strip():
30
- continue
31
- try:
32
- jobs.append(json.loads(line))
33
- except json.JSONDecodeError:
34
- continue
35
- return jobs
36
-
37
-
38
- def find_by_hash(request_hash: str) -> dict[str, Any] | None:
39
- for job in reversed(read_jobs()):
40
- if job.get("request_hash") == request_hash and job.get("task_id"):
41
- return job
60
+ return sorted(jobs, key=lambda x: x.get("created_at", 0))
61
+
62
+
63
+ def find_by_hash(h):
64
+ for record in reversed(read_jobs()):
65
+ if record.get("request_hash") == h:
66
+ return record
42
67
  return None
43
68
 
44
69
 
45
- def append_status(task_id: str, status: str, response: dict[str, Any] | None = None) -> None:
46
- append_job({"event": "status", "task_id": task_id, "status": status, "response": response or {}})
70
+ def recover_audio(record):
71
+ d = job_dir(record["task_id"])
72
+ response_path = d / "response.json"
73
+ if not response_path.is_file():
74
+ return record
75
+ # Raw response is saved before decoding/export, so local failures are recoverable.
76
+ response = json.loads(response_path.read_text(encoding="utf-8"))
77
+ try:
78
+ audio = base64.b64decode(response.get("audio", ""), validate=True)
79
+ if not audio:
80
+ raise ValueError()
81
+ except (ValueError, TypeError):
82
+ raise SeedAudioError("No valid audio bytes in the saved response; do not resubmit automatically.", "OUTPUT_CONTRACT_FAILED") from None
83
+ fmt = record["format"]
84
+ p = d / ("audio." + ("ogg" if fmt == "ogg_opus" else fmt))
85
+ if p.exists():
86
+ if p.read_bytes() != audio:
87
+ raise SeedAudioError("Cached audio differs from the saved response.", "OUTPUT_EXISTS")
88
+ else:
89
+ fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
90
+ with os.fdopen(fd, "wb") as f:
91
+ f.write(audio)
92
+ record.update(status="completed", audio_path=str(p), bytes=len(audio),
93
+ duration=response.get("duration"), original_duration=response.get("original_duration"),
94
+ logid=response.get("logid"), url=response.get("url"))
95
+ if response.get("subtitle") is not None:
96
+ sp = d / "subtitle.json"
97
+ if not sp.exists():
98
+ write_json(sp, response["subtitle"])
99
+ record["subtitle_path"] = str(sp)
100
+ save_record(record)
101
+ return record
102
+
103
+
104
+ def validate_output_name(stem):
105
+ if stem and (stem in {".", ".."} or any(c in stem for c in '/\\<>:"|?*') or any(ord(c) < 32 for c in stem)):
106
+ raise ValueError("output-name must be a filename stem without path separators.")
107
+
108
+
109
+ def export_audio(record, destination, output_name=None):
110
+ record = recover_audio(record)
111
+ if record.get("status") != "completed":
112
+ raise SeedAudioError("No saved successful response. Official API has no remote task-query operation.", "SUBMISSION_UNKNOWN")
113
+ stem = output_name or record["task_id"]
114
+ if stem in {".", ".."} or any(c in stem for c in '/\\<>:"|?*') or any(ord(c) < 32 for c in stem):
115
+ raise ValueError("output-name must be a filename stem without path separators.")
116
+ out = Path(destination).expanduser().resolve()
117
+ out.mkdir(parents=True, exist_ok=True)
118
+ paths = []
119
+ for field, suffix in [("audio_path", Path(record["audio_path"]).suffix), ("subtitle_path", ".subtitle.json")]:
120
+ if field not in record:
121
+ continue
122
+ src = Path(record[field])
123
+ target = out / (stem + suffix)
124
+ if target.is_symlink():
125
+ raise SeedAudioError("Output target is a symlink.", "OUTPUT_EXISTS")
126
+ if target.exists():
127
+ if not target.is_file() or target.read_bytes() != src.read_bytes():
128
+ raise SeedAudioError("Output already exists; choose another output-name.", "OUTPUT_EXISTS")
129
+ else:
130
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
131
+ with os.fdopen(fd, "wb") as f, src.open("rb") as source:
132
+ shutil.copyfileobj(source, f)
133
+ paths.append(str(target))
134
+ return paths
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.request
6
+ from typing import Any
7
+
8
+ from .config import seedaudio_api_key, seedaudio_base_url
9
+
10
+
11
+ class SeedAudioError(RuntimeError):
12
+ def __init__(self, message: str, code: str = "PROVIDER_FAILURE", *, details=None):
13
+ super().__init__(message)
14
+ self.code = code
15
+ self.details = details or {}
16
+
17
+
18
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
19
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
20
+ return None
21
+
22
+
23
+ def create_audio(payload: dict[str, Any], request_id: str, timeout: int = 300) -> dict:
24
+ # Validate destination before reading/sending the credential. No POST retry.
25
+ url = seedaudio_base_url() + "/api/v3/tts/create"
26
+ key = seedaudio_api_key()
27
+ if not key:
28
+ raise SeedAudioError("Import a Doubao Speech configuration file.", "CONFIG_REQUIRED")
29
+ req = urllib.request.Request(url, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
30
+ headers={"X-Api-Key": key, "X-Api-Request-Id": request_id,
31
+ "Content-Type": "application/json"}, method="POST")
32
+ opener = urllib.request.build_opener(NoRedirect)
33
+ try:
34
+ with opener.open(req, timeout=timeout) as resp:
35
+ raw = resp.read()
36
+ logid = resp.headers.get("X-Tt-Logid")
37
+ except urllib.error.HTTPError as e:
38
+ logid = e.headers.get("X-Tt-Logid") if e.headers else None
39
+ try:
40
+ error = json.loads(e.read())
41
+ except (ValueError, OSError):
42
+ error = {}
43
+ if not isinstance(error, dict):
44
+ error = {}
45
+ code = "AUTH_REQUIRED" if e.code == 401 else "PERMISSION_REQUIRED" if e.code == 403 else "PROVIDER_FAILURE"
46
+ if e.code >= 500 or e.code == 408:
47
+ code = "SUBMISSION_UNKNOWN"
48
+ message = str(error.get("message", "Official service rejected the request.")).replace(key, "[REDACTED]")
49
+ raise SeedAudioError(message, code, details={"http_status": e.code,
50
+ "provider_code": error.get("code"), "logid": logid,
51
+ "request_id": request_id}) from None
52
+ except (urllib.error.URLError, TimeoutError, OSError):
53
+ raise SeedAudioError("Connection ended without a confirmed result. Do not automatically resubmit.",
54
+ "SUBMISSION_UNKNOWN", details={"request_id": request_id}) from None
55
+ try:
56
+ data = json.loads(raw)
57
+ if not isinstance(data, dict):
58
+ raise ValueError()
59
+ except (ValueError, UnicodeError):
60
+ raise SeedAudioError("Official service returned an unreadable result; generation outcome is unknown.",
61
+ "SUBMISSION_UNKNOWN", details={"request_id": request_id, "logid": logid}) from None
62
+ return {**data, "request_id": request_id, "logid": logid}
@@ -1,20 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  import hashlib
4
5
  import json
5
6
  from pathlib import Path
7
+ from urllib.parse import urlparse
6
8
  from typing import Any
7
9
 
8
10
  from .config import default_format, default_sample_rate, seedaudio_model
9
11
 
10
-
11
12
  FORMATS = {"wav", "mp3", "pcm", "ogg_opus"}
12
- SAMPLE_RATES = {8000, 16000, 24000, 32000, 44100, 48000}
13
+ SAMPLE_RATES = {8000, 16000, 24000, 32000, 40000, 44100, 48000}
13
14
 
14
15
 
15
- def read_prompt(prompt: str | None, prompt_file: str | None) -> str:
16
+ def read_prompt(prompt, prompt_file):
16
17
  if prompt and prompt_file:
17
- raise ValueError("Use either --prompt or --prompt-file, not both.")
18
+ raise ValueError("Use either --prompt or --prompt-file.")
18
19
  if prompt_file:
19
20
  return Path(prompt_file).expanduser().read_text(encoding="utf-8").strip()
20
21
  if prompt:
@@ -22,86 +23,107 @@ def read_prompt(prompt: str | None, prompt_file: str | None) -> str:
22
23
  raise ValueError("Missing --prompt or --prompt-file.")
23
24
 
24
25
 
25
- def build_payload(
26
- *,
27
- prompt: str,
28
- model: str | None = None,
29
- audio_references: list[str] | None = None,
30
- image_urls: list[str] | None = None,
31
- output_format: str | None = None,
32
- sample_rate: int | None = None,
33
- speech_rate: float | None = None,
34
- loudness_rate: float | None = None,
35
- pitch_rate: int | None = None,
36
- callback_url: str | None = None,
37
- ) -> dict[str, Any]:
38
- payload: dict[str, Any] = {
39
- "model": model or seedaudio_model(),
40
- "prompt": prompt,
41
- "format": output_format or default_format(),
42
- "sample_rate": sample_rate or default_sample_rate(),
43
- }
44
- if audio_references:
45
- payload["audio_references"] = audio_references
46
- if image_urls:
47
- payload["image_urls"] = image_urls
48
- if speech_rate is not None:
49
- payload["speech_rate"] = speech_rate
50
- if loudness_rate is not None:
51
- payload["loudness_rate"] = loudness_rate
52
- if pitch_rate is not None:
53
- payload["pitch_rate"] = pitch_rate
54
- if callback_url:
55
- payload["callback_url"] = callback_url
56
- return payload
57
-
58
-
59
- def validate_payload(payload: dict[str, Any]) -> list[str]:
60
- errors: list[str] = []
61
- prompt = payload.get("prompt")
62
- if not isinstance(prompt, str) or not prompt.strip():
63
- errors.append("prompt is required.")
64
- elif len(prompt) > 1500:
65
- errors.append("prompt must be <= 1500 characters.")
66
-
67
- if payload.get("model") != "doubao-seed-audio-1-0":
68
- errors.append("model must be doubao-seed-audio-1-0 unless the gateway adds another compatible model.")
69
-
70
- fmt = payload.get("format")
26
+ def local_reference(filename, kind):
27
+ p = Path(filename).expanduser()
28
+ allowed = {".wav", ".mp3", ".pcm", ".ogg", ".opus"} if kind == "audio" else {".jpg", ".jpeg", ".png", ".webp"}
29
+ if not p.is_file() or p.suffix.lower() not in allowed or not 0 < p.stat().st_size <= 10 * 1024 * 1024:
30
+ raise ValueError("Reference must be a supported non-empty file up to 10 MiB.")
31
+ return {kind + "_data": base64.b64encode(p.read_bytes()).decode("ascii")}
32
+
33
+
34
+ def build_payload(*, prompt, model=None, audio_references=None, image_urls=None,
35
+ audio_files=None, image_files=None, output_format=None, sample_rate=None,
36
+ speech_rate=None, loudness_rate=None, pitch_rate=None, enable_subtitle=False):
37
+ config = {"format": output_format or default_format(), "sample_rate": sample_rate if sample_rate is not None else default_sample_rate()}
38
+ for key, value in [("speech_rate", speech_rate), ("loudness_rate", loudness_rate), ("pitch_rate", pitch_rate)]:
39
+ if value is not None:
40
+ config[key] = value
41
+ if enable_subtitle:
42
+ config["enable_subtitle"] = True
43
+ refs = [{"audio_url" if r.startswith(("http://", "https://")) else "speaker": r} for r in audio_references or []]
44
+ refs += [local_reference(f, "audio") for f in audio_files or []]
45
+ refs += [{"image_url": u} for u in image_urls or []]
46
+ refs += [local_reference(f, "image") for f in image_files or []]
47
+ result = {"model": model or seedaudio_model(), "text_prompt": prompt, "audio_config": config}
48
+ if refs:
49
+ result["references"] = refs
50
+ return result
51
+
52
+
53
+ def validate_payload(payload):
54
+ if not isinstance(payload, dict):
55
+ return ["Payload must be a JSON object."]
56
+ errors = []
57
+ if set(payload) - {"model", "text_prompt", "references", "audio_config", "watermark"}:
58
+ errors.append("Unsupported top-level fields; use the official request schema.")
59
+ prompt = payload.get("text_prompt")
60
+ if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 3000:
61
+ errors.append("text_prompt must contain 1–3000 characters.")
62
+ if payload.get("model") != "seed-audio-1.0":
63
+ errors.append("model must be seed-audio-1.0.")
64
+ config = payload.get("audio_config", {})
65
+ if not isinstance(config, dict):
66
+ errors.append("audio_config must be an object.")
67
+ config = {}
68
+ if set(config) - {"format", "sample_rate", "speech_rate", "loudness_rate", "pitch_rate", "enable_subtitle"}:
69
+ errors.append("Unsupported audio_config fields.")
70
+ fmt = config.get("format", "wav")
71
+ rate = config.get("sample_rate", 40000 if fmt in {"wav", "pcm"} else 48000 if fmt == "ogg_opus" else 44100)
71
72
  if fmt not in FORMATS:
72
- errors.append(f"format must be one of {sorted(FORMATS)}.")
73
-
74
- sample_rate = payload.get("sample_rate")
75
- if sample_rate not in SAMPLE_RATES:
76
- errors.append(f"sample_rate must be one of {sorted(SAMPLE_RATES)}.")
77
-
78
- audio_refs = payload.get("audio_references")
79
- image_urls = payload.get("image_urls")
80
- if audio_refs and image_urls:
81
- errors.append("audio_references and image_urls are mutually exclusive.")
82
- if audio_refs is not None:
83
- if not isinstance(audio_refs, list):
84
- errors.append("audio_references must be a list.")
85
- elif len(audio_refs) > 3:
86
- errors.append("audio_references supports at most 3 items.")
87
- if image_urls is not None:
88
- if not isinstance(image_urls, list):
89
- errors.append("image_urls must be a list.")
90
- elif len(image_urls) > 1:
91
- errors.append("image_urls supports at most 1 item.")
92
-
93
- for key in ("speech_rate", "loudness_rate"):
94
- if key in payload:
95
- value = payload[key]
96
- if not isinstance(value, (int, float)) or value < 0.5 or value > 2:
97
- errors.append(f"{key} must be between 0.5 and 2.0.")
98
- if "pitch_rate" in payload:
99
- value = payload["pitch_rate"]
100
- if not isinstance(value, int) or value < -12 or value > 12:
101
- errors.append("pitch_rate must be an integer between -12 and 12.")
73
+ errors.append("Unsupported audio format.")
74
+ if type(rate) is not int or rate not in SAMPLE_RATES or (fmt == "mp3" and rate == 40000) or (fmt == "ogg_opus" and rate != 48000):
75
+ errors.append("sample_rate is incompatible with the output format.")
76
+ for name, low, high in [("speech_rate", -50, 100), ("loudness_rate", -50, 100), ("pitch_rate", -12, 12)]:
77
+ if name in config and (type(config[name]) is not int or not low <= config[name] <= high):
78
+ errors.append(f"{name} must be an integer in [{low}, {high}].")
79
+ if "enable_subtitle" in config and type(config["enable_subtitle"]) is not bool:
80
+ errors.append("enable_subtitle must be boolean.")
81
+ refs = payload.get("references", [])
82
+ if not isinstance(refs, list):
83
+ errors.append("references must be a list.")
84
+ refs = []
85
+ audio, image = 0, 0
86
+ for ref in refs:
87
+ if not isinstance(ref, dict) or len(ref) != 1:
88
+ errors.append("Each reference must contain exactly one source field.")
89
+ continue
90
+ key, value = next(iter(ref.items()))
91
+ if key not in {"speaker", "audio_data", "audio_url", "image_data", "image_url"} or not isinstance(value, str) or not value.strip():
92
+ errors.append("Unsupported or empty reference.")
93
+ continue
94
+ image += int(key.startswith("image"))
95
+ audio += int(not key.startswith("image"))
96
+ if key.endswith("_url"):
97
+ u = urlparse(value)
98
+ if u.scheme not in {"http", "https"} or not u.hostname or u.username or u.password:
99
+ errors.append("Reference URL must be an HTTP(S) URL without credentials.")
100
+ if key.endswith("_data"):
101
+ try:
102
+ size = len(base64.b64decode(value, validate=True))
103
+ if not 0 < size <= 10 * 1024 * 1024:
104
+ raise ValueError()
105
+ except ValueError:
106
+ errors.append("Reference base64 must decode to 1 byte–10 MiB.")
107
+ if audio and image:
108
+ errors.append("Audio and image references are mutually exclusive.")
109
+ if audio > 3 or image > 1:
110
+ errors.append("Use up to three audio references or one image reference.")
111
+ watermark = payload.get("watermark", {})
112
+ if not isinstance(watermark, dict):
113
+ errors.append("watermark must be an object.")
114
+ else:
115
+ if set(watermark) - {"aigc_watermark", "aigc_metadata"}:
116
+ errors.append("Unsupported watermark fields.")
117
+ if "aigc_watermark" in watermark and type(watermark["aigc_watermark"]) is not bool:
118
+ errors.append("aigc_watermark must be boolean.")
119
+ meta = watermark.get("aigc_metadata", {})
120
+ if not isinstance(meta, dict) or set(meta) - {"enable", "content_producer", "produce_id", "content_propagator", "propagate_id"}:
121
+ errors.append("Invalid aigc_metadata.")
122
+ elif any(type(v) is not bool if k == "enable" else not isinstance(v, str) for k, v in meta.items()):
123
+ errors.append("Invalid watermark metadata field type.")
102
124
  return errors
103
125
 
104
126
 
105
- def request_hash(payload: dict[str, Any]) -> str:
127
+ def request_hash(payload):
106
128
  data = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
107
- return hashlib.sha256(data).hexdigest()[:16]
129
+ return hashlib.sha256(data).hexdigest()
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "SEEDAUDIO_API_KEY": "",
3
- "SEEDAUDIO_BASE_URL": "https://api.evolink.ai",
4
- "SEEDAUDIO_MODEL": "doubao-seed-audio-1-0",
3
+ "SEEDAUDIO_BASE_URL": "https://openspeech.bytedance.com",
4
+ "SEEDAUDIO_MODEL": "seed-audio-1.0",
5
5
  "SEEDAUDIO_DEFAULT_FORMAT": "mp3",
6
6
  "SEEDAUDIO_DEFAULT_SAMPLE_RATE": "44100"
7
7
  }
@@ -4,7 +4,7 @@
4
4
  # This is a local-only file. Do not commit real API keys.
5
5
 
6
6
  SEEDAUDIO_API_KEY=
7
- SEEDAUDIO_BASE_URL=https://api.evolink.ai
8
- SEEDAUDIO_MODEL=doubao-seed-audio-1-0
7
+ SEEDAUDIO_BASE_URL=https://openspeech.bytedance.com
8
+ SEEDAUDIO_MODEL=seed-audio-1.0
9
9
  SEEDAUDIO_DEFAULT_FORMAT=mp3
10
10
  SEEDAUDIO_DEFAULT_SAMPLE_RATE=44100
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: seedaudiocli
3
- description: Generate audio through EvoLink Seed Audio using the seedaudiocli CLI; import configuration files, submit explicit audio requests, recover tasks, and download results.
3
+ description: Generate audio through official Volcengine Seed Audio using the seedaudiocli CLI; import configuration files, submit explicit audio requests, recover tasks, and download results.
4
4
  ---
5
5
 
6
6
  # Seed Audio CLI
@@ -10,7 +10,9 @@ Inputs are an explicit audio prompt, selected reference mode, output settings an
10
10
 
11
11
  ## Runtime and discovery
12
12
 
13
- Requires Node.js 22+, Python 3.9+ with SSL, network access to EvoLink, and an authorized API key. Run `seedaudiocli version`, `seedaudiocli capabilities --json` and `seedaudiocli doctor --json`. Resolve the canonical Skill with `seedaudiocli skill source --json`; the returned `source` directory belongs to the installed npm package.
13
+ Requires Node.js 22+, Python 3.9+ with SSL, network access to Doubao Speech, and an authorized API key. Run `seedaudiocli version`, `seedaudiocli capabilities --json` and `seedaudiocli doctor --json`. Resolve the canonical Skill with `seedaudiocli skill source --json`; the returned `source` directory belongs to the installed npm package.
14
+
15
+ When continuing an existing conversation or after a package update, load the current canonical `SKILL.md` and read the selected leaf command's `--help` (for example, `seedaudiocli generate text --help`). Build the invocation from that live contract; earlier conversation commands may belong to a different version.
14
16
 
15
17
  Use the current host's terminal execution capability. Its mapping is declared in [capabilities.json](capabilities.json) and the selected adapter: [Codex](adapters/codex.json) or [SealSeek](adapters/sealseek.json). Adapter mappings are implemented; host integration evidence is separate from CLI tests.
16
18
 
@@ -18,34 +20,44 @@ Use the current host's terminal execution capability. Its mapping is declared in
18
20
 
19
21
  1. Check `seedaudiocli auth status`. This checks local configuration only and does not prove the key is accepted remotely.
20
22
  2. If configuration is missing, ask the user for the local path to the administrator-provided JSON or env configuration file. Import with `seedaudiocli config import "FILE"`. Pass the file path to the CLI without displaying or reading its secret contents into the conversation. If configuration already exists, preserve it; an explicitly requested replacement uses `--replace`, which creates a backup. After import, return to step 1. Interactive terminal users can run `seedaudiocli setup`.
21
- 3. Preserve the caller's prompt, model, reference mode and output choices. Choose `generate text`, `generate audio-ref`, or `generate image-ref` according to those inputs. Prompts support at most 1500 characters; audio mode accepts up to three preset IDs or public audio URLs; image mode accepts exactly one public image URL. Local references need an authorized hosting step outside this CLI.
22
- 4. Validate the intended request with `--dry-run`. Validation must succeed before paid submission. Configuration import and dry-run do not submit generation jobs.
23
- 5. Execute the authorized request with `--wait --download "OUTPUT_DIRECTORY"` when local delivery is required. Save the returned task ID. Generation can incur service charges.
23
+ 3. Preserve the caller's prompt, model, reference mode and output choices. Choose `generate text`, `generate audio-ref`, or `generate image-ref` according to those inputs. Prompts support 1–3000 characters. Audio mode accepts up to three preset speaker IDs, public URLs or local recordings (each at most 30 seconds and 10 MiB). Image mode accepts one JPEG/PNG/WebP URL or local image (at most 10 MiB). Use `--audio-file` / `--image-file` for local files; the CLI sends their bytes directly. Audio and image references are mutually exclusive.
24
+ 4. Construct the complete generation command, including references, output settings, timeout, destination and output name. Validate that exact command by adding `--dry-run`; after success, remove only `--dry-run` for the authorized submission. If any argument changes, repeat this preflight. Configuration import and dry-run do not submit generation jobs.
25
+ 5. Execute the authorized request with `--download "OUTPUT_DIRECTORY"` when local delivery is required. The official call is synchronous; `--wait` remains a compatibility flag. Save the local task ID emitted before submission. The CLI saves the official response and audio in private local state. Generation can incur service charges.
24
26
  6. Inspect status and actual output paths. A completed task without an audio file is incomplete delivery. Report the task ID, file and material limitations; content quality remains the caller's acceptance responsibility.
25
27
 
26
28
  ## Invocation examples
27
29
 
28
30
  ```bash
29
- seedaudiocli generate text --prompt "Generate five seconds of instrumental piano." --dry-run
30
- seedaudiocli generate text --prompt "Generate five seconds of instrumental piano." --wait --download ./audio
31
- seedaudiocli generate audio-ref --audio-reference zh_female_vv_uranus_bigtts --prompt "@audio1 今天的咖啡刚刚好。" --wait --download ./audio
31
+ seedaudiocli generate text --help
32
+ seedaudiocli generate text --prompt "Generate five seconds of instrumental piano." --timeout 300 --download ./audio --output-name piano --dry-run
33
+ seedaudiocli generate text --prompt "Generate five seconds of instrumental piano." --timeout 300 --download ./audio --output-name piano
34
+ seedaudiocli generate audio-ref --audio-reference zh_female_vv_uranus_bigtts --prompt "@音频1 今天的咖啡刚刚好。" --wait --download ./audio
32
35
  seedaudiocli generate image-ref --image-url "https://example.com/reference.jpg" --prompt "Generate audio matching the supplied reference." --dry-run
33
36
  seedaudiocli task get TASK_ID
34
37
  seedaudiocli task wait TASK_ID --download ./audio
35
38
  seedaudiocli task jobs --limit 20
36
39
  ```
37
40
 
38
- Use `@audio1` through `@audio3` in prompts to address the corresponding audio references. Supported formats are mp3, wav, pcm and ogg_opus. Use the command help for sample-rate and other constraints. Explicit unsupported requests require caller resolution.
41
+ Use `@音频1` through `@音频3` to address references in order; `--audio-reference` entries precede `--audio-file` entries when mixed. Supported formats are mp3, wav, pcm and ogg_opus. Sample rates are 8000, 16000, 24000, 32000, 40000, 44100 and 48000; mp3 excludes 40000, and ogg_opus requires 48000. Speech/loudness adjustments are integers from -50 to 100 (0 unchanged); pitch is an integer from -12 to 12. `--subtitle` saves returned subtitle JSON. For advanced official watermark fields use a JSON payload with `payload validate` then `payload submit`. Explicit unsupported requirements return to the caller.
42
+
43
+ The only provider endpoint is `https://openspeech.bytedance.com/api/v3/tts/create`, with model `seed-audio-1.0` and `X-Api-Key` authentication. Use a Doubao Speech API key authorized for Seed Audio. Ark credentials belong to another service. The official schema is documented at https://docs.volcengine.com/docs/DoubaoVoice/audio-generation-http?lang=zh.
44
+
45
+ Official generation waits synchronously for the HTTP response. `--timeout` sets the request timeout in seconds; `--wait` is an optional compatibility flag. `--interval` is unsupported because there is no remote polling loop. When migrating an old invocation, omit `--interval` and its value while preserving the caller's supported choices; it is not a substitute for `--timeout`.
39
46
 
40
47
  ## Error and recovery branches
41
48
 
49
+ - Argument parsing error (`unrecognized arguments`, exit 2): read the selected command's current help, correct the invocation and return to step 4. A confirmed local parser rejection happens before audio submission, so it does not require `--force`. This branch applies only to a parser error; timeout, connection loss or a returned task ID require the recovery branches below. Inspect the CLI exit status and stderr directly; a following shell command can mask its exit status.
42
50
  - `CONFIG_REQUIRED`: request the file path, import, then return to authentication check.
43
51
  - `CONFIG_INVALID` or `CONFIG_READ_FAILED`: request a corrected file/path. Preserve the saved configuration.
44
52
  - `CONFIG_EXISTS`: explain that configuration already exists; replace only when the user requests it.
45
- - `AUTH_REQUIRED`: the provider rejected credentials or permissions. Request a valid replacement or account correction, then resume the same intended operation.
53
+ - `CONFIG_MIGRATION_REQUIRED`: import the administrator-provided official configuration with authorized `--replace`; a backup preserves the previous file. Return to step 1.
54
+ - `AUTH_REQUIRED`: credentials were rejected. Request a valid Doubao Speech replacement, then return to step 1. `PERMISSION_REQUIRED` means the account needs service permission correction.
46
55
  - `PYTHON_REQUIRED`: install a supported Python runtime in the host's managed environment; verify with `doctor` before returning to the main flow.
47
- - Network failure or polling timeout: retain configuration and recover using the existing task ID. Do not submit a duplicate paid job to fix a polling error.
48
- - Unknown submission outcome: inspect task history and provider evidence before any new explicitly authorized submission. `--force` intentionally bypasses local request deduplication and can charge again.
56
+ - `task get`, `task wait` and `task download` read local records and saved responses; they make no remote polling call and need no API key. Completed saved audio can be exported again even if the provider URL expires.
57
+ - Network timeout or interrupted submission: keep the local ID and inspect `task get`. If no successful response was saved, the outcome remains unknown; the official API has no task-query operation.
58
+ - `REQUEST_BUSY`: an identical request is active or its process was interrupted. Inspect history and verify no process is still using the lock before removing a stale local lock. Resume local recovery first.
59
+ - `OUTPUT_EXISTS`: choose a new output name or directory; reuse the saved result without another paid call. `OUTPUT_CONTRACT_FAILED`: inspect the saved response and report incomplete delivery.
60
+ - `SUBMISSION_UNKNOWN`: inspect local history and provider billing/support evidence before any new explicitly authorized submission. `--force` intentionally bypasses local request deduplication and can charge again.
49
61
  - Provider failure: report it with the task ID. Changing prompt, references or model is a caller decision.
50
62
 
51
63
  ## Installation and update
@@ -33,7 +33,13 @@
33
33
  "CONFIG_INVALID",
34
34
  "AUTH_REQUIRED",
35
35
  "PYTHON_REQUIRED",
36
- "PROVIDER_FAILURE"
36
+ "PROVIDER_FAILURE",
37
+ "CONFIG_MIGRATION_REQUIRED",
38
+ "PERMISSION_REQUIRED",
39
+ "SUBMISSION_UNKNOWN",
40
+ "REQUEST_BUSY",
41
+ "OUTPUT_CONTRACT_FAILED",
42
+ "OUTPUT_EXISTS"
37
43
  ]
38
44
  }
39
45
  ]
@@ -1,149 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import re
5
- import time
6
- import urllib.error
7
- import urllib.request
8
- from pathlib import Path
9
- from typing import Any
10
-
11
- from .config import seedaudio_api_key, seedaudio_base_url
12
-
13
-
14
- class SeedAudioError(RuntimeError):
15
- def __init__(self, message: str, code: str = "PROVIDER_FAILURE"):
16
- super().__init__(message)
17
- self.code = code
18
-
19
-
20
- def _headers(json_body: bool = True) -> dict[str, str]:
21
- key = seedaudio_api_key()
22
- if not key:
23
- raise SeedAudioError("Run seedaudiocli setup to import a configuration file.", "CONFIG_REQUIRED")
24
- headers = {"Authorization": f"Bearer {key}"}
25
- if json_body:
26
- headers["Content-Type"] = "application/json"
27
- return headers
28
-
29
-
30
- def api_root() -> str:
31
- return seedaudio_base_url()
32
-
33
-
34
- def create_url() -> str:
35
- return f"{api_root()}/v1/audios/generations"
36
-
37
-
38
- def task_url(task_id: str) -> str:
39
- return f"{api_root()}/v1/tasks/{task_id}"
40
-
41
-
42
- def _request(method: str, url: str, body: dict[str, Any] | None = None, timeout: int = 120) -> dict[str, Any]:
43
- data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
44
- req = urllib.request.Request(url, data=data, headers=_headers(json_body=body is not None), method=method)
45
- try:
46
- with urllib.request.urlopen(req, timeout=timeout) as resp:
47
- text = resp.read().decode("utf-8")
48
- if not text:
49
- return {}
50
- return json.loads(text)
51
- except urllib.error.HTTPError as e:
52
- text = e.read().decode("utf-8", errors="replace")
53
- if e.code in {401, 403}:
54
- raise SeedAudioError("Provider rejected authorization. Check key validity and account permissions.", "AUTH_REQUIRED") from e
55
- raise SeedAudioError(f"HTTP {e.code}: {text}") from e
56
- except urllib.error.URLError as e:
57
- raise SeedAudioError(f"Network error: {e}") from e
58
- except json.JSONDecodeError as e:
59
- raise SeedAudioError(f"Non-JSON response from EvoLink: {e}") from e
60
-
61
-
62
- def _request_with_retry(method: str, url: str, body: dict[str, Any] | None = None, timeout: int = 120) -> dict[str, Any]:
63
- last_error: SeedAudioError | None = None
64
- for attempt in range(1, 4):
65
- try:
66
- return _request(method, url, body, timeout)
67
- except SeedAudioError as e:
68
- last_error = e
69
- if attempt < 3:
70
- time.sleep(min(2 * attempt, 8))
71
- raise last_error or SeedAudioError("Request failed")
72
-
73
-
74
- def create_task(payload: dict[str, Any]) -> dict[str, Any]:
75
- return _request("POST", create_url(), payload)
76
-
77
-
78
- def get_task(task_id: str) -> dict[str, Any]:
79
- return _request_with_retry("GET", task_url(task_id), None, timeout=60)
80
-
81
-
82
- def extract_task_id(response: dict[str, Any]) -> str | None:
83
- for key in ("id", "task_id", "taskId"):
84
- value = response.get(key)
85
- if value:
86
- return str(value)
87
- return None
88
-
89
-
90
- def extract_status(response: dict[str, Any]) -> str:
91
- value = response.get("status")
92
- return str(value).lower() if value else "unknown"
93
-
94
-
95
- def extract_audio_urls(response: dict[str, Any]) -> list[str]:
96
- urls: list[str] = []
97
- result_data = response.get("result_data")
98
- if isinstance(result_data, list):
99
- for item in result_data:
100
- if isinstance(item, dict):
101
- value = item.get("audio_url")
102
- if isinstance(value, str) and value.startswith(("http://", "https://")):
103
- urls.append(value)
104
- results = response.get("results")
105
- if isinstance(results, list):
106
- for value in results:
107
- if isinstance(value, str) and value.startswith(("http://", "https://")):
108
- urls.append(value)
109
- seen: list[str] = []
110
- for url in urls:
111
- if url not in seen:
112
- seen.append(url)
113
- return seen
114
-
115
-
116
- def infer_extension(url: str, output_format: str | None = None) -> str:
117
- match = re.search(r"\.(mp3|wav|ogg|opus|pcm)(\?|$)", url, re.I)
118
- if match:
119
- ext = match.group(1).lower()
120
- return "ogg_opus" if ext == "opus" else ext
121
- return output_format or "mp3"
122
-
123
-
124
- def wait_for_task(task_id: str, interval: int = 5, timeout: int = 1800) -> dict[str, Any]:
125
- deadline = time.time() + timeout
126
- last_response: dict[str, Any] = {}
127
- while time.time() < deadline:
128
- last_response = get_task(task_id)
129
- status = extract_status(last_response)
130
- if status in {"completed", "failed", "cancelled", "canceled", "expired"}:
131
- return last_response
132
- time.sleep(interval)
133
- raise SeedAudioError(f"Timed out waiting for task {task_id}. Last response: {json.dumps(last_response, ensure_ascii=False)}")
134
-
135
-
136
- def download_url(url: str, out: Path) -> Path:
137
- out.parent.mkdir(parents=True, exist_ok=True)
138
- req = urllib.request.Request(url, headers={"User-Agent": "seedaudiocli/0.1"})
139
- last_error: urllib.error.URLError | None = None
140
- for attempt in range(1, 4):
141
- try:
142
- with urllib.request.urlopen(req, timeout=300) as resp:
143
- out.write_bytes(resp.read())
144
- return out
145
- except urllib.error.URLError as e:
146
- last_error = e
147
- if attempt < 3:
148
- time.sleep(min(2 * attempt, 8))
149
- raise SeedAudioError(f"Download failed: {last_error}") from last_error