vcref 0.1.0__tar.gz
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-0.1.0/.gitignore +8 -0
- vcref-0.1.0/LICENSE +21 -0
- vcref-0.1.0/PKG-INFO +46 -0
- vcref-0.1.0/README.md +29 -0
- vcref-0.1.0/pyproject.toml +30 -0
- vcref-0.1.0/src/vcref/__init__.py +5 -0
- vcref-0.1.0/src/vcref/__main__.py +3 -0
- vcref-0.1.0/src/vcref/bundle.py +225 -0
- vcref-0.1.0/src/vcref/cli.py +34 -0
- vcref-0.1.0/tests/test_bundle.py +103 -0
vcref-0.1.0/.gitignore
ADDED
vcref-0.1.0/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.
|
vcref-0.1.0/PKG-INFO
ADDED
|
@@ -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]`.
|
vcref-0.1.0/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# vcref (Python)
|
|
2
|
+
|
|
3
|
+
Read, write and verify `.vcref` voice reference bundles. Format: [SPEC.md](https://github.com/bhavya091213/vcref/blob/main/SPEC.md).
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install vcref # no dependencies
|
|
7
|
+
pip install "vcref[audio]" # + soundfile/numpy: decode clips to arrays, pack from WAV/ndarray
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
import vcref
|
|
12
|
+
|
|
13
|
+
with vcref.Bundle("dean.vcref") as b: # path, bytes or file object
|
|
14
|
+
b.name, b.default, b.tags, b.config, b.cadence, b.id
|
|
15
|
+
b.verify() # SHA-256 of every clip and part
|
|
16
|
+
tag = b.select_tag("Really?") # first matching rule, else the default
|
|
17
|
+
clip = b.clip(tag) # Clip(tag, text, seconds, label, sample_rate, flac)
|
|
18
|
+
audio, sr = b.read(tag) # float32 array (needs the audio extra)
|
|
19
|
+
b.unpack("out/") # <tag>.flac + <tag>.txt (wav=True for WAV)
|
|
20
|
+
|
|
21
|
+
vcref.pack("voice.vcref",
|
|
22
|
+
{"neutral": {"wav": "neutral.wav", "text": "What is said in the clip."},
|
|
23
|
+
"question": {"flac": flac_bytes, "text": "Is it?"}},
|
|
24
|
+
name="voice", rules=[{"pattern": r"\?\s*$", "tag": "question"}], config={"engine": "voxcpm"})
|
|
25
|
+
|
|
26
|
+
vcref.set_cadence("voice.vcref", cadence_dict) # rewrite only the cadence block
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Errors are `vcref.VcrefError`. Command line: `vcref info|verify <file>`, `vcref unpack <file> <dir> [--wav]`.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vcref"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Read, write and verify .vcref voice reference bundles for zero-shot voice-cloning TTS."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["tts", "voice-cloning", "speech", "audio", "vcref"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
audio = ["soundfile>=0.12", "numpy"]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
vcref = "vcref.cli:main"
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/bhavya091213/vcref"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/vcref"]
|
|
@@ -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"
|
|
@@ -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
|
|
@@ -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,103 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import tempfile
|
|
3
|
+
import unittest
|
|
4
|
+
import zipfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import vcref
|
|
8
|
+
|
|
9
|
+
FIX = Path(__file__).resolve().parents[2] / "fixtures" / "tiny.vcref"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FixtureTests(unittest.TestCase):
|
|
13
|
+
def test_reads_shared_fixture(self):
|
|
14
|
+
with vcref.Bundle(FIX) as b:
|
|
15
|
+
self.assertEqual((b.name, b.default), ("tiny", "neutral"))
|
|
16
|
+
self.assertEqual(b.tags, ["neutral", "question"])
|
|
17
|
+
self.assertEqual(b.parts, ["A"])
|
|
18
|
+
self.assertEqual(b.config["lufs"], -16)
|
|
19
|
+
self.assertEqual(b.cadence["global"]["artic"], 5.6)
|
|
20
|
+
self.assertTrue(b.verify())
|
|
21
|
+
self.assertEqual(len(b.id), 12)
|
|
22
|
+
|
|
23
|
+
def test_rules_and_fallback(self):
|
|
24
|
+
with vcref.Bundle(FIX) as b:
|
|
25
|
+
self.assertEqual(b.select_tag("Really?"), "question")
|
|
26
|
+
self.assertEqual(b.select_tag("Plain."), "neutral")
|
|
27
|
+
self.assertEqual(b.clip("nope").tag, "neutral")
|
|
28
|
+
|
|
29
|
+
def test_clip_bytes_are_flac(self):
|
|
30
|
+
with vcref.Bundle(FIX) as b:
|
|
31
|
+
c = b.clip("question")
|
|
32
|
+
self.assertEqual(c.flac[:4], b"fLaC")
|
|
33
|
+
self.assertEqual(c.sample_rate, 16000)
|
|
34
|
+
x, sr = b.read("question")
|
|
35
|
+
self.assertEqual(sr, 16000)
|
|
36
|
+
self.assertAlmostEqual(len(x) / sr, 0.5, places=2)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RoundTripTests(unittest.TestCase):
|
|
40
|
+
def setUp(self):
|
|
41
|
+
self.tmp = Path(tempfile.mkdtemp())
|
|
42
|
+
|
|
43
|
+
def test_pack_from_flac_bytes_and_unpack(self):
|
|
44
|
+
with vcref.Bundle(FIX) as src:
|
|
45
|
+
flac = src.clip("neutral").flac
|
|
46
|
+
out = vcref.pack(self.tmp / "a.vcref", {"neutral": {"flac": flac, "text": "hello"}})
|
|
47
|
+
with vcref.Bundle(out) as b:
|
|
48
|
+
self.assertTrue(b.verify())
|
|
49
|
+
self.assertEqual(b.clip().flac, flac)
|
|
50
|
+
d = b.unpack(self.tmp / "u")
|
|
51
|
+
self.assertEqual((d / "neutral.txt").read_text(encoding="utf-8"), "hello")
|
|
52
|
+
self.assertEqual((d / "neutral.flac").read_bytes(), flac)
|
|
53
|
+
|
|
54
|
+
def test_set_cadence_keeps_audio(self):
|
|
55
|
+
import shutil
|
|
56
|
+
p = self.tmp / "c.vcref"
|
|
57
|
+
shutil.copy(FIX, p)
|
|
58
|
+
with vcref.Bundle(p) as b:
|
|
59
|
+
before = b.clip("neutral").flac, b.id
|
|
60
|
+
vcref.set_cadence(p, {"v": 1, "global": {"artic": 4.0}})
|
|
61
|
+
with vcref.Bundle(p) as b:
|
|
62
|
+
self.assertEqual((b.clip("neutral").flac, b.id), before)
|
|
63
|
+
self.assertEqual(b.cadence["global"]["artic"], 4.0)
|
|
64
|
+
|
|
65
|
+
def test_pack_validation(self):
|
|
66
|
+
with self.assertRaises(vcref.VcrefError):
|
|
67
|
+
vcref.pack(self.tmp / "x.vcref", {})
|
|
68
|
+
with self.assertRaises(vcref.VcrefError):
|
|
69
|
+
vcref.pack(self.tmp / "x.vcref", {"bad tag": {"flac": b"fLaC", "text": "t"}})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RejectTests(unittest.TestCase):
|
|
73
|
+
def test_bad_magic(self):
|
|
74
|
+
buf = io.BytesIO()
|
|
75
|
+
with zipfile.ZipFile(buf, "w") as z:
|
|
76
|
+
z.writestr("manifest.json", "{}")
|
|
77
|
+
with self.assertRaises(vcref.VcrefError):
|
|
78
|
+
vcref.Bundle(buf.getvalue())
|
|
79
|
+
|
|
80
|
+
def test_not_a_zip(self):
|
|
81
|
+
with self.assertRaises(vcref.VcrefError):
|
|
82
|
+
vcref.Bundle(b"nope")
|
|
83
|
+
|
|
84
|
+
def test_corruption_detected(self):
|
|
85
|
+
data = bytearray(FIX.read_bytes())
|
|
86
|
+
i = data.index(b"fLaC") + 60
|
|
87
|
+
data[i] ^= 0xFF
|
|
88
|
+
with vcref.Bundle(bytes(data)) as b:
|
|
89
|
+
with self.assertRaises(vcref.VcrefError):
|
|
90
|
+
b.verify()
|
|
91
|
+
|
|
92
|
+
def test_future_version_refused(self):
|
|
93
|
+
import json
|
|
94
|
+
buf = io.BytesIO()
|
|
95
|
+
with zipfile.ZipFile(buf, "w") as z:
|
|
96
|
+
z.comment = vcref.MAGIC
|
|
97
|
+
z.writestr("manifest.json", json.dumps({"format": "vcref", "version": 99, "default": "a", "clips": {"a": {}}}))
|
|
98
|
+
with self.assertRaises(vcref.VcrefError):
|
|
99
|
+
vcref.Bundle(buf.getvalue())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
unittest.main()
|