@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
package/seedaudiocli/cli.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
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=
|
|
40
|
-
parser.add_argument("--loudness-rate", type=
|
|
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("--
|
|
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="
|
|
46
|
-
parser.add_argument("--
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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": "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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",
|
|
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",
|
|
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="
|
|
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="
|
|
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="
|
|
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("--
|
|
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,
|
|
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."})
|
package/seedaudiocli/config.py
CHANGED
|
@@ -10,8 +10,8 @@ from urllib.parse import urlparse
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
APP_NAME = "seedaudiocli"
|
|
13
|
-
DEFAULT_BASE_URL = "https://
|
|
14
|
-
DEFAULT_MODEL = "
|
|
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
|
|
77
|
-
raise ConfigError("
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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)
|
package/seedaudiocli/jobs.py
CHANGED
|
@@ -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
|
|
12
|
+
from .config import state_dir
|
|
13
|
+
from .official import SeedAudioError
|
|
9
14
|
|
|
10
15
|
|
|
11
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
46
|
-
|
|
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
|