@petercjl/seedaudiocli 0.2.1 → 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/INSTALL.md +11 -0
- package/README.md +40 -7
- package/UPDATE.md +11 -0
- package/capabilities.json +475 -28
- package/npm/cli.mjs +31 -7
- package/npm/notice.mjs +1 -0
- package/npm/skills.mjs +21 -12
- package/npm/update.mjs +34 -22
- package/package.json +7 -3
- package/scripts/validate-release.mjs +14 -0
- package/seedaudiocli/cli.py +59 -3
- package/seedaudiocli/voices.py +229 -0
- package/skills/seedaudiocli/SKILL.md +19 -5
- package/skills/seedaudiocli/adapters/generic.json +23 -0
- package/skills/seedaudiocli/capabilities.json +6 -3
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
|
|
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') || '
|
|
26
|
-
if (args[1] === 'status')
|
|
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
|
|
33
|
-
|
|
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
|
-
|
|
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 (
|
|
28
|
-
|
|
29
|
-
if (!base
|
|
30
|
-
if (!base
|
|
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 =
|
|
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 =
|
|
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(
|
|
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]
|
|
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(
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
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}));
|
package/seedaudiocli/cli.py
CHANGED
|
@@ -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
|