@petercjl/seedaudiocli 0.2.0 → 0.3.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/npm/cli.mjs CHANGED
@@ -8,9 +8,10 @@ import { source, targetPath, status, install } from './skills.mjs';
8
8
  const args = process.argv.slice(2);
9
9
  const print = value => console.log(JSON.stringify(value, null, 2));
10
10
  const flag = name => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
11
+ const manifest = JSON.parse(fs.readFileSync(path.join(root,'capabilities.json'),'utf8'));
11
12
  try {
12
13
  if (await beforeRun(args)) { /* child completed */ }
13
- else if (['version', '--version'].includes(args[0])) console.log(`seedaudiocli ${pkg.version}`);
14
+ else if (['version', '--version'].includes(args[0])) args.includes('--json') ? print({ok:true,package:pkg.name,version:pkg.version}) : console.log(`seedaudiocli ${pkg.version}`);
14
15
  else if (args[0] === 'capabilities') print(JSON.parse(fs.readFileSync(path.join(root, 'capabilities.json'), 'utf8')));
15
16
  else if (args[0] === 'doctor') {
16
17
  const python = findPython();
@@ -20,19 +21,42 @@ try {
20
21
  print({ ok: auth.status === 0, version: pkg.version, python, skill_source: source, config });
21
22
  process.exitCode = auth.status === 0 ? 0 : 1;
22
23
  } else if (args[0] === 'skill') {
23
- if (args[1] === 'source') print({ ok: true, source, version: pkg.version });
24
+ if(args.includes('--help')||args.includes('-h')) {
25
+ print(manifest.commandContracts['skill '+args[1]]||{error:'Unknown Skill command'});
26
+ process.exit(0);
27
+ }
28
+ const boolean = new Set(['--json','--all']);
29
+ const values = new Set(['--agent','--root','--name']);
30
+ const seen = new Set();
31
+ for(let i=2;i<args.length;i++) {
32
+ const a=args[i];
33
+ if(seen.has(a)||(!boolean.has(a)&&!values.has(a))) throw fail('INVALID_ARGUMENT','Unknown or duplicate Skill option.');
34
+ seen.add(a);
35
+ if(values.has(a)&&(!args[++i]||args[i].startsWith('--'))) throw fail('INVALID_ARGUMENT','Skill option requires a value.');
36
+ }
37
+ if(flag('--name') && flag('--name')!=='seedaudiocli') throw fail('SKILL_NOT_FOUND','Unknown logical Skill.');
38
+ const identity = {...manifest,ok:true,name:'seedaudiocli',package:pkg.name,source,version:pkg.version};
39
+ if (args[1] === 'source') print(args.includes('--all') ? {...identity,complete:true,skills:[identity]} : identity);
24
40
  else {
25
- const target = targetPath(flag('--agent') || 'codex', flag('--root'));
26
- if (args[1] === 'status') print({ ok: true, ...status(target), record: undefined });
41
+ const target = targetPath(flag('--agent') || 'generic', flag('--root'));
42
+ if (args[1] === 'status') {
43
+ const state=status(target);
44
+ print({...identity,...state,version:state.version||pkg.version,edited:!!state.edited,verification:{verified:!!state.current,algorithm:'sha256'},recovery:{required:false},record:undefined});
45
+ }
27
46
  else if (['install', 'update'].includes(args[1])) print(install(target));
28
47
  else throw fail('INVALID_COMMAND', 'Use skill source|status|install|update.');
29
48
  }
30
49
  } else if (args[0] === 'update') {
31
50
  const check = await checkUpdate();
32
- if (args[1] === 'install' && check.updateAvailable) print(installUpdate(check));
33
- else print({ ...check, policy: 'before-every-run', opt_out: 'SEEDAUDIO_AUTO_UPDATE=0' });
51
+ if(args[1]==='install') {
52
+ const target=targetPath(flag('--agent')||'generic',flag('--root'));
53
+ if(!check.ok) throw fail('REGISTRY_UNAVAILABLE','Keep current installation; update version could not be checked.');
54
+ print(check.updateAvailable ? installUpdate(check,{target}) : {...install(target),package_changed:false});
55
+ } else if(args[1]==='check') print({ ...check, policy: 'cached-notify', opt_out: 'SEEDAUDIO_AUTO_UPDATE=0' });
56
+ else throw fail('INVALID_COMMAND','Use update check|install.');
34
57
  } else if (!args.length || ['--help', '-h'].includes(args[0])) {
35
- console.log('seedaudiocli: setup | config import FILE [--replace] | auth status | generate | task | payload\nPackage: version | capabilities --json | doctor --json | skill source/status/install/update [--agent codex|sealseek] [--root SKILLS_DIRECTORY] | update check/install\nRuntime: Node.js 22+, Python 3.9+ (SEEDAUDIO_PYTHON optional).');
58
+ if(args.includes('--json')){print(manifest);process.exit(0);}
59
+ console.log('seedaudiocli: setup | config import FILE [--replace] | auth status | voice | generate | task | payload\nPackage: version [--json] | capabilities --json | doctor --json | skill source/status/install/update [--agent NAME] [--root ABSOLUTE_SKILLS_DIRECTORY] | update check/install\nRuntime: Node.js 22+, Python 3.9+ (SEEDAUDIO_PYTHON optional).');
36
60
  process.exitCode = runPython(['--help']).status || 0;
37
61
  } else {
38
62
  const result = runPython(args);
package/npm/notice.mjs ADDED
@@ -0,0 +1 @@
1
+ console.error('seedaudiocli: npm runtime installed. The current Agent must resolve its own Skill directory and follow INSTALL.md / UPDATE.md to synchronize and verify the companion Skill. User credentials and voice library are preserved.');
package/npm/skills.mjs CHANGED
@@ -5,15 +5,16 @@ import crypto from 'node:crypto';
5
5
  import { stateRoot } from './update.mjs';
6
6
  import { root, pkg, fail } from './runtime.mjs';
7
7
 
8
- export const source = path.join(root, 'skills', 'seedaudiocli');
8
+ export const source = fs.existsSync(path.join(root, 'skills', 'seedaudiocli')) ? path.join(root, 'skills', 'seedaudiocli') : path.dirname(root);
9
9
  const marker = '.seedaudiocli-managed.json';
10
10
  const metadata = 'agents/openai.yaml';
11
11
  function exists(file) { try { return fs.lstatSync(file); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } }
12
- export function inventory(dir) {
12
+ export function inventory(dir, skipRuntime = false) {
13
13
  const result = {};
14
14
  function walk(at, prefix = '') {
15
15
  for (const entry of fs.readdirSync(at, { withFileTypes: true })) {
16
16
  if (entry.name === marker) continue;
17
+ if (skipRuntime && !prefix && entry.name === 'runtime') continue;
17
18
  const relative = prefix + entry.name;
18
19
  if (entry.isSymbolicLink()) throw fail('SKILL_UNSAFE_TARGET', 'Skill files must not contain symlinks.');
19
20
  if (entry.isDirectory()) walk(path.join(at, entry.name), relative + '/');
@@ -24,13 +25,21 @@ export function inventory(dir) {
24
25
  return result;
25
26
  }
26
27
  export function targetPath(agent, targetRoot) {
27
- if (!['codex', 'sealseek'].includes(agent)) throw fail('INVALID_AGENT', 'Choose --agent codex or sealseek.');
28
- let base = targetRoot;
29
- if (!base && agent === 'codex') base = path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'skills');
30
- if (!base && agent === 'sealseek') base = process.env.SEALSEEK_SKILLS_DIR;
31
- if (!base) throw fail('AGENT_PATH_REQUIRED', 'Provide the actual SealSeek skills directory through --root or SEALSEEK_SKILLS_DIR.');
28
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(agent)) throw fail('INVALID_AGENT', 'Agent names use lowercase letters, digits and hyphens.');
29
+ const base = targetRoot;
30
+ if (!base) throw fail('AGENT_PATH_REQUIRED', 'Resolve the current Agent Skill directory and pass it explicitly through --root.');
31
+ if (!path.isAbsolute(base)) throw fail('AGENT_PATH_REQUIRED', '--root must be an absolute Skill parent directory.');
32
32
  return path.join(path.resolve(base), 'seedaudiocli');
33
33
  }
34
+ export function expectedFiles() {
35
+ const files = inventory(source, true);
36
+ for (const folder of ['npm', 'seedaudiocli']) for (const file of fs.readdirSync(path.join(root, folder))) {
37
+ if (!file.endsWith(folder === 'npm' ? '.mjs' : '.py')) continue;
38
+ files[`runtime/${folder}/${file}`] = crypto.createHash('sha256').update(fs.readFileSync(path.join(root, folder, file))).digest('hex');
39
+ }
40
+ for (const file of ['package.json', 'capabilities.json']) files[`runtime/${file}`] = crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex');
41
+ return files;
42
+ }
34
43
  export function status(target) {
35
44
  const st = exists(target);
36
45
  if (!st) return { installed: false, managed: false, current: false, target };
@@ -41,8 +50,8 @@ export function status(target) {
41
50
  if (Object.keys(record.files).some(file => !file || path.isAbsolute(file) || file.includes('\\') || file.split('/').some(part => ['.', '..', ''].includes(part)))) throw fail('SKILL_UNSAFE_TARGET', 'Invalid managed-file path in Skill ownership record.');
42
51
  const files = inventory(target);
43
52
  const edited = Object.entries(record.files).some(([file, hash]) => file !== metadata && files[file] !== hash);
44
- const expected = inventory(source);
45
- const current = !edited && Object.entries(expected).every(([file, hash]) => file === metadata || files[file] === hash);
53
+ const expected = expectedFiles();
54
+ const current = !edited && record.version === pkg.version && Object.entries(expected).every(([file, hash]) => file === metadata || files[file] === hash);
46
55
  return { installed: true, managed: true, current, edited, target, version: record.version, record };
47
56
  }
48
57
  export function install(target) {
@@ -54,7 +63,7 @@ export function install(target) {
54
63
  if (state.installed && !state.managed) throw fail('SKILL_UNMANAGED', 'An existing user-owned Skill occupies this target; preserve it and choose a new root or explicitly migrate it.');
55
64
  if (state.edited) throw fail('SKILL_EDITED', 'Managed Skill files contain local edits; preserve and reconcile those edits before updating.');
56
65
  if (state.current && state.version === pkg.version) return { ...state, record: undefined, ok: true, changed: false };
57
- const expected = inventory(source);
66
+ const expected = expectedFiles();
58
67
  for (const file of Object.keys(expected)) {
59
68
  if (state.installed && exists(path.join(target, file)) && !(file in state.record.files) && file !== metadata) throw fail('SKILL_UNMANAGED_FILE', 'A new bundled file would overwrite a user-owned file.');
60
69
  }
@@ -73,7 +82,7 @@ export function install(target) {
73
82
  for (const file of Object.keys(expected)) {
74
83
  if (file === metadata && exists(path.join(target, file))) continue;
75
84
  fs.mkdirSync(path.dirname(path.join(target, file)), { recursive: true });
76
- fs.copyFileSync(path.join(source, file), path.join(target, file));
85
+ fs.copyFileSync(file.startsWith('runtime/') ? path.join(root, file.slice(8)) : path.join(source, file), path.join(target, file));
77
86
  }
78
87
  for (const file of Object.keys(state.record?.files || {})) {
79
88
  if (file !== metadata && !(file in expected)) fs.unlinkSync(path.join(target, file));
@@ -81,7 +90,7 @@ export function install(target) {
81
90
  const actual = inventory(target);
82
91
  if (Object.entries(expected).some(([file, hash]) => file !== metadata && actual[file] !== hash)) throw fail('SKILL_VERIFY_FAILED', 'Installed Skill hashes differ from the package.');
83
92
  fs.writeFileSync(path.join(target, marker), JSON.stringify({ package: pkg.name, version: pkg.version, files: expected }, null, 2));
84
- return { ok: true, installed: true, managed: true, current: true, changed: true, target, version: pkg.version, backup: backup || null };
93
+ return { ok: true, installed: true, managed: true, current: true, changed: true, target, version: pkg.version, backup: backup || null, runtime_entry: path.join(target,'runtime','npm','cli.mjs'), host_discovery: 'pending-host-verification' };
85
94
  } catch (e) {
86
95
  throw fail('SKILL_INSTALL_FAILED', `Skill installation failed; ${backup ? 'restore from ' + backup : 'inspect the partial target at ' + target}.`);
87
96
  } finally { fs.closeSync(lockfd); fs.unlinkSync(lock); }
package/npm/update.mjs CHANGED
@@ -20,7 +20,7 @@ export function globalPrefix(location=root, platform=process.platform) {
20
20
  }
21
21
  export async function checkUpdate(registry=fetch) {
22
22
  try {
23
- const r=await registry(`https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/latest`,{signal:AbortSignal.timeout(8000)});
23
+ const r=await registry(`https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/latest`,{signal:AbortSignal.timeout(4000)});
24
24
  if(!r.ok) throw Error('Registry unavailable');
25
25
  const data=await r.json();
26
26
  if(data.name!==pkg.name || !/^\d+\.\d+\.\d+$/.test(data.version)) throw Error('Invalid stable package');
@@ -40,6 +40,7 @@ function npm(args) {
40
40
  return r.stdout;
41
41
  }
42
42
  export function installUpdate(check, services={}) {
43
+ if(!services.target) throw fail('AGENT_PATH_REQUIRED','Resolve one current Agent Skill target before package replacement.');
43
44
  const prefix=services.prefix || globalPrefix();
44
45
  if(!prefix) throw fail('UPDATE_OWNER_REQUIRED','This installation is managed by its source checkout or parent package.');
45
46
  const state=stateRoot(); fs.mkdirSync(state,{recursive:true});
@@ -54,15 +55,15 @@ export function installUpdate(check, services={}) {
54
55
  if(!newer(check.available,live.version)) return {ok:true,changed:false,version:live.version};
55
56
  fs.mkdirSync(backup,{recursive:true});
56
57
  const archive=JSON.parse(run(['pack',root,'--ignore-scripts','--pack-destination',backup,'--json']))[0];
57
- const registered=path.join(state,'skill-targets');
58
- const known=[path.join(process.env.CODEX_HOME || path.join(os.homedir(),'.codex'),'skills','seedaudiocli')];
59
- for(const f of (fs.existsSync(registered) ? fs.readdirSync(registered) : [])) {
60
- try { known.push(JSON.parse(fs.readFileSync(path.join(registered,f),'utf8')).target); } catch { /* invalid registration */ }
61
- }
62
- for(const target of new Set(known)) {
63
- try { if(JSON.parse(fs.readFileSync(path.join(target,'.seedaudiocli-managed.json'),'utf8')).package===pkg.name) targets.push(target); } catch { /* unmanaged or absent */ }
64
- }
65
58
  const entry=path.join(root,'npm','cli.mjs');
59
+ const target=services.target;
60
+ if(fs.existsSync(target)) {
61
+ const pre=spawn(process.execPath,[entry,'skill','status','--root',path.dirname(target),'--json'],{encoding:'utf8',env:{...process.env,SEEDAUDIO_AUTO_UPDATE:'0'}});
62
+ let info;try{info=JSON.parse(pre.stdout);}catch{throw fail('SKILL_PREFLIGHT_FAILED','Cannot verify selected Skill before package replacement.');}
63
+ if(!info.managed||info.edited)throw fail('SKILL_EDITED','Preserve unmanaged or edited Skill before update.');
64
+ fs.cpSync(target,path.join(backup,'skill'),{recursive:true,errorOnExist:true,force:false});
65
+ }
66
+ targets.push(target);
66
67
  const sync=target=>spawn(process.execPath,[entry,'skill','update','--root',path.dirname(target)],{encoding:'utf8',timeout:30000,env:{...process.env,SEEDAUDIO_AUTO_UPDATE:'0'}});
67
68
  let mutated=false;
68
69
  try {
@@ -83,19 +84,30 @@ export function installUpdate(check, services={}) {
83
84
  } finally { fs.rmdirSync(lock); }
84
85
  }
85
86
  export async function beforeRun(args, services={}) {
86
- if(process.env.SEEDAUDIO_AUTO_UPDATE==='0' || !globalPrefix() || args[0]==='update') return false;
87
+ if(process.env.SEEDAUDIO_AUTO_UPDATE==='0' || !globalPrefix() || !['generate'].includes(args[0]) || args.includes('--dry-run') || args.includes('--help')) return false;
88
+ // Detection only: installations reconcile one explicitly selected host.
89
+ const cache=path.join(stateRoot(),'update-check.json');
90
+ try {
91
+ const info=JSON.parse(fs.readFileSync(cache,'utf8'));
92
+ if(Number.isFinite(info.checkedAt) && Date.now()-info.checkedAt<86400000)return false;
93
+ } catch { /* absent/corrupt cache is never overwritten */ }
94
+ let fd;
95
+ const guard=cache+'.lock';
96
+ try {fs.mkdirSync(path.dirname(cache),{recursive:true});fd=fs.openSync(guard,'wx',0o600);} catch {return false;}
97
+ try {
87
98
  const check=await (services.check || checkUpdate)();
88
- if(!check.ok) console.error('[seedaudiocli] Update check unavailable; using installed version.');
89
- if(!check.updateAvailable) return false;
99
+ if(check.updateAvailable)console.error(`[seedaudiocli] ${check.available} is available; ask the current Agent to update CLI and its own Skill.`);
90
100
  try {
91
- const result=(services.install || installUpdate)(check);
92
- console.error(`[seedaudiocli] Using latest ${result.version}.`);
93
- } catch(e) {
94
- if(['UPDATE_BUSY','ROLLBACK_SYNC_REQUIRED','ROLLBACK_REQUIRED'].includes(e.code)) throw e;
95
- console.error(`[seedaudiocli] ${e.code || 'UPDATE_FAILED'}; using restored installed version.`);
96
- }
97
- // Delegate once only after update/rollback; never replay a business request.
98
- const r=spawnSync(process.execPath,[path.join(root,'npm','cli.mjs'),...args],{stdio:'inherit',env:{...process.env,SEEDAUDIO_AUTO_UPDATE:'0'}});
99
- process.exitCode=r.status??1;
100
- return true;
101
+ fs.mkdirSync(path.dirname(cache),{recursive:true});
102
+ if(!fs.existsSync(cache))fs.writeFileSync(cache,JSON.stringify({checkedAt:Date.now(),check}),{flag:'wx',mode:0o600});
103
+ else {
104
+ const old=JSON.parse(fs.readFileSync(cache,'utf8'));
105
+ if(Number.isFinite(old.checkedAt)) {
106
+ const next=cache+'.'+crypto.randomUUID();
107
+ fs.writeFileSync(next,JSON.stringify({checkedAt:Date.now(),check}),{flag:'wx',mode:0o600});fs.renameSync(next,cache);
108
+ }
109
+ }
110
+ } catch { /* cache must not block business execution */ }
111
+ } finally {fs.closeSync(fd);fs.unlinkSync(guard);}
112
+ return false;
101
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@petercjl/seedaudiocli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Seed Audio CLI with portable Agent skill and persistent user credentials",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,10 @@
14
14
  "adapters/",
15
15
  "seedaudiocli.env.example",
16
16
  "seedaudiocli.config.example.json",
17
- "README.md"
17
+ "README.md",
18
+ "INSTALL.md",
19
+ "UPDATE.md",
20
+ "scripts/validate-release.mjs"
18
21
  ],
19
22
  "engines": {
20
23
  "node": ">=22"
@@ -22,7 +25,8 @@
22
25
  "scripts": {
23
26
  "check": "node --check npm/cli.mjs && node --check npm/runtime.mjs && node --check npm/skills.mjs",
24
27
  "test": "node --test test/*.test.mjs",
25
- "prepack": "npm run check && npm test"
28
+ "prepack": "npm run check && npm test && node scripts/validate-release.mjs",
29
+ "postinstall": "node npm/notice.mjs"
26
30
  },
27
31
  "publishConfig": {
28
32
  "access": "public"
@@ -0,0 +1,14 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import {pkg,root,runPython} from '../npm/runtime.mjs';
4
+ import path from 'node:path';
5
+
6
+ const result=runPython(['--version'],{encoding:'utf8',stdio:'pipe'});
7
+ assert.equal(result.status,0);
8
+ assert.equal(result.stdout.trim(),`seedaudiocli ${pkg.version}`);
9
+ const source=fs.readFileSync(path.join(root,'skills/seedaudiocli/SKILL.md'),'utf8');
10
+ assert.ok(source.includes('--voice'));
11
+ const m=JSON.parse(fs.readFileSync(path.join(root,'capabilities.json')));
12
+ assert.ok(m.voiceLibrary);
13
+ assert.equal(m.installationLifecycle.sharedLinksAllowed,false);
14
+ console.log(JSON.stringify({ok:true,version:pkg.version,pythonVersionMatches:true,voiceContract:true}));
@@ -13,6 +13,7 @@ from typing import Any
13
13
 
14
14
  from . import __version__
15
15
  from . import official
16
+ from . import voices
16
17
  from .config import (
17
18
  DEFAULT_BASE_URL,
18
19
  DEFAULT_MODEL,
@@ -34,6 +35,7 @@ def print_json(data: Any) -> None:
34
35
 
35
36
 
36
37
  def add_generation_args(parser: argparse.ArgumentParser) -> None:
38
+ parser.add_argument("--voice", help="Named local voice ID/name, optionally NAME@VERSION; prompt is narration text.")
37
39
  parser.add_argument("--prompt", help="Prompt text, max 3000 characters.")
38
40
  parser.add_argument("--prompt-file", help="Path to a UTF-8 prompt text file.")
39
41
  parser.add_argument("--model", help="Override model id. Default: seed-audio-1.0.")
@@ -53,6 +55,8 @@ def add_generation_args(parser: argparse.ArgumentParser) -> None:
53
55
 
54
56
  def generation_payload_from_args(args: argparse.Namespace) -> dict[str, Any]:
55
57
  prompt = read_prompt(args.prompt, args.prompt_file)
58
+ if args.voice:
59
+ return voices.apply(args, prompt)
56
60
  audio_refs = list(getattr(args, "audio_reference", None) or [])
57
61
  image_urls = list(getattr(args, "image_url", None) or [])
58
62
  return build_payload(
@@ -84,7 +88,7 @@ def submit_or_print(payload, args):
84
88
  raise ValueError("download destination must be a directory.")
85
89
  if args.dry_run:
86
90
  print_json({"ok": True, "dry_run": True, "request_hash": h, "payload": payload,
87
- "provider": "Volcengine", "endpoint": DEFAULT_BASE_URL + "/api/v3/tts/create"})
91
+ "voice": getattr(args, "voice_receipt", None), "provider": "Volcengine", "endpoint": DEFAULT_BASE_URL + "/api/v3/tts/create"})
88
92
  return 0
89
93
  seedaudio_base_url()
90
94
  key = seedaudio_api_key()
@@ -108,6 +112,8 @@ def submit_or_print(payload, args):
108
112
  job_dir(task_id).mkdir(mode=0o700)
109
113
  record = {"task_id": task_id, "request_id": task_id, "request_hash": h, "provider": "Volcengine",
110
114
  "status": "submitting", "created_at": time.time(), "format": payload.get("audio_config", {}).get("format", "wav")}
115
+ if getattr(args, "voice_receipt", None):
116
+ record["voice"] = args.voice_receipt
111
117
  save_record(record)
112
118
  # Persist the local ID before sending the only billable request.
113
119
  print(json.dumps({"event": "submitting", "task_id": task_id}), file=sys.stderr, flush=True)
@@ -124,6 +130,8 @@ def submit_or_print(payload, args):
124
130
  result = {"ok": True, "task_id": task_id, "job": record}
125
131
  if args.download:
126
132
  result["downloaded_to"] = export_audio(record, args.download, args.output_name)
133
+ if getattr(args, "voice_receipt", None):
134
+ result["voice"] = args.voice_receipt
127
135
  print_json(result)
128
136
  return 0
129
137
  finally:
@@ -200,14 +208,33 @@ def cmd_models(_: argparse.Namespace) -> int:
200
208
 
201
209
 
202
210
  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.")
211
+ if args.generate_cmd == "audio-ref" and not (args.audio_reference or args.audio_file or args.voice):
212
+ raise ValueError("audio-ref requires --voice, --audio-reference or --audio-file.")
205
213
  if args.generate_cmd == "image-ref" and not (args.image_url or args.image_file):
206
214
  raise ValueError("image-ref requires --image-url or --image-file.")
207
215
  payload = generation_payload_from_args(args)
208
216
  return submit_or_print(payload, args)
209
217
 
210
218
 
219
+ def cmd_voice(args):
220
+ if args.voice_cmd == "list":
221
+ items = voices.profiles()
222
+ if not args.all:
223
+ items = [max([q for q in items if q["id"] == identity], key=lambda q: q["version"]) for identity in sorted({q["id"] for q in items})]
224
+ result = {"ok": True, "library": str(voices.library()), "voices": items}
225
+ elif args.voice_cmd == "save":
226
+ result = voices.save(args)
227
+ elif args.voice_cmd == "export":
228
+ result = voices.export_voice(args.selector, args.output)
229
+ elif args.voice_cmd == "import":
230
+ result = voices.import_voice(args.file)
231
+ else:
232
+ p, sample = voices.get(args.selector)
233
+ result = {"ok": True, "voice": p, "reference_path": str(sample), "preview_path": str(sample), "paid": False}
234
+ print_json(result)
235
+ return 0
236
+
237
+
211
238
  def cmd_task(args):
212
239
  if args.task_cmd == "jobs":
213
240
  if args.limit < 1:
@@ -280,6 +307,34 @@ def build_parser() -> argparse.ArgumentParser:
280
307
  models_list = models_sub.add_parser("list", help="List built-in model ids.")
281
308
  models_list.set_defaults(func=cmd_models)
282
309
 
310
+ voice = sub.add_parser("voice", help="Private named voice library; local operations do not generate audio.")
311
+ voice_sub = voice.add_subparsers(dest="voice_cmd", required=True)
312
+ voice_list = voice_sub.add_parser("list", help="List latest revisions; --all includes history.")
313
+ voice_list.add_argument("--all", action="store_true")
314
+ for action in ["show", "preview"]:
315
+ leaf = voice_sub.add_parser(action, help="Inspect settings and return the existing sample for playback.")
316
+ leaf.add_argument("selector")
317
+ leaf.set_defaults(func=cmd_voice)
318
+ voice_save = voice_sub.add_parser("save", help="Copy a reference and settings into a new immutable revision.")
319
+ voice_save.add_argument("--id", required=True)
320
+ voice_save.add_argument("--name", required=True)
321
+ voice_save.add_argument("--audio-file", required=True)
322
+ voice_save.add_argument("--description", default="")
323
+ voice_save.add_argument("--style", default="")
324
+ voice_save.add_argument("--model", default="seed-audio-1.0")
325
+ voice_save.add_argument("--format", choices=["wav", "mp3", "pcm", "ogg_opus"], default="mp3")
326
+ voice_save.add_argument("--sample-rate", type=int, default=24000)
327
+ voice_save.add_argument("--speech-rate", type=int, default=0)
328
+ voice_save.add_argument("--pitch-rate", type=int, default=0)
329
+ voice_save.add_argument("--loudness-rate", type=int)
330
+ voice_export = voice_sub.add_parser("export", help="Export one self-contained voice bundle with its sample.")
331
+ voice_export.add_argument("selector")
332
+ voice_export.add_argument("--output", required=True)
333
+ voice_import = voice_sub.add_parser("import", help="Validate and import a voice bundle as a new local revision.")
334
+ voice_import.add_argument("file")
335
+ for leaf in [voice_list, voice_save, voice_export, voice_import]:
336
+ leaf.set_defaults(func=cmd_voice)
337
+
283
338
  generate = sub.add_parser("generate", help="Create Seed-Audio generation tasks.")
284
339
  gen_sub = generate.add_subparsers(dest="generate_cmd", required=True)
285
340
 
@@ -348,6 +403,7 @@ def build_parser() -> argparse.ArgumentParser:
348
403
 
349
404
  def main(argv: list[str] | None = None) -> int:
350
405
  parser = build_parser()
406
+ argv = [x for x in (sys.argv[1:] if argv is None else argv) if x != "--json"]
351
407
  args = parser.parse_args(argv)
352
408
  try:
353
409
  network = ((args.cmd == "generate" and not args.dry_run)
@@ -0,0 +1,229 @@
1
+ """Private, immutable named voice presets. No provider calls or shared samples."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import time
12
+ import wave
13
+ from pathlib import Path
14
+
15
+ from .config import config_path
16
+ from .payloads import build_payload, validate_payload
17
+
18
+ MAX_BYTES = 10 * 1024 * 1024
19
+ SETTINGS = {"model", "format", "sample_rate", "speech_rate", "pitch_rate", "loudness_rate"}
20
+
21
+
22
+ class VoiceError(ValueError):
23
+ def __init__(self, code, message):
24
+ super().__init__(message)
25
+ self.code = code
26
+
27
+
28
+ def library():
29
+ return Path(os.environ.get("SEEDAUDIO_VOICE_DIR", str(config_path().parent / "voices"))).expanduser().absolute()
30
+
31
+
32
+ def regular(path):
33
+ if path.is_symlink() or not path.is_file():
34
+ raise VoiceError("VOICE_UNSAFE_FILE", "Expected a regular, non-symlink file.")
35
+ if not 0 < path.stat().st_size <= MAX_BYTES * 2:
36
+ raise VoiceError("VOICE_INVALID", "Voice file is empty or exceeds the size limit.")
37
+
38
+
39
+ def audio_seconds(path):
40
+ regular(path)
41
+ if path.stat().st_size > MAX_BYTES or path.suffix.lower() not in {".wav", ".mp3", ".ogg", ".opus"}:
42
+ raise VoiceError("VOICE_INVALID", "Reference must be WAV, MP3 or Ogg/Opus, up to 10 MiB.")
43
+ if path.suffix.lower() == ".wav":
44
+ try:
45
+ with wave.open(str(path), "rb") as audio:
46
+ seconds = audio.getnframes() / audio.getframerate()
47
+ except (wave.Error, EOFError, ZeroDivisionError):
48
+ raise VoiceError("VOICE_INVALID", "Reference WAV is invalid.") from None
49
+ else:
50
+ probe = shutil.which("ffprobe")
51
+ if not probe:
52
+ raise VoiceError("VOICE_PROBE_REQUIRED", "Install FFmpeg (ffprobe) to validate compressed voice samples, or use PCM WAV.")
53
+ try:
54
+ r = subprocess.run([probe, "-v", "error", "-show_entries", "format=duration", "-of", "json", str(path)], capture_output=True, timeout=15, check=True)
55
+ seconds = float(json.loads(r.stdout)["format"]["duration"])
56
+ except (ValueError, KeyError, subprocess.SubprocessError):
57
+ raise VoiceError("VOICE_INVALID", "Unable to validate reference duration.") from None
58
+ if not 0 < seconds <= 30:
59
+ raise VoiceError("VOICE_INVALID", "Reference duration must be greater than zero and at most 30 seconds.")
60
+ return seconds
61
+
62
+
63
+ def validate_profile(p):
64
+ required = {"schema_version", "id", "name", "version", "description", "style", "settings", "reference"}
65
+ if not isinstance(p, dict) or set(p) != required or p.get("schema_version") != 1:
66
+ raise VoiceError("VOICE_INVALID", "Unsupported voice profile schema.")
67
+ if not isinstance(p["id"], str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", p["id"]):
68
+ raise VoiceError("VOICE_INVALID", "Voice ID must use 1–64 lowercase letters, digits or hyphens.")
69
+ if p["id"] in {"con", "prn", "aux", "nul", *[f"com{i}" for i in range(1, 10)], *[f"lpt{i}" for i in range(1, 10)]}:
70
+ raise VoiceError("VOICE_INVALID", "Voice ID is a reserved platform filename.")
71
+ if type(p["version"]) is not int or p["version"] < 1:
72
+ raise VoiceError("VOICE_INVALID", "Voice version must be a positive integer.")
73
+ for field, limit, empty in [("name", 80, False), ("description", 1000, True), ("style", 2000, True)]:
74
+ value = p[field]
75
+ if not isinstance(value, str) or len(value) > limit or (not empty and not value.strip()) or any(ord(c) < 32 for c in value):
76
+ raise VoiceError("VOICE_INVALID", "Invalid voice name, description or style.")
77
+ if "@" in p["name"]:
78
+ raise VoiceError("VOICE_INVALID", "Voice name cannot contain @ (reserved for version selection).")
79
+ settings = p["settings"]
80
+ if not isinstance(settings, dict) or set(settings) - SETTINGS:
81
+ raise VoiceError("VOICE_INVALID", "Unsupported voice settings.")
82
+ payload = {"model": settings.get("model", "seed-audio-1.0"), "text_prompt": "validation", "audio_config": {k: v for k, v in settings.items() if k != "model"}}
83
+ if validate_payload(payload):
84
+ raise VoiceError("VOICE_INVALID", "Voice output settings are invalid.")
85
+ ref = p["reference"]
86
+ if not isinstance(ref, dict) or set(ref) != {"file", "sha256", "seconds"} or ref["file"] not in {"sample.wav", "sample.mp3", "sample.ogg", "sample.opus"} or not isinstance(ref["sha256"], str) or not re.fullmatch(r"[0-9a-f]{64}", ref["sha256"]) or type(ref["seconds"]) not in {int, float} or not 0 < ref["seconds"] <= 30:
87
+ raise VoiceError("VOICE_INVALID", "Invalid voice reference metadata.")
88
+ return p
89
+
90
+
91
+ def profiles():
92
+ root = library()
93
+ if root.is_symlink():
94
+ raise VoiceError("VOICE_UNSAFE_FILE", "Voice library cannot be a symlink.")
95
+ if not root.exists():
96
+ return []
97
+ items = []
98
+ for folder in root.iterdir():
99
+ if folder.name.startswith("."):
100
+ continue
101
+ if folder.is_symlink() or not folder.is_dir():
102
+ raise VoiceError("VOICE_UNSAFE_FILE", "Invalid library entry.")
103
+ for revision in folder.iterdir():
104
+ if revision.is_symlink() or not revision.is_dir() or not revision.name.isdigit():
105
+ raise VoiceError("VOICE_UNSAFE_FILE", "Invalid voice revision.")
106
+ f = revision / "profile.json"
107
+ regular(f)
108
+ try:
109
+ p = validate_profile(json.loads(f.read_text(encoding="utf-8")))
110
+ except (ValueError, TypeError):
111
+ raise VoiceError("VOICE_INVALID", "Stored voice profile is invalid; preserve it for recovery.") from None
112
+ if p["id"] != folder.name or str(p["version"]) != revision.name:
113
+ raise VoiceError("VOICE_INVALID", "Voice path and profile identity differ.")
114
+ items.append(p)
115
+ return items
116
+
117
+
118
+ def get(selector):
119
+ identity, sep, revision = selector.rpartition("@")
120
+ if not sep:
121
+ identity, revision = selector, None
122
+ if revision is not None and (not revision.isdigit() or int(revision) < 1):
123
+ raise VoiceError("VOICE_INVALID", "Use NAME@VERSION or ID@VERSION.")
124
+ found = [p for p in profiles() if identity in {p["id"], p["name"]} and (revision is None or p["version"] == int(revision))]
125
+ if not found:
126
+ raise VoiceError("VOICE_NOT_FOUND", "Voice not found; use voice list to choose an installed voice.")
127
+ if len({p["id"] for p in found}) != 1:
128
+ raise VoiceError("VOICE_AMBIGUOUS", "Voice name is ambiguous; select its ID.")
129
+ p = max(found, key=lambda p: p["version"])
130
+ sample = library() / p["id"] / str(p["version"]) / p["reference"]["file"]
131
+ regular(sample)
132
+ if hashlib.sha256(sample.read_bytes()).hexdigest() != p["reference"]["sha256"]:
133
+ raise VoiceError("VOICE_INTEGRITY_FAILED", "Voice reference hash differs; recover the original sample.")
134
+ return p, sample
135
+
136
+
137
+ def commit(p, sample_bytes):
138
+ validate_profile(p)
139
+ if not 0 < len(sample_bytes) <= MAX_BYTES or hashlib.sha256(sample_bytes).hexdigest() != p["reference"]["sha256"]:
140
+ raise VoiceError("VOICE_INTEGRITY_FAILED", "Voice sample hash or size is invalid.")
141
+ root = library()
142
+ if root.is_symlink():
143
+ raise VoiceError("VOICE_UNSAFE_FILE", "Voice library cannot be a symlink.")
144
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
145
+ lock = root / ".write-lock"
146
+ try:
147
+ fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
148
+ except FileExistsError:
149
+ raise VoiceError("VOICE_BUSY", "Another library operation holds the lock; inspect it before retrying.") from None
150
+ stage = root / (".pending-" + str(time.time_ns()))
151
+ try:
152
+ existing = profiles()
153
+ if any(q["id"] != p["id"] and (q["name"] in {p["id"], p["name"]} or q["id"] == p["name"]) for q in existing):
154
+ raise VoiceError("VOICE_NAME_CONFLICT", "Name or ID already belongs to another voice.")
155
+ versions = [q["version"] for q in existing if q["id"] == p["id"]]
156
+ p = {**p, "version": max(versions, default=0) + 1}
157
+ stage.mkdir(mode=0o700)
158
+ sample = stage / p["reference"]["file"]
159
+ with sample.open("xb") as f:
160
+ f.write(sample_bytes)
161
+ sample.chmod(0o600)
162
+ # Validate imported bytes too; duration metadata alone is not trusted.
163
+ seconds = audio_seconds(sample)
164
+ if abs(seconds - p["reference"]["seconds"]) > .1:
165
+ raise VoiceError("VOICE_INVALID", "Reference duration differs from its metadata.")
166
+ with (stage / "profile.json").open("x", encoding="utf-8") as f:
167
+ json.dump(p, f, ensure_ascii=False, indent=2)
168
+ (stage / "profile.json").chmod(0o600)
169
+ target = root / p["id"] / str(p["version"])
170
+ target.parent.mkdir(mode=0o700, exist_ok=True)
171
+ stage.rename(target)
172
+ return {"ok": True, "voice": p, "library": str(root), "reference_path": str(target / p["reference"]["file"])}
173
+ finally:
174
+ if stage.exists():
175
+ shutil.rmtree(stage)
176
+ os.close(fd)
177
+ lock.unlink()
178
+
179
+
180
+ def save(args):
181
+ path = Path(args.audio_file).expanduser().absolute()
182
+ seconds = audio_seconds(path)
183
+ data = path.read_bytes()
184
+ settings = {k: getattr(args, k) for k in SETTINGS if getattr(args, k, None) is not None}
185
+ profile = {"schema_version": 1, "id": args.id, "name": args.name, "version": 1, "description": args.description,
186
+ "style": args.style, "settings": settings,
187
+ "reference": {"file": "sample" + path.suffix.lower(), "sha256": hashlib.sha256(data).hexdigest(), "seconds": seconds}}
188
+ return commit(profile, data)
189
+
190
+
191
+ def export_voice(selector, destination):
192
+ p, sample = get(selector)
193
+ output = Path(destination).expanduser().absolute()
194
+ bundle = {"schema_version": 1, "profile": p, "audio_base64": base64.b64encode(sample.read_bytes()).decode("ascii")}
195
+ try:
196
+ with output.open("x", encoding="utf-8") as f:
197
+ json.dump(bundle, f, ensure_ascii=False)
198
+ except FileExistsError:
199
+ raise VoiceError("OUTPUT_EXISTS", "Export destination exists; choose a new filename.") from None
200
+ output.chmod(0o600)
201
+ return {"ok": True, "exported_to": str(output), "voice": {"id": p["id"], "name": p["name"], "version": p["version"]}}
202
+
203
+
204
+ def import_voice(filename):
205
+ path = Path(filename).expanduser()
206
+ regular(path)
207
+ try:
208
+ bundle = json.loads(path.read_text(encoding="utf-8"))
209
+ if not isinstance(bundle, dict) or set(bundle) != {"schema_version", "profile", "audio_base64"} or bundle["schema_version"] != 1:
210
+ raise ValueError()
211
+ p = validate_profile(bundle["profile"])
212
+ data = base64.b64decode(bundle["audio_base64"], validate=True)
213
+ except (ValueError, TypeError, KeyError):
214
+ raise VoiceError("VOICE_INVALID", "Invalid voice bundle.") from None
215
+ return commit(p, data)
216
+
217
+
218
+ def apply(args, narration):
219
+ p, sample = get(args.voice)
220
+ if any(getattr(args, flag, None) for flag in ["audio_reference", "audio_file", "image_url", "image_file"]) or args.generate_cmd == "image-ref":
221
+ raise VoiceError("VOICE_REFERENCE_CONFLICT", "Select either a named voice or explicit audio/image references.")
222
+ settings = dict(p["settings"])
223
+ for k in SETTINGS:
224
+ if getattr(args, k, None) is not None:
225
+ settings[k] = getattr(args, k)
226
+ prompt = "@音频1 使用参考音频中同一位讲述者,保持其音色和表达风格。" + p["style"] + "。单人干声,只朗读以下正文。\n\n" + narration
227
+ payload = build_payload(prompt=prompt, model=settings.get("model"), audio_files=[str(sample)], output_format=settings.get("format"), sample_rate=settings.get("sample_rate"), speech_rate=settings.get("speech_rate"), pitch_rate=settings.get("pitch_rate"), loudness_rate=settings.get("loudness_rate"), enable_subtitle=args.subtitle)
228
+ args.voice_receipt = {"id": p["id"], "name": p["name"], "version": p["version"], "reference_sha256": p["reference"]["sha256"], "style": p["style"], "effective_settings": {"model": payload["model"], **payload["audio_config"]}}
229
+ return payload