vcref 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +22 -2
- package/bin/vcref.js +34 -0
- package/package.json +40 -4
- package/src/index.d.ts +96 -0
- package/src/index.js +242 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bhavya091213
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
-
#
|
|
1
|
+
# vcref (JavaScript)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Read, write and verify `.vcref` voice reference bundles. No dependencies; Node 18+ and browsers.
|
|
4
|
+
Format: [SPEC.md](https://github.com/bhavya091213/vcref/blob/main/SPEC.md).
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
npm install vcref
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
import { Bundle, pack } from "vcref";
|
|
12
|
+
|
|
13
|
+
const b = await Bundle.open("dean.vcref"); // Node; in a browser: Bundle.fromBytes(await (await fetch(url)).arrayBuffer())
|
|
14
|
+
b.name; b.default; b.tags; b.config; b.cadence;
|
|
15
|
+
await b.id();
|
|
16
|
+
await b.verify(); // SHA-256 of every clip and part
|
|
17
|
+
const clip = await b.clip(b.selectTag("Really?")); // { tag, text, seconds, label, sampleRate, flac: Uint8Array }
|
|
18
|
+
|
|
19
|
+
// FLAC bytes only: audio is not encoded or decoded here (browser: new AudioContext().decodeAudioData(clip.flac.buffer))
|
|
20
|
+
const bytes = await pack({ clips: { neutral: { flac, text: "What is said." } }, name: "voice", rules: [] });
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Errors are `VcrefError`. Command line: `npx vcref info|verify <file>`, `npx vcref unpack <file> <dir>`.
|
package/bin/vcref.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Bundle, VcrefError } from "../src/index.js";
|
|
5
|
+
|
|
6
|
+
const [cmd, file, dir] = process.argv.slice(2);
|
|
7
|
+
if (!["info", "verify", "unpack"].includes(cmd) || !file || (cmd === "unpack" && !dir)) {
|
|
8
|
+
console.error("usage: vcref info|verify <file.vcref> | vcref unpack <file.vcref> <dir>");
|
|
9
|
+
process.exit(2);
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
const b = await Bundle.open(file);
|
|
13
|
+
if (cmd === "info") {
|
|
14
|
+
console.log(`${b.name} id=${await b.id()} default=${b.default} rules=${(b.manifest.rules ?? []).length} parts=${b.parts.length} cadence=${b.cadence ? "yes" : "no"}`);
|
|
15
|
+
for (const [t, c] of Object.entries(b.manifest.clips)) console.log(` ${t.padEnd(12)} ${String((c.seconds ?? 0).toFixed(1)).padStart(6)}s ${String(c.sample_rate ?? 0).padStart(6)} Hz ${c.text.slice(0, 60)}`);
|
|
16
|
+
} else if (cmd === "verify") {
|
|
17
|
+
await b.verify();
|
|
18
|
+
console.log("ok");
|
|
19
|
+
} else {
|
|
20
|
+
await mkdir(dir, { recursive: true });
|
|
21
|
+
for (const t of b.tags) {
|
|
22
|
+
const c = await b.clip(t);
|
|
23
|
+
await writeFile(join(dir, `${t}.flac`), c.flac);
|
|
24
|
+
await writeFile(join(dir, `${t}.txt`), c.text, "utf8");
|
|
25
|
+
}
|
|
26
|
+
console.log(dir);
|
|
27
|
+
}
|
|
28
|
+
} catch (e) {
|
|
29
|
+
if (e instanceof VcrefError || e.code === "ENOENT") {
|
|
30
|
+
console.error(`vcref: ${e.message}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vcref",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Read, write and verify .vcref voice reference bundles for zero-shot voice-cloning TTS.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"vcref": "bin/vcref.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"bin",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test test/"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"tts",
|
|
31
|
+
"voice-cloning",
|
|
32
|
+
"speech",
|
|
33
|
+
"audio",
|
|
34
|
+
"vcref"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/bhavya091213/vcref.git",
|
|
40
|
+
"directory": "js"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export const MAGIC: "VCREF1";
|
|
2
|
+
export const VERSION: 1;
|
|
3
|
+
|
|
4
|
+
export class VcrefError extends Error {}
|
|
5
|
+
|
|
6
|
+
export interface ClipMeta {
|
|
7
|
+
file: string;
|
|
8
|
+
text: string;
|
|
9
|
+
seconds?: number;
|
|
10
|
+
label?: string;
|
|
11
|
+
source?: string;
|
|
12
|
+
sample_rate?: number;
|
|
13
|
+
sha256: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Rule {
|
|
17
|
+
pattern: string;
|
|
18
|
+
tag: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** See SPEC.md, "cadence". Every member is optional. */
|
|
22
|
+
export interface CadenceStats {
|
|
23
|
+
seconds?: number;
|
|
24
|
+
syll?: number;
|
|
25
|
+
artic?: number;
|
|
26
|
+
rate?: number;
|
|
27
|
+
pauses?: Record<string, { n: number; med: number; p90: number }>;
|
|
28
|
+
final?: number;
|
|
29
|
+
lead?: number[];
|
|
30
|
+
db?: number;
|
|
31
|
+
sentence_gap?: { n: number; med: number; p25: number; p90: number };
|
|
32
|
+
f0?: { med: number; spread: number; sd: number };
|
|
33
|
+
[k: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Cadence {
|
|
37
|
+
v?: number;
|
|
38
|
+
global?: CadenceStats;
|
|
39
|
+
tags?: Record<string, CadenceStats>;
|
|
40
|
+
sections?: Record<string, CadenceStats>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Manifest {
|
|
44
|
+
format: "vcref";
|
|
45
|
+
version: number;
|
|
46
|
+
name: string;
|
|
47
|
+
created?: string;
|
|
48
|
+
default: string;
|
|
49
|
+
clips: Record<string, ClipMeta>;
|
|
50
|
+
rules?: Rule[];
|
|
51
|
+
config?: Record<string, unknown>;
|
|
52
|
+
parts?: Record<string, { file: string; seconds?: number; sha256: string }>;
|
|
53
|
+
cadence?: Cadence;
|
|
54
|
+
[k: string]: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Clip {
|
|
58
|
+
tag: string;
|
|
59
|
+
text: string;
|
|
60
|
+
seconds: number | null;
|
|
61
|
+
label: string | null;
|
|
62
|
+
sampleRate: number | null;
|
|
63
|
+
flac: Uint8Array;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class Bundle {
|
|
67
|
+
manifest: Manifest;
|
|
68
|
+
static fromBytes(bytes: Uint8Array | ArrayBuffer): Promise<Bundle>;
|
|
69
|
+
/** Node only. */
|
|
70
|
+
static open(path: string): Promise<Bundle>;
|
|
71
|
+
readonly name: string;
|
|
72
|
+
readonly default: string;
|
|
73
|
+
readonly config: Record<string, unknown>;
|
|
74
|
+
readonly tags: string[];
|
|
75
|
+
readonly parts: string[];
|
|
76
|
+
readonly cadence: Cadence | null;
|
|
77
|
+
id(): Promise<string>;
|
|
78
|
+
rules(): { pattern: RegExp; tag: string }[];
|
|
79
|
+
selectTag(text: string): string;
|
|
80
|
+
clip(tag?: string): Promise<Clip>;
|
|
81
|
+
part(name: string): Promise<Uint8Array>;
|
|
82
|
+
verify(): Promise<true>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface PackOptions {
|
|
86
|
+
clips: Record<string, { text: string; flac: Uint8Array | ArrayBuffer; label?: string; source?: string; seconds?: number }>;
|
|
87
|
+
name?: string;
|
|
88
|
+
defaultTag?: string;
|
|
89
|
+
rules?: Rule[];
|
|
90
|
+
config?: Record<string, unknown>;
|
|
91
|
+
cadence?: Cadence | null;
|
|
92
|
+
parts?: Record<string, { flac: Uint8Array | ArrayBuffer; seconds?: number }>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function pack(options: PackOptions): Promise<Uint8Array>;
|
|
96
|
+
export function crc32(buf: Uint8Array): number;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// vcref: read, write and verify .vcref voice reference bundles (format spec: SPEC.md). No dependencies.
|
|
2
|
+
// Works in Node >= 18 and modern browsers (Web Crypto + DecompressionStream). Audio is returned as FLAC bytes;
|
|
3
|
+
// decode it with your audio stack (e.g. AudioContext.decodeAudioData in a browser).
|
|
4
|
+
|
|
5
|
+
export const MAGIC = "VCREF1";
|
|
6
|
+
export const VERSION = 1;
|
|
7
|
+
|
|
8
|
+
export class VcrefError extends Error {
|
|
9
|
+
constructor(msg) {
|
|
10
|
+
super(msg);
|
|
11
|
+
this.name = "VcrefError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const enc = new TextEncoder();
|
|
16
|
+
const dec = new TextDecoder("utf-8");
|
|
17
|
+
const TAG = /^[A-Za-z0-9_-]+$/;
|
|
18
|
+
|
|
19
|
+
const CRC_TABLE = (() => {
|
|
20
|
+
const t = new Uint32Array(256);
|
|
21
|
+
for (let n = 0; n < 256; n++) {
|
|
22
|
+
let c = n;
|
|
23
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
24
|
+
t[n] = c >>> 0;
|
|
25
|
+
}
|
|
26
|
+
return t;
|
|
27
|
+
})();
|
|
28
|
+
|
|
29
|
+
export function crc32(buf) {
|
|
30
|
+
let c = 0xffffffff;
|
|
31
|
+
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
32
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function sha256Hex(bytes) {
|
|
36
|
+
const d = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
|
|
37
|
+
return Array.from(d, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function inflateRaw(bytes) {
|
|
41
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
|
|
42
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parse a ZIP into {comment, entries: Map(name -> {method, csize, usize, crc, offset})}. */
|
|
46
|
+
function readZip(u8) {
|
|
47
|
+
const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
|
48
|
+
let eocd = -1;
|
|
49
|
+
for (let i = u8.length - 22; i >= Math.max(0, u8.length - 22 - 65535); i--) {
|
|
50
|
+
if (dv.getUint32(i, true) === 0x06054b50) {
|
|
51
|
+
eocd = i;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (eocd < 0) throw new VcrefError("not a readable vcref (no zip directory)");
|
|
56
|
+
const count = dv.getUint16(eocd + 10, true);
|
|
57
|
+
let p = dv.getUint32(eocd + 16, true);
|
|
58
|
+
const clen = dv.getUint16(eocd + 20, true);
|
|
59
|
+
const comment = dec.decode(u8.subarray(eocd + 22, eocd + 22 + clen));
|
|
60
|
+
const entries = new Map();
|
|
61
|
+
for (let i = 0; i < count; i++) {
|
|
62
|
+
if (dv.getUint32(p, true) !== 0x02014b50) throw new VcrefError("corrupt zip directory");
|
|
63
|
+
const method = dv.getUint16(p + 10, true);
|
|
64
|
+
const crc = dv.getUint32(p + 16, true);
|
|
65
|
+
const csize = dv.getUint32(p + 20, true);
|
|
66
|
+
const usize = dv.getUint32(p + 24, true);
|
|
67
|
+
const nlen = dv.getUint16(p + 28, true);
|
|
68
|
+
const xlen = dv.getUint16(p + 30, true);
|
|
69
|
+
const klen = dv.getUint16(p + 32, true);
|
|
70
|
+
const offset = dv.getUint32(p + 42, true);
|
|
71
|
+
const name = dec.decode(u8.subarray(p + 46, p + 46 + nlen));
|
|
72
|
+
entries.set(name, { method, crc, csize, usize, offset });
|
|
73
|
+
p += 46 + nlen + xlen + klen;
|
|
74
|
+
}
|
|
75
|
+
return { comment, entries };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function entryBytes(u8, e, name) {
|
|
79
|
+
const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
|
80
|
+
if (dv.getUint32(e.offset, true) !== 0x04034b50) throw new VcrefError(`corrupt entry ${name}`);
|
|
81
|
+
const start = e.offset + 30 + dv.getUint16(e.offset + 26, true) + dv.getUint16(e.offset + 28, true);
|
|
82
|
+
const raw = u8.subarray(start, start + e.csize);
|
|
83
|
+
let out;
|
|
84
|
+
if (e.method === 0) out = raw;
|
|
85
|
+
else if (e.method === 8) out = await inflateRaw(raw);
|
|
86
|
+
else throw new VcrefError(`entry ${name}: unsupported zip method ${e.method}`);
|
|
87
|
+
if (crc32(out) !== e.crc) throw new VcrefError(`corrupt entry ${name} (bad CRC-32)`);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class Bundle {
|
|
92
|
+
/** @param {Uint8Array} u8 whole file */
|
|
93
|
+
constructor(u8, zip, manifest) {
|
|
94
|
+
this._u8 = u8;
|
|
95
|
+
this._zip = zip;
|
|
96
|
+
this.manifest = manifest;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Parse a bundle from its bytes (Uint8Array, ArrayBuffer or Node Buffer). */
|
|
100
|
+
static async fromBytes(bytes) {
|
|
101
|
+
// plain Uint8Array view (a Node Buffer would leak Buffer-typed slices to callers)
|
|
102
|
+
const u8 = ArrayBuffer.isView(bytes) ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) : new Uint8Array(bytes);
|
|
103
|
+
const zip = readZip(u8);
|
|
104
|
+
if (zip.comment !== MAGIC) throw new VcrefError("not a vcref file (bad magic)");
|
|
105
|
+
const me = zip.entries.get("manifest.json");
|
|
106
|
+
if (!me) throw new VcrefError("bad manifest.json (missing)");
|
|
107
|
+
let m;
|
|
108
|
+
try {
|
|
109
|
+
m = JSON.parse(dec.decode(await entryBytes(u8, me, "manifest.json")));
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (e instanceof VcrefError) throw e;
|
|
112
|
+
throw new VcrefError(`bad manifest.json (${e.message})`);
|
|
113
|
+
}
|
|
114
|
+
if (m.format !== "vcref" || (m.version ?? 0) > VERSION) throw new VcrefError(`unsupported vcref version ${m.version}`);
|
|
115
|
+
if (!m.clips || !(m.default in m.clips)) throw new VcrefError("manifest default is not a clip");
|
|
116
|
+
return new Bundle(u8, zip, m);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Node only: read a bundle from disk. */
|
|
120
|
+
static async open(path) {
|
|
121
|
+
const { readFile } = await import("node:fs/promises");
|
|
122
|
+
return Bundle.fromBytes(await readFile(path));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get name() { return this.manifest.name; }
|
|
126
|
+
get default() { return this.manifest.default; }
|
|
127
|
+
get config() { return this.manifest.config ?? {}; }
|
|
128
|
+
get tags() { return Object.keys(this.manifest.clips); }
|
|
129
|
+
get parts() { return Object.keys(this.manifest.parts ?? {}); }
|
|
130
|
+
get cadence() { return this.manifest.cadence ?? null; }
|
|
131
|
+
|
|
132
|
+
/** 12-hex content id: hash of the sorted clip hashes (ignores names and settings). */
|
|
133
|
+
async id() {
|
|
134
|
+
const hashes = Object.values(this.manifest.clips).map((c) => c.sha256).sort();
|
|
135
|
+
return (await sha256Hex(enc.encode(hashes.join("")))).slice(0, 12);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** [{pattern: RegExp, tag}] in order. */
|
|
139
|
+
rules() {
|
|
140
|
+
return (this.manifest.rules ?? []).map((r) => ({ pattern: new RegExp(r.pattern), tag: r.tag }));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The clip tag the rules choose for `text` (first match), else the default. */
|
|
144
|
+
selectTag(text) {
|
|
145
|
+
for (const { pattern, tag } of this.rules()) if (pattern.test(text) && tag in this.manifest.clips) return tag;
|
|
146
|
+
return this.default;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async _read(file) {
|
|
150
|
+
const e = this._zip.entries.get(file);
|
|
151
|
+
if (!e) throw new VcrefError(`missing entry ${file}`);
|
|
152
|
+
return entryBytes(this._u8, e, file);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Clip for `tag` (unknown/undefined -> default) with FLAC bytes. */
|
|
156
|
+
async clip(tag) {
|
|
157
|
+
if (!(tag in this.manifest.clips)) tag = this.default;
|
|
158
|
+
const c = this.manifest.clips[tag];
|
|
159
|
+
return { tag, text: c.text, seconds: c.seconds ?? null, label: c.label ?? null, sampleRate: c.sample_rate ?? null, flac: await this._read(c.file) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** FLAC bytes of an enrollment recording. */
|
|
163
|
+
async part(name) {
|
|
164
|
+
return this._read(this.manifest.parts[name].file);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Throws VcrefError on any hash mismatch or missing entry; resolves true otherwise. */
|
|
168
|
+
async verify() {
|
|
169
|
+
for (const kind of ["clips", "parts"]) {
|
|
170
|
+
for (const [key, meta] of Object.entries(this.manifest[kind] ?? {})) {
|
|
171
|
+
if ((await sha256Hex(await this._read(meta.file))) !== meta.sha256) {
|
|
172
|
+
throw new VcrefError(`${kind.slice(0, -1)} '${key}' is corrupt (hash mismatch)`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function flacRate(data) {
|
|
181
|
+
if (data.length < 22 || dec.decode(data.subarray(0, 4)) !== "fLaC") throw new VcrefError("flac bytes do not start with 'fLaC'");
|
|
182
|
+
return ((data[18] << 12) | (data[19] << 4) | (data[20] >> 4)) >>> 0; // STREAMINFO sample rate (20 bits)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function u16(n) { const b = new Uint8Array(2); new DataView(b.buffer).setUint16(0, n, true); return b; }
|
|
186
|
+
function u32(n) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n >>> 0, true); return b; }
|
|
187
|
+
function cat(parts) {
|
|
188
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
189
|
+
let o = 0;
|
|
190
|
+
for (const p of parts) { out.set(p, o); o += p.length; }
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function zipStored(files, comment) {
|
|
195
|
+
const locals = [], central = [];
|
|
196
|
+
let offset = 0;
|
|
197
|
+
for (const [name, data] of files) {
|
|
198
|
+
const nb = enc.encode(name), crc = crc32(data);
|
|
199
|
+
const local = cat([u32(0x04034b50), u16(20), u16(0x0800), u16(0), u16(0), u16(0x21), u32(crc), u32(data.length), u32(data.length), u16(nb.length), u16(0), nb, data]);
|
|
200
|
+
central.push(cat([u32(0x02014b50), u16(20), u16(20), u16(0x0800), u16(0), u16(0), u16(0x21), u32(crc), u32(data.length), u32(data.length), u16(nb.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(offset), nb]));
|
|
201
|
+
locals.push(local);
|
|
202
|
+
offset += local.length;
|
|
203
|
+
}
|
|
204
|
+
const dir = cat(central), cb = enc.encode(comment);
|
|
205
|
+
return cat([...locals, dir, u32(0x06054b50), u16(0), u16(0), u16(files.length), u16(files.length), u32(dir.length), u32(offset), u16(cb.length), cb]);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Build a bundle and return its bytes (Uint8Array).
|
|
210
|
+
* clips: {tag: {text, flac: Uint8Array, label?, source?, seconds?}} parts: {name: {flac, seconds?}}
|
|
211
|
+
* Audio must already be FLAC; this library does not encode audio.
|
|
212
|
+
*/
|
|
213
|
+
export async function pack({ clips, name = "voice", defaultTag, rules = [], config = {}, cadence = null, parts = {} }) {
|
|
214
|
+
const tags = Object.keys(clips ?? {});
|
|
215
|
+
if (!tags.length) throw new VcrefError("a bundle needs at least one clip");
|
|
216
|
+
const files = [], mclips = {}, mparts = {};
|
|
217
|
+
for (const tag of tags) {
|
|
218
|
+
if (!TAG.test(tag)) throw new VcrefError(`bad tag '${tag}': use letters, digits, '_' or '-'`);
|
|
219
|
+
const spec = clips[tag];
|
|
220
|
+
if (!spec.text) throw new VcrefError(`clip '${tag}' has no transcript text`);
|
|
221
|
+
const flac = new Uint8Array(spec.flac);
|
|
222
|
+
files.push([`clips/${tag}.flac`, flac]);
|
|
223
|
+
const entry = { file: `clips/${tag}.flac`, text: spec.text, seconds: spec.seconds, label: spec.label, source: spec.source, sample_rate: flacRate(flac), sha256: await sha256Hex(flac) };
|
|
224
|
+
mclips[tag] = Object.fromEntries(Object.entries(entry).filter(([, v]) => v !== undefined));
|
|
225
|
+
}
|
|
226
|
+
for (const [pn, spec] of Object.entries(parts)) {
|
|
227
|
+
if (!TAG.test(pn)) throw new VcrefError(`bad part name '${pn}'`);
|
|
228
|
+
const flac = new Uint8Array(spec.flac);
|
|
229
|
+
files.push([`parts/${pn}.flac`, flac]);
|
|
230
|
+
mparts[pn] = Object.fromEntries(Object.entries({ file: `parts/${pn}.flac`, seconds: spec.seconds, sha256: await sha256Hex(flac) }).filter(([, v]) => v !== undefined));
|
|
231
|
+
}
|
|
232
|
+
const def = defaultTag ?? (tags.includes("neutral") ? "neutral" : tags[0]);
|
|
233
|
+
if (!(def in mclips)) throw new VcrefError(`default '${def}' is not a clip`);
|
|
234
|
+
for (const r of rules) {
|
|
235
|
+
new RegExp(r.pattern); // fail now, not at read time
|
|
236
|
+
if (!(r.tag in mclips)) throw new VcrefError(`rule tag '${r.tag}' is not a clip`);
|
|
237
|
+
}
|
|
238
|
+
const manifest = { format: "vcref", version: VERSION, name, created: new Date().toISOString().slice(0, 10), default: def, clips: mclips, rules, config, parts: mparts };
|
|
239
|
+
if (cadence) manifest.cadence = cadence;
|
|
240
|
+
files.push(["manifest.json", enc.encode(JSON.stringify(manifest, null, 1))]);
|
|
241
|
+
return zipStored(files, MAGIC);
|
|
242
|
+
}
|