@petercjl/seedaudiocli 0.1.1 → 0.2.0
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 +14 -4
- package/capabilities.json +22 -9
- package/npm/cli.mjs +7 -3
- package/npm/skills.mjs +7 -1
- package/npm/update.mjs +101 -0
- package/package.json +1 -1
- package/seedaudiocli/__init__.py +9 -1
- package/seedaudiocli/cli.py +102 -108
- package/seedaudiocli/config.py +15 -44
- package/seedaudiocli/jobs.py +118 -30
- package/seedaudiocli/official.py +62 -0
- package/seedaudiocli/payloads.py +104 -82
- package/seedaudiocli.config.example.json +2 -2
- package/seedaudiocli.env.example +2 -2
- package/skills/seedaudiocli/SKILL.md +16 -10
- package/skills/seedaudiocli/capabilities.json +7 -1
- package/seedaudiocli/evolink.py +0 -149
|
@@ -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}
|
package/seedaudiocli/payloads.py
CHANGED
|
@@ -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
|
|
16
|
+
def read_prompt(prompt, prompt_file):
|
|
16
17
|
if prompt and prompt_file:
|
|
17
|
-
raise ValueError("Use either --prompt or --prompt-file
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
errors.append("
|
|
66
|
-
|
|
67
|
-
if
|
|
68
|
-
errors.append("
|
|
69
|
-
|
|
70
|
-
|
|
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(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
errors.append("
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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()
|
|
129
|
+
return hashlib.sha256(data).hexdigest()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"SEEDAUDIO_API_KEY": "",
|
|
3
|
-
"SEEDAUDIO_BASE_URL": "https://
|
|
4
|
-
"SEEDAUDIO_MODEL": "
|
|
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
|
}
|
package/seedaudiocli.env.example
CHANGED
|
@@ -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://
|
|
8
|
-
SEEDAUDIO_MODEL=
|
|
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
|
|
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,7 @@ 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
|
|
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
14
|
|
|
15
15
|
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
16
|
|
|
@@ -18,9 +18,9 @@ Use the current host's terminal execution capability. Its mapping is declared in
|
|
|
18
18
|
|
|
19
19
|
1. Check `seedaudiocli auth status`. This checks local configuration only and does not prove the key is accepted remotely.
|
|
20
20
|
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
|
|
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 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.
|
|
22
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 `--
|
|
23
|
+
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
24
|
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
25
|
|
|
26
26
|
## Invocation examples
|
|
@@ -28,28 +28,34 @@ Use the current host's terminal execution capability. Its mapping is declared in
|
|
|
28
28
|
```bash
|
|
29
29
|
seedaudiocli generate text --prompt "Generate five seconds of instrumental piano." --dry-run
|
|
30
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 "
|
|
31
|
+
seedaudiocli generate audio-ref --audio-reference zh_female_vv_uranus_bigtts --prompt "@音频1 今天的咖啡刚刚好。" --wait --download ./audio
|
|
32
32
|
seedaudiocli generate image-ref --image-url "https://example.com/reference.jpg" --prompt "Generate audio matching the supplied reference." --dry-run
|
|
33
33
|
seedaudiocli task get TASK_ID
|
|
34
34
|
seedaudiocli task wait TASK_ID --download ./audio
|
|
35
35
|
seedaudiocli task jobs --limit 20
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Use
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
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.
|
|
39
41
|
|
|
40
42
|
## Error and recovery branches
|
|
41
43
|
|
|
42
44
|
- `CONFIG_REQUIRED`: request the file path, import, then return to authentication check.
|
|
43
45
|
- `CONFIG_INVALID` or `CONFIG_READ_FAILED`: request a corrected file/path. Preserve the saved configuration.
|
|
44
46
|
- `CONFIG_EXISTS`: explain that configuration already exists; replace only when the user requests it.
|
|
45
|
-
- `
|
|
47
|
+
- `CONFIG_MIGRATION_REQUIRED`: import the administrator-provided official configuration with authorized `--replace`; a backup preserves the previous file. Return to step 1.
|
|
48
|
+
- `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
49
|
- `PYTHON_REQUIRED`: install a supported Python runtime in the host's managed environment; verify with `doctor` before returning to the main flow.
|
|
47
|
-
-
|
|
48
|
-
-
|
|
50
|
+
- `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.
|
|
51
|
+
- 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.
|
|
52
|
+
- `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.
|
|
53
|
+
- `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.
|
|
54
|
+
- `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
55
|
- Provider failure: report it with the task ID. Changing prompt, references or model is a caller decision.
|
|
50
56
|
|
|
51
57
|
## Installation and update
|
|
52
58
|
|
|
53
59
|
`seedaudiocli skill install --agent codex` installs a managed copy. For SealSeek supply the actual discovery directory with `--root SKILLS_DIRECTORY` or `SEALSEEK_SKILLS_DIR`; installation refuses to guess the host's layout. Existing unmanaged Skills are preserved. `skill status` reports ownership and freshness; `skill update` backs up managed files and preserves customized UI metadata. Edited managed content requires reconciliation.
|
|
54
60
|
|
|
55
|
-
|
|
61
|
+
Global npm installations check stable `latest` before every invocation and install a newer version before executing the command once. Managed Skills are synchronized with backups. Offline checks retain the installed version. `seedaudiocli update check` inspects availability; `update install` performs an explicit update. Set `SEEDAUDIO_AUTO_UPDATE=0` for a deliberately pinned run. Development checkouts remain Git-managed. Within taobao-ai-ops use `taobao-ai-ops component run seedaudiocli ...`; the suite owns component updates. Configuration and task history remain in user directories. Record recurring contract failures for a separately authorized package 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
|
]
|
package/seedaudiocli/evolink.py
DELETED
|
@@ -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
|