vcref 0.1.0__py3-none-any.whl

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.
vcref/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """vcref: read, write and verify .vcref voice reference bundles (format spec: SPEC.md)."""
2
+ from .bundle import MAGIC, VERSION, Bundle, Clip, VcrefError, pack, set_cadence
3
+
4
+ __all__ = ["Bundle", "Clip", "VcrefError", "pack", "set_cadence", "MAGIC", "VERSION"]
5
+ __version__ = "0.1.0"
vcref/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
vcref/bundle.py ADDED
@@ -0,0 +1,225 @@
1
+ """The .vcref container. Standard library only; decoding audio to arrays needs the optional `audio` extra."""
2
+ import hashlib
3
+ import io
4
+ import json
5
+ import re
6
+ import struct
7
+ import time
8
+ import zipfile
9
+ from collections import namedtuple
10
+ from pathlib import Path
11
+
12
+ MAGIC = b"VCREF1"
13
+ VERSION = 1
14
+ _TAG = re.compile(r"^[A-Za-z0-9_-]+$")
15
+
16
+ Clip = namedtuple("Clip", "tag text seconds label sample_rate flac")
17
+
18
+
19
+ class VcrefError(Exception):
20
+ pass
21
+
22
+
23
+ def _sha(b):
24
+ return hashlib.sha256(b).hexdigest()
25
+
26
+
27
+ def _check_tag(tag):
28
+ if not _TAG.match(tag):
29
+ raise VcrefError(f"bad tag {tag!r}: use letters, digits, '_' or '-'")
30
+
31
+
32
+ def _soundfile():
33
+ try:
34
+ import soundfile as sf
35
+ except ImportError:
36
+ raise VcrefError("this needs the audio extra: pip install 'vcref[audio]'")
37
+ return sf
38
+
39
+
40
+ def _flac_rate(data):
41
+ if data[:4] != b"fLaC":
42
+ raise VcrefError("flac bytes do not start with 'fLaC'")
43
+ return (struct.unpack(">I", data[18:22])[0] >> 12) & 0xFFFFF # STREAMINFO sample rate (20 bits)
44
+
45
+
46
+ def pack(out, clips, name=None, default=None, rules=None, config=None, cadence=None, parts=None):
47
+ """Write a bundle and return its path.
48
+
49
+ clips {tag: {"text": str, plus one of "flac": bytes | "wav": path | "audio": (ndarray, sr),
50
+ optional "label", "source"}}
51
+ "wav"/"audio" are encoded to 24-bit FLAC (needs vcref[audio]); "flac" bytes are stored as given.
52
+ parts {name: same audio keys} optional full enrollment recordings
53
+ """
54
+ if not clips:
55
+ raise VcrefError("a bundle needs at least one clip")
56
+ out = Path(out)
57
+ out.parent.mkdir(parents=True, exist_ok=True)
58
+
59
+ def to_flac(spec):
60
+ if "flac" in spec:
61
+ data = bytes(spec["flac"])
62
+ return data, _flac_rate(data), spec.get("seconds")
63
+ sf = _soundfile()
64
+ if "wav" in spec:
65
+ x, sr = sf.read(spec["wav"], dtype="float32")
66
+ elif "audio" in spec:
67
+ x, sr = spec["audio"]
68
+ else:
69
+ raise VcrefError("each clip needs 'flac', 'wav' or 'audio'")
70
+ buf = io.BytesIO()
71
+ sf.write(buf, x, sr, format="FLAC", subtype="PCM_24")
72
+ return buf.getvalue(), int(sr), round(len(x) / sr, 2)
73
+
74
+ tmp = out.with_suffix(out.suffix + ".tmp")
75
+ mclips, mparts = {}, {}
76
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_STORED) as z:
77
+ z.comment = MAGIC
78
+ for tag, spec in clips.items():
79
+ _check_tag(tag)
80
+ if not spec.get("text"):
81
+ raise VcrefError(f"clip {tag!r} has no transcript text")
82
+ data, sr, secs = to_flac(spec)
83
+ z.writestr(f"clips/{tag}.flac", data)
84
+ entry = {"file": f"clips/{tag}.flac", "text": spec["text"], "seconds": secs, "label": spec.get("label"),
85
+ "source": spec.get("source"), "sample_rate": sr, "sha256": _sha(data)}
86
+ mclips[tag] = {k: v for k, v in entry.items() if v is not None}
87
+ for pname, spec in (parts or {}).items():
88
+ _check_tag(pname)
89
+ data, sr, secs = to_flac(spec)
90
+ z.writestr(f"parts/{pname}.flac", data)
91
+ entry = {"file": f"parts/{pname}.flac", "seconds": secs, "sha256": _sha(data)}
92
+ mparts[pname] = {k: v for k, v in entry.items() if v is not None}
93
+ default = default or ("neutral" if "neutral" in mclips else next(iter(mclips)))
94
+ if default not in mclips:
95
+ raise VcrefError(f"default {default!r} is not a clip")
96
+ for r in rules or []:
97
+ re.compile(r["pattern"]) # fail now, not at read time
98
+ if r["tag"] not in mclips:
99
+ raise VcrefError(f"rule tag {r['tag']!r} is not a clip")
100
+ manifest = {"format": "vcref", "version": VERSION, "name": name or out.stem, "created": time.strftime("%Y-%m-%d"),
101
+ "default": default, "clips": mclips, "rules": rules or [], "config": config or {}, "parts": mparts}
102
+ if cadence:
103
+ manifest["cadence"] = cadence
104
+ z.writestr("manifest.json", json.dumps(manifest, indent=1, ensure_ascii=False))
105
+ tmp.replace(out)
106
+ return out
107
+
108
+
109
+ class Bundle:
110
+ """Open a bundle from a path, bytes or a binary file object."""
111
+
112
+ def __init__(self, source):
113
+ if isinstance(source, (bytes, bytearray)):
114
+ source = io.BytesIO(source)
115
+ try:
116
+ self._z = zipfile.ZipFile(source)
117
+ except (zipfile.BadZipFile, FileNotFoundError) as e:
118
+ raise VcrefError(f"not a readable vcref ({e})")
119
+ self.path = Path(source) if isinstance(source, (str, Path)) else None
120
+ if self._z.comment != MAGIC:
121
+ raise VcrefError("not a vcref file (bad magic)")
122
+ try:
123
+ m = json.loads(self._z.read("manifest.json"))
124
+ except (KeyError, ValueError) as e:
125
+ raise VcrefError(f"bad manifest.json ({e})")
126
+ if m.get("format") != "vcref" or m.get("version", 0) > VERSION:
127
+ raise VcrefError(f"unsupported vcref version {m.get('version')}")
128
+ if m.get("default") not in m.get("clips", {}):
129
+ raise VcrefError("manifest default is not a clip")
130
+ self.manifest = m
131
+
132
+ def _read(self, name):
133
+ try:
134
+ return self._z.read(name)
135
+ except KeyError:
136
+ raise VcrefError(f"missing entry {name}")
137
+ except zipfile.BadZipFile as e:
138
+ raise VcrefError(f"corrupt entry {name} ({e})")
139
+
140
+ def close(self):
141
+ self._z.close()
142
+
143
+ def __enter__(self):
144
+ return self
145
+
146
+ def __exit__(self, *exc):
147
+ self.close()
148
+
149
+ name = property(lambda s: s.manifest["name"])
150
+ default = property(lambda s: s.manifest["default"])
151
+ config = property(lambda s: s.manifest.get("config", {}))
152
+ tags = property(lambda s: list(s.manifest["clips"]))
153
+ parts = property(lambda s: list(s.manifest.get("parts", {})))
154
+ cadence = property(lambda s: s.manifest.get("cadence"))
155
+
156
+ @property
157
+ def id(self):
158
+ """12-hex content id: hash of the sorted clip hashes (ignores names and settings)."""
159
+ return _sha("".join(sorted(c["sha256"] for c in self.manifest["clips"].values())).encode())[:12]
160
+
161
+ def rules(self):
162
+ """[(compiled regex, tag)] in order."""
163
+ return [(re.compile(r["pattern"]), r["tag"]) for r in self.manifest.get("rules", [])]
164
+
165
+ def select_tag(self, text):
166
+ """The clip tag the rules choose for `text` (first match), else the default."""
167
+ for rx, tag in self.rules():
168
+ if rx.search(text) and tag in self.manifest["clips"]:
169
+ return tag
170
+ return self.default
171
+
172
+ def clip(self, tag=None):
173
+ """Clip for `tag` (unknown or None -> default) with its FLAC bytes."""
174
+ if tag not in self.manifest["clips"]:
175
+ tag = self.default
176
+ c = self.manifest["clips"][tag]
177
+ return Clip(tag, c["text"], c.get("seconds"), c.get("label"), c.get("sample_rate"), self._read(c["file"]))
178
+
179
+ def part(self, name):
180
+ """FLAC bytes of an enrollment recording."""
181
+ return self._read(self.manifest["parts"][name]["file"])
182
+
183
+ def read(self, tag=None):
184
+ """(float32 ndarray, sample_rate) for a clip. Needs vcref[audio]."""
185
+ return _soundfile().read(io.BytesIO(self.clip(tag).flac), dtype="float32")
186
+
187
+ def verify(self):
188
+ """Raise VcrefError on any hash mismatch or missing entry; return True otherwise."""
189
+ for kind in ("clips", "parts"):
190
+ for key, meta in self.manifest.get(kind, {}).items():
191
+ data = self._read(meta["file"])
192
+ if _sha(data) != meta["sha256"]:
193
+ raise VcrefError(f"{kind[:-1]} {key!r} is corrupt (hash mismatch)")
194
+ return True
195
+
196
+ def unpack(self, out_dir, wav=False):
197
+ """Write <tag>.flac (or .wav with the audio extra) and <tag>.txt for every clip."""
198
+ out_dir = Path(out_dir)
199
+ out_dir.mkdir(parents=True, exist_ok=True)
200
+ for tag in self.tags:
201
+ c = self.clip(tag)
202
+ if wav:
203
+ x, sr = self.read(tag)
204
+ _soundfile().write(out_dir / f"{tag}.wav", x, sr, subtype="PCM_24")
205
+ else:
206
+ (out_dir / f"{tag}.flac").write_bytes(c.flac)
207
+ (out_dir / f"{tag}.txt").write_text(c.text, encoding="utf-8")
208
+ return out_dir
209
+
210
+
211
+ def set_cadence(path, cadence):
212
+ """Replace the cadence block of an existing bundle; every other entry is copied byte for byte."""
213
+ path = Path(path)
214
+ with Bundle(path) as b:
215
+ m = dict(b.manifest)
216
+ m["cadence"] = cadence
217
+ tmp = path.with_suffix(path.suffix + ".tmp")
218
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_STORED) as z:
219
+ z.comment = MAGIC
220
+ for n in b._z.namelist():
221
+ if n != "manifest.json":
222
+ z.writestr(n, b._z.read(n))
223
+ z.writestr("manifest.json", json.dumps(m, indent=1, ensure_ascii=False))
224
+ tmp.replace(path)
225
+ return path
vcref/cli.py ADDED
@@ -0,0 +1,34 @@
1
+ import argparse
2
+ import sys
3
+
4
+ from .bundle import Bundle, VcrefError
5
+
6
+
7
+ def main(argv=None):
8
+ ap = argparse.ArgumentParser(prog="vcref", description="Inspect and check .vcref voice bundles")
9
+ sub = ap.add_subparsers(dest="cmd", required=True)
10
+ for n in ("info", "verify"):
11
+ sub.add_parser(n).add_argument("file")
12
+ u = sub.add_parser("unpack")
13
+ u.add_argument("file")
14
+ u.add_argument("dir")
15
+ u.add_argument("--wav", action="store_true", help="decode to 24-bit WAV (needs vcref[audio])")
16
+ a = ap.parse_args(argv)
17
+ try:
18
+ with Bundle(a.file) as b:
19
+ if a.cmd == "info":
20
+ print(f"{b.name} id={b.id} default={b.default} rules={len(b.manifest.get('rules', []))} "
21
+ f"parts={len(b.parts)} cadence={'yes' if b.cadence else 'no'}")
22
+ for t, c in b.manifest["clips"].items():
23
+ print(f" {t:12} {c.get('seconds') or 0:6.1f}s {c.get('sample_rate') or 0:>6} Hz {c['text'][:60]}")
24
+ elif a.cmd == "verify":
25
+ b.verify()
26
+ print("ok")
27
+ else:
28
+ print(b.unpack(a.dir, wav=a.wav))
29
+ except VcrefError as e:
30
+ sys.exit(f"vcref: {e}")
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.5
2
+ Name: vcref
3
+ Version: 0.1.0
4
+ Summary: Read, write and verify .vcref voice reference bundles for zero-shot voice-cloning TTS.
5
+ Project-URL: Homepage, https://github.com/bhavya091213/vcref
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: audio,speech,tts,vcref,voice-cloning
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
12
+ Requires-Python: >=3.9
13
+ Provides-Extra: audio
14
+ Requires-Dist: numpy; extra == 'audio'
15
+ Requires-Dist: soundfile>=0.12; extra == 'audio'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # vcref (Python)
19
+
20
+ Read, write and verify `.vcref` voice reference bundles. Format: [SPEC.md](https://github.com/bhavya091213/vcref/blob/main/SPEC.md).
21
+
22
+ ```
23
+ pip install vcref # no dependencies
24
+ pip install "vcref[audio]" # + soundfile/numpy: decode clips to arrays, pack from WAV/ndarray
25
+ ```
26
+
27
+ ```python
28
+ import vcref
29
+
30
+ with vcref.Bundle("dean.vcref") as b: # path, bytes or file object
31
+ b.name, b.default, b.tags, b.config, b.cadence, b.id
32
+ b.verify() # SHA-256 of every clip and part
33
+ tag = b.select_tag("Really?") # first matching rule, else the default
34
+ clip = b.clip(tag) # Clip(tag, text, seconds, label, sample_rate, flac)
35
+ audio, sr = b.read(tag) # float32 array (needs the audio extra)
36
+ b.unpack("out/") # <tag>.flac + <tag>.txt (wav=True for WAV)
37
+
38
+ vcref.pack("voice.vcref",
39
+ {"neutral": {"wav": "neutral.wav", "text": "What is said in the clip."},
40
+ "question": {"flac": flac_bytes, "text": "Is it?"}},
41
+ name="voice", rules=[{"pattern": r"\?\s*$", "tag": "question"}], config={"engine": "voxcpm"})
42
+
43
+ vcref.set_cadence("voice.vcref", cadence_dict) # rewrite only the cadence block
44
+ ```
45
+
46
+ Errors are `vcref.VcrefError`. Command line: `vcref info|verify <file>`, `vcref unpack <file> <dir> [--wav]`.
@@ -0,0 +1,9 @@
1
+ vcref/__init__.py,sha256=njjnW7WM1rQ34EW-uvsXcQcnsAb17Jlpz_MhqUjSYk0,280
2
+ vcref/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
3
+ vcref/bundle.py,sha256=cfxtPtYr-C2R3-mPbvw5QJIR55np9igVbnmOYSIQ8uA,8908
4
+ vcref/cli.py,sha256=azrGqKMFPY0Yuhp8DCN5W8eT0CUcTVMgBjP_Kzgw5LA,1248
5
+ vcref-0.1.0.dist-info/METADATA,sha256=G4MJVnYOeugIo6mrNjARyDKcSI6Vlf1fDiNFiuJrw3w,2007
6
+ vcref-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ vcref-0.1.0.dist-info/entry_points.txt,sha256=l9lx4X7etWIUJBU0Iayakplg_3i90hAdwDFIXttUKcA,41
8
+ vcref-0.1.0.dist-info/licenses/LICENSE,sha256=Duv0nbattyqyG15S7Q8_7BCmnIyaIQJOghA0mxVmZXM,1069
9
+ vcref-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vcref = vcref.cli:main
@@ -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.