voxedge 0.0.1a0__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.
- voxedge/__init__.py +10 -0
- voxedge/artifacts/__init__.py +48 -0
- voxedge/artifacts/download.py +213 -0
- voxedge/artifacts/manifest.json +58 -0
- voxedge/artifacts/manifest.py +209 -0
- voxedge/backends/__init__.py +36 -0
- voxedge/backends/_deps.py +100 -0
- voxedge/backends/base.py +525 -0
- voxedge/backends/jetson/__init__.py +42 -0
- voxedge/backends/jetson/_trt_edge_llm_util.py +220 -0
- voxedge/backends/jetson/_util.py +276 -0
- voxedge/backends/jetson/kokoro_trt.py +1385 -0
- voxedge/backends/jetson/matcha_trt.py +848 -0
- voxedge/backends/jetson/moss_tts_nano.py +533 -0
- voxedge/backends/jetson/paraformer_trt.py +1210 -0
- voxedge/backends/jetson/qwen3_trt.py +689 -0
- voxedge/backends/jetson/trt_edge_llm_asr.py +1168 -0
- voxedge/backends/jetson/trt_edge_llm_ipc.py +295 -0
- voxedge/backends/jetson/trt_edge_llm_tts.py +1369 -0
- voxedge/backends/jetson/worker_io.py +307 -0
- voxedge/backends/llm_base.py +29 -0
- voxedge/backends/llm_translator.py +224 -0
- voxedge/backends/mock.py +344 -0
- voxedge/backends/nllb_translator.py +179 -0
- voxedge/backends/rk/__init__.py +30 -0
- voxedge/backends/rk/_util.py +100 -0
- voxedge/backends/rk/artifacts.py +221 -0
- voxedge/backends/rk/asr.py +580 -0
- voxedge/backends/rk/runtime.py +170 -0
- voxedge/backends/rk/tts.py +280 -0
- voxedge/backends/sherpa/__init__.py +22 -0
- voxedge/backends/sherpa/_util.py +98 -0
- voxedge/backends/sherpa/asr.py +419 -0
- voxedge/backends/sherpa/tts.py +325 -0
- voxedge/engine/__init__.py +23 -0
- voxedge/engine/asr_session_manager.py +457 -0
- voxedge/engine/builtin_tools.py +32 -0
- voxedge/engine/capability_resolver.py +238 -0
- voxedge/engine/concurrency_capability.py +60 -0
- voxedge/engine/conversation.py +1607 -0
- voxedge/engine/coordinator.py +138 -0
- voxedge/engine/tool_registry.py +452 -0
- voxedge/engine/tts_buffer.py +167 -0
- voxedge/transport/__init__.py +10 -0
- voxedge/transport/base.py +255 -0
- voxedge-0.0.1a0.dist-info/METADATA +74 -0
- voxedge-0.0.1a0.dist-info/RECORD +49 -0
- voxedge-0.0.1a0.dist-info/WHEEL +5 -0
- voxedge-0.0.1a0.dist-info/top_level.txt +1 -0
voxedge/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""voxedge — edge-native real-time voice conversation library.
|
|
2
|
+
|
|
3
|
+
Phase 1a: pure-Python foundation. Core stays importable with no CUDA / torch /
|
|
4
|
+
tensorrt — heavy backends live behind optional extras.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
__version__ = "0.0.1a0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Backend-agnostic runtime artifact manifest + env-free download helper.
|
|
2
|
+
|
|
3
|
+
The package is
|
|
4
|
+
pure-Python and env-free; ``huggingface_hub`` is an optional dependency only
|
|
5
|
+
needed for the default network downloader (``voxedge[artifacts]``).
|
|
6
|
+
|
|
7
|
+
Public API::
|
|
8
|
+
|
|
9
|
+
from voxedge.artifacts import (
|
|
10
|
+
load_manifest, parse_manifest, default_manifest_path,
|
|
11
|
+
resolve_artifact, ArtifactInstallResult,
|
|
12
|
+
ManifestError, ArtifactError,
|
|
13
|
+
)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .download import (
|
|
19
|
+
DEFAULT_HF_ENDPOINT,
|
|
20
|
+
ArtifactError,
|
|
21
|
+
ArtifactInstallResult,
|
|
22
|
+
resolve_artifact,
|
|
23
|
+
)
|
|
24
|
+
from .manifest import (
|
|
25
|
+
SCHEMA_VERSION,
|
|
26
|
+
ArtifactFile,
|
|
27
|
+
ArtifactManifest,
|
|
28
|
+
ArtifactSpec,
|
|
29
|
+
ManifestError,
|
|
30
|
+
default_manifest_path,
|
|
31
|
+
load_manifest,
|
|
32
|
+
parse_manifest,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"SCHEMA_VERSION",
|
|
37
|
+
"DEFAULT_HF_ENDPOINT",
|
|
38
|
+
"ArtifactFile",
|
|
39
|
+
"ArtifactSpec",
|
|
40
|
+
"ArtifactManifest",
|
|
41
|
+
"ArtifactInstallResult",
|
|
42
|
+
"ManifestError",
|
|
43
|
+
"ArtifactError",
|
|
44
|
+
"parse_manifest",
|
|
45
|
+
"load_manifest",
|
|
46
|
+
"default_manifest_path",
|
|
47
|
+
"resolve_artifact",
|
|
48
|
+
]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Env-free, backend-agnostic artifact download + install helper.
|
|
2
|
+
|
|
3
|
+
Generalises the RK-specific ``ensure_rk_artifacts`` prototype
|
|
4
|
+
(``voxedge/backends/rk/artifacts.py``) into a backend-agnostic helper keyed by
|
|
5
|
+
a stable ``artifact_ref``. The public entry point is :func:`resolve_artifact`,
|
|
6
|
+
which:
|
|
7
|
+
|
|
8
|
+
1. looks up the artifact by ``artifact_ref`` in the manifest,
|
|
9
|
+
2. for each declared file, downloads it from the HF repo (or any injected
|
|
10
|
+
downloader) into ``install_root/<path>``,
|
|
11
|
+
3. verifies the SHA-256 of every file (already-present + correct files are
|
|
12
|
+
skipped),
|
|
13
|
+
4. fails loudly with :class:`ArtifactError` on a missing file or SHA mismatch
|
|
14
|
+
(preflight semantics — e.g. a missing ``style.npy`` voice pack must fail,
|
|
15
|
+
not silently produce silent audio),
|
|
16
|
+
5. returns an :class:`ArtifactInstallResult` recording what was installed vs
|
|
17
|
+
skipped.
|
|
18
|
+
|
|
19
|
+
**Env-free**: every input is a function argument; nothing reads ``os.environ``.
|
|
20
|
+
The install root is supplied by the caller (product / profile) — the manifest
|
|
21
|
+
only stores artifact-relative paths.
|
|
22
|
+
|
|
23
|
+
``huggingface_hub`` is an OPTIONAL dependency. The default downloader uses it,
|
|
24
|
+
but tests (and offline callers) inject a local downloader via the
|
|
25
|
+
``download_fn`` parameter, so the manifest/SHA logic needs no network.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Callable, Optional
|
|
34
|
+
|
|
35
|
+
from .manifest import (
|
|
36
|
+
ArtifactFile,
|
|
37
|
+
ArtifactManifest,
|
|
38
|
+
ArtifactSpec,
|
|
39
|
+
default_manifest_path,
|
|
40
|
+
load_manifest,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
DEFAULT_HF_ENDPOINT = "https://huggingface.co"
|
|
44
|
+
|
|
45
|
+
# A downloader fetches one file from an HF repo into ``dest``.
|
|
46
|
+
# Signature: (repo_id, revision, source_path, dest, endpoint) -> None
|
|
47
|
+
DownloadFn = Callable[[str, str, str, Path, str], None]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ArtifactError(RuntimeError):
|
|
51
|
+
"""Raised when an artifact cannot be resolved, downloaded, or verified."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ArtifactInstallResult:
|
|
56
|
+
"""Outcome of :func:`resolve_artifact`."""
|
|
57
|
+
|
|
58
|
+
artifact_ref: str
|
|
59
|
+
install_root: Path
|
|
60
|
+
installed: list[str] = field(default_factory=list) # rel paths downloaded
|
|
61
|
+
skipped: list[str] = field(default_factory=list) # rel paths already present
|
|
62
|
+
files: list[Path] = field(default_factory=list) # absolute installed paths
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def ready(self) -> bool:
|
|
66
|
+
"""True when every declared file is present on disk (installed or
|
|
67
|
+
already-present). ``resolve_artifact`` raises before returning if any
|
|
68
|
+
file is missing/mismatched, so a returned result is always ready."""
|
|
69
|
+
return bool(self.files) and not self.missing()
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def all_skipped(self) -> bool:
|
|
73
|
+
"""True when nothing needed downloading (every file already present)."""
|
|
74
|
+
return bool(self.files) and not self.installed
|
|
75
|
+
|
|
76
|
+
def missing(self) -> list[str]: # pragma: no cover - resolve raises first
|
|
77
|
+
return [str(p) for p in self.files if not p.exists()]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _sha256(path: Path) -> str:
|
|
81
|
+
h = hashlib.sha256()
|
|
82
|
+
with path.open("rb") as fh:
|
|
83
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
84
|
+
h.update(chunk)
|
|
85
|
+
return h.hexdigest()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _hf_download(
|
|
89
|
+
repo_id: str, revision: str, source_path: str, dest: Path, endpoint: str
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Default downloader backed by ``huggingface_hub.hf_hub_download``.
|
|
92
|
+
|
|
93
|
+
Imported lazily so the core package stays installable without the optional
|
|
94
|
+
``huggingface_hub`` dependency.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
from huggingface_hub import hf_hub_download # type: ignore
|
|
98
|
+
except ImportError as exc: # pragma: no cover - exercised only without extra
|
|
99
|
+
raise ArtifactError(
|
|
100
|
+
"huggingface_hub is required to download artifacts; install "
|
|
101
|
+
"voxedge[artifacts] or inject a custom download_fn."
|
|
102
|
+
) from exc
|
|
103
|
+
|
|
104
|
+
import shutil
|
|
105
|
+
|
|
106
|
+
src = hf_hub_download(
|
|
107
|
+
repo_id=repo_id,
|
|
108
|
+
filename=source_path,
|
|
109
|
+
revision=revision or "main",
|
|
110
|
+
endpoint=endpoint or DEFAULT_HF_ENDPOINT,
|
|
111
|
+
)
|
|
112
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
if Path(src).resolve() != dest.resolve():
|
|
114
|
+
shutil.copyfile(src, dest)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _resolve_one(
|
|
118
|
+
spec: ArtifactSpec,
|
|
119
|
+
item: ArtifactFile,
|
|
120
|
+
install_root: Path,
|
|
121
|
+
repo_id: str,
|
|
122
|
+
revision: str,
|
|
123
|
+
endpoint: str,
|
|
124
|
+
download_fn: DownloadFn,
|
|
125
|
+
result: ArtifactInstallResult,
|
|
126
|
+
) -> None:
|
|
127
|
+
dest = install_root / item.rel_path
|
|
128
|
+
result.files.append(dest)
|
|
129
|
+
|
|
130
|
+
# Already present and correct → skip.
|
|
131
|
+
if dest.exists() and _sha256(dest) == item.sha256:
|
|
132
|
+
result.skipped.append(item.rel_path)
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
if not repo_id:
|
|
136
|
+
raise ArtifactError(
|
|
137
|
+
f"artifact {spec.artifact_ref!r} file {item.rel_path!r} is missing "
|
|
138
|
+
f"and no hf_repo is declared to download it from "
|
|
139
|
+
f"(preflight fail: required file absent)."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
try:
|
|
144
|
+
download_fn(repo_id, revision, item.source_rel, dest, endpoint)
|
|
145
|
+
except ArtifactError:
|
|
146
|
+
raise
|
|
147
|
+
except Exception as exc: # noqa: BLE001 - normalise any downloader failure
|
|
148
|
+
raise ArtifactError(
|
|
149
|
+
f"download failed for {spec.artifact_ref!r} file "
|
|
150
|
+
f"{item.source_rel!r}: {exc}"
|
|
151
|
+
) from exc
|
|
152
|
+
|
|
153
|
+
if not dest.exists():
|
|
154
|
+
raise ArtifactError(
|
|
155
|
+
f"download reported success but file missing: {dest} "
|
|
156
|
+
f"(artifact {spec.artifact_ref!r})"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
got = _sha256(dest)
|
|
160
|
+
if got != item.sha256:
|
|
161
|
+
dest.unlink(missing_ok=True)
|
|
162
|
+
raise ArtifactError(
|
|
163
|
+
f"sha256 mismatch for {item.rel_path!r} in artifact "
|
|
164
|
+
f"{spec.artifact_ref!r}: got {got}, expected {item.sha256}"
|
|
165
|
+
)
|
|
166
|
+
result.installed.append(item.rel_path)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def resolve_artifact(
|
|
170
|
+
artifact_ref: str,
|
|
171
|
+
install_root: str | Path,
|
|
172
|
+
*,
|
|
173
|
+
manifest_path: Optional[str | Path] = None,
|
|
174
|
+
manifest: Optional[ArtifactManifest] = None,
|
|
175
|
+
hf_endpoint: str = DEFAULT_HF_ENDPOINT,
|
|
176
|
+
download_fn: Optional[DownloadFn] = None,
|
|
177
|
+
) -> ArtifactInstallResult:
|
|
178
|
+
"""Resolve, download, SHA-verify and install a named artifact.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
artifact_ref: stable artifact name to resolve in the manifest.
|
|
182
|
+
install_root: caller-chosen base dir; files land at
|
|
183
|
+
``install_root/<file.path>`` (manifest stores relative paths only).
|
|
184
|
+
manifest_path: path to a JSON manifest. Defaults to the bundled sample
|
|
185
|
+
manifest when neither ``manifest_path`` nor ``manifest`` is given.
|
|
186
|
+
manifest: a pre-parsed manifest (takes precedence over ``manifest_path``).
|
|
187
|
+
hf_endpoint: HF endpoint base URL (env-free; passed by the caller).
|
|
188
|
+
download_fn: per-file downloader override. Defaults to a
|
|
189
|
+
``huggingface_hub``-backed downloader. Tests inject a local one.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
:class:`ArtifactInstallResult` recording installed/skipped files.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
ArtifactError: artifact_ref missing, a required file is absent with no
|
|
196
|
+
repo to fetch it, a download fails, or a SHA-256 mismatch occurs.
|
|
197
|
+
"""
|
|
198
|
+
if manifest is None:
|
|
199
|
+
path = manifest_path if manifest_path is not None else default_manifest_path()
|
|
200
|
+
manifest = load_manifest(path)
|
|
201
|
+
|
|
202
|
+
spec = manifest.get(artifact_ref)
|
|
203
|
+
|
|
204
|
+
root = Path(install_root)
|
|
205
|
+
endpoint = (hf_endpoint or manifest.hf_endpoint or DEFAULT_HF_ENDPOINT).rstrip("/")
|
|
206
|
+
repo_id = spec.hf_repo
|
|
207
|
+
revision = spec.revision or "main"
|
|
208
|
+
dl = download_fn or _hf_download
|
|
209
|
+
|
|
210
|
+
result = ArtifactInstallResult(artifact_ref=artifact_ref, install_root=root)
|
|
211
|
+
for item in spec.file_list:
|
|
212
|
+
_resolve_one(spec, item, root, repo_id, revision, endpoint, dl, result)
|
|
213
|
+
return result
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"hf_endpoint": "https://huggingface.co",
|
|
4
|
+
"artifacts": {
|
|
5
|
+
"rk3588-kokoro-hybrid-2026-05-23": {
|
|
6
|
+
"artifact_ref": "rk3588-kokoro-hybrid-2026-05-23",
|
|
7
|
+
"backend_key": "rk.tts",
|
|
8
|
+
"device": "rk3588",
|
|
9
|
+
"precision": "int8",
|
|
10
|
+
"hf_repo": "example-org/example-artifacts",
|
|
11
|
+
"revision": "main",
|
|
12
|
+
"sha256": "",
|
|
13
|
+
"runtime_contract": {
|
|
14
|
+
"tts_path": "kokoro_hybrid",
|
|
15
|
+
"closed_loop_validated": true,
|
|
16
|
+
"env": {
|
|
17
|
+
"TTS_BACKEND": "kokoro_rknn",
|
|
18
|
+
"KOKORO_RKNN_MODE": "hybrid",
|
|
19
|
+
"KOKORO_PREFIX_ONNX": "kokoro-prefix-cpu.onnx",
|
|
20
|
+
"KOKORO_FRONT_RKNN": "rk3588/kokoro-decoder-front.int8.rknn",
|
|
21
|
+
"KOKORO_TAIL_ONNX": "kokoro-generator-tail-cpu.onnx"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"file_list": [
|
|
25
|
+
{
|
|
26
|
+
"path": "opt/kokoro-rknn/kokoro-prefix-cpu.onnx",
|
|
27
|
+
"source_path": "rk3588/kokoro-hybrid-v1/kokoro-prefix-cpu.onnx",
|
|
28
|
+
"size_bytes": 22922434,
|
|
29
|
+
"sha256": "549fece07d8696ae9ea60f4667e2680b5ca1f41735af545489a378eed3fe1f1c"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"path": "opt/kokoro-rknn/kokoro-generator-tail-cpu.onnx",
|
|
33
|
+
"source_path": "rk3588/kokoro-hybrid-v1/kokoro-generator-tail-cpu.onnx",
|
|
34
|
+
"size_bytes": 89009749,
|
|
35
|
+
"sha256": "1bdbb9fdea62922e79fa29891471e7797047f16636bff9bfb8944b28ee451bcb"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"path": "opt/kokoro-rknn/rk3588/kokoro-decoder-front.int8.rknn",
|
|
39
|
+
"source_path": "rk3588/kokoro-hybrid-v1/rk3588/kokoro-decoder-front.int8.rknn",
|
|
40
|
+
"size_bytes": 40599018,
|
|
41
|
+
"sha256": "a6de7a2512d83a22cfba8e1793b1f463108126dfee4eb8592a28fe7c268d6861"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"path": "opt/kokoro-rknn/default.npy",
|
|
45
|
+
"source_path": "rk3588/kokoro-hybrid-v1/default.npy",
|
|
46
|
+
"size_bytes": 1152,
|
|
47
|
+
"sha256": "e844b0d3200a28b47a2472d4052c2696ac639197aa0ab06bb3fb708781b5a09d"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"path": "opt/kokoro-rknn/style.npy",
|
|
51
|
+
"source_path": "rk3588/kokoro-hybrid-v1/style.npy",
|
|
52
|
+
"size_bytes": 1152,
|
|
53
|
+
"sha256": "e844b0d3200a28b47a2472d4052c2696ac639197aa0ab06bb3fb708781b5a09d"
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Backend-agnostic runtime artifact manifest — schema + loader.
|
|
2
|
+
|
|
3
|
+
This module is **env-free** and **pure-Python** (stdlib + dataclasses only):
|
|
4
|
+
no ``os.environ`` reads, no ``huggingface_hub`` import, no network. It only
|
|
5
|
+
parses and validates a JSON manifest that describes the runtime artifacts a
|
|
6
|
+
backend needs (engines / plugins / model weights / voice-pack sidecars), each
|
|
7
|
+
pinned by SHA-256 and referenced by a stable ``artifact_ref`` name.
|
|
8
|
+
|
|
9
|
+
Schema (a backend-agnostic generalisation of a per-device artifact manifest):
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
"schema_version": 1,
|
|
13
|
+
"artifacts": {
|
|
14
|
+
"<artifact_ref>": {
|
|
15
|
+
"backend_key": "rk.tts" | "jetson.trt_edge_llm" | ...,
|
|
16
|
+
"artifact_ref": "<stable name>", # echoed; must match the key
|
|
17
|
+
"device": "rk3588" | "jetson-orin-sm87" | ...,
|
|
18
|
+
"precision": "fp16" | "int8" | "w4a16" | ...,
|
|
19
|
+
"hf_repo": "<org>/<repo>",
|
|
20
|
+
"revision": "main" | "<commit-sha>",
|
|
21
|
+
"sha256": "<top-level digest, optional>",
|
|
22
|
+
"runtime_contract": { ... }, # env/profile constraints
|
|
23
|
+
"file_list": [
|
|
24
|
+
{ "path": "<artifact-relative path, NO install root>",
|
|
25
|
+
"source_path": "<HF source path, defaults to path>",
|
|
26
|
+
"sha256": "<per-file digest>",
|
|
27
|
+
"size_bytes": <int, optional> },
|
|
28
|
+
...
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
``file_list[].path`` is the artifact-relative install path; the install root is
|
|
35
|
+
chosen by the caller (product / profile) at :func:`resolve_artifact` time — the
|
|
36
|
+
manifest never encodes an absolute install root.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any, Optional
|
|
45
|
+
|
|
46
|
+
SCHEMA_VERSION = 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ManifestError(ValueError):
|
|
50
|
+
"""Raised when a manifest is malformed or an artifact_ref is missing."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ArtifactFile:
|
|
55
|
+
"""One file in an artifact's ``file_list``.
|
|
56
|
+
|
|
57
|
+
``path`` is the artifact-relative destination (no install root). ``source_path``
|
|
58
|
+
is the path inside the HF repo to download from; it defaults to ``path``.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
path: str
|
|
62
|
+
sha256: str
|
|
63
|
+
source_path: str = ""
|
|
64
|
+
size_bytes: Optional[int] = None
|
|
65
|
+
|
|
66
|
+
def __post_init__(self) -> None:
|
|
67
|
+
if not self.path or not str(self.path).strip():
|
|
68
|
+
raise ManifestError("file_list entry missing 'path'")
|
|
69
|
+
if not self.sha256 or not str(self.sha256).strip():
|
|
70
|
+
raise ManifestError(f"file_list entry {self.path!r} missing 'sha256'")
|
|
71
|
+
# Normalise source_path to default to path (frozen → object.__setattr__).
|
|
72
|
+
if not self.source_path:
|
|
73
|
+
object.__setattr__(self, "source_path", self.path)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def rel_path(self) -> str:
|
|
77
|
+
"""Artifact-relative install path, leading slash stripped."""
|
|
78
|
+
return self.path.lstrip("/")
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def source_rel(self) -> str:
|
|
82
|
+
"""HF-source-relative path, leading slash stripped."""
|
|
83
|
+
return self.source_path.lstrip("/")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class ArtifactSpec:
|
|
88
|
+
"""A single named runtime artifact (resolved by ``artifact_ref``)."""
|
|
89
|
+
|
|
90
|
+
artifact_ref: str
|
|
91
|
+
file_list: tuple[ArtifactFile, ...]
|
|
92
|
+
backend_key: str = ""
|
|
93
|
+
device: str = ""
|
|
94
|
+
precision: str = ""
|
|
95
|
+
hf_repo: str = ""
|
|
96
|
+
revision: str = "main"
|
|
97
|
+
sha256: str = ""
|
|
98
|
+
runtime_contract: dict[str, Any] = field(default_factory=dict)
|
|
99
|
+
|
|
100
|
+
def __post_init__(self) -> None:
|
|
101
|
+
if not self.artifact_ref:
|
|
102
|
+
raise ManifestError("artifact spec missing 'artifact_ref'")
|
|
103
|
+
if not self.file_list:
|
|
104
|
+
raise ManifestError(
|
|
105
|
+
f"artifact {self.artifact_ref!r} has an empty file_list"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class ArtifactManifest:
|
|
111
|
+
"""Parsed, validated manifest. Keyed by ``artifact_ref``."""
|
|
112
|
+
|
|
113
|
+
schema_version: int
|
|
114
|
+
artifacts: dict[str, ArtifactSpec]
|
|
115
|
+
hf_endpoint: str = ""
|
|
116
|
+
|
|
117
|
+
def get(self, artifact_ref: str) -> ArtifactSpec:
|
|
118
|
+
"""Return the :class:`ArtifactSpec` for ``artifact_ref`` or raise."""
|
|
119
|
+
spec = self.artifacts.get(artifact_ref)
|
|
120
|
+
if spec is None:
|
|
121
|
+
raise ManifestError(
|
|
122
|
+
f"artifact_ref {artifact_ref!r} not found; "
|
|
123
|
+
f"available={sorted(self.artifacts)}"
|
|
124
|
+
)
|
|
125
|
+
return spec
|
|
126
|
+
|
|
127
|
+
def __contains__(self, artifact_ref: str) -> bool:
|
|
128
|
+
return artifact_ref in self.artifacts
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_file(raw: dict[str, Any]) -> ArtifactFile:
|
|
132
|
+
if not isinstance(raw, dict):
|
|
133
|
+
raise ManifestError(f"file_list entry must be an object, got {type(raw)!r}")
|
|
134
|
+
return ArtifactFile(
|
|
135
|
+
path=str(raw.get("path", "")),
|
|
136
|
+
sha256=str(raw.get("sha256", "")),
|
|
137
|
+
source_path=str(raw.get("source_path", "") or ""),
|
|
138
|
+
size_bytes=raw.get("size_bytes"),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_spec(artifact_ref: str, raw: dict[str, Any]) -> ArtifactSpec:
|
|
143
|
+
if not isinstance(raw, dict):
|
|
144
|
+
raise ManifestError(
|
|
145
|
+
f"artifact {artifact_ref!r} must be an object, got {type(raw)!r}"
|
|
146
|
+
)
|
|
147
|
+
declared_ref = str(raw.get("artifact_ref", "") or artifact_ref)
|
|
148
|
+
if declared_ref != artifact_ref:
|
|
149
|
+
raise ManifestError(
|
|
150
|
+
f"artifact key {artifact_ref!r} != declared artifact_ref "
|
|
151
|
+
f"{declared_ref!r}"
|
|
152
|
+
)
|
|
153
|
+
files_raw = raw.get("file_list")
|
|
154
|
+
if not isinstance(files_raw, list) or not files_raw:
|
|
155
|
+
raise ManifestError(
|
|
156
|
+
f"artifact {artifact_ref!r} must declare a non-empty 'file_list'"
|
|
157
|
+
)
|
|
158
|
+
file_list = tuple(_parse_file(f) for f in files_raw)
|
|
159
|
+
return ArtifactSpec(
|
|
160
|
+
artifact_ref=artifact_ref,
|
|
161
|
+
file_list=file_list,
|
|
162
|
+
backend_key=str(raw.get("backend_key", "") or ""),
|
|
163
|
+
device=str(raw.get("device", "") or ""),
|
|
164
|
+
precision=str(raw.get("precision", "") or ""),
|
|
165
|
+
hf_repo=str(raw.get("hf_repo", "") or ""),
|
|
166
|
+
revision=str(raw.get("revision", "") or "main"),
|
|
167
|
+
sha256=str(raw.get("sha256", "") or ""),
|
|
168
|
+
runtime_contract=dict(raw.get("runtime_contract") or {}),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def parse_manifest(data: dict[str, Any]) -> ArtifactManifest:
|
|
173
|
+
"""Validate and parse an in-memory manifest dict."""
|
|
174
|
+
if not isinstance(data, dict):
|
|
175
|
+
raise ManifestError(f"manifest must be a JSON object, got {type(data)!r}")
|
|
176
|
+
schema_version = data.get("schema_version")
|
|
177
|
+
if schema_version != SCHEMA_VERSION:
|
|
178
|
+
raise ManifestError(
|
|
179
|
+
f"unsupported schema_version {schema_version!r} "
|
|
180
|
+
f"(expected {SCHEMA_VERSION})"
|
|
181
|
+
)
|
|
182
|
+
artifacts_raw = data.get("artifacts")
|
|
183
|
+
if not isinstance(artifacts_raw, dict) or not artifacts_raw:
|
|
184
|
+
raise ManifestError("manifest must declare a non-empty 'artifacts' map")
|
|
185
|
+
artifacts = {
|
|
186
|
+
ref: _parse_spec(ref, spec) for ref, spec in artifacts_raw.items()
|
|
187
|
+
}
|
|
188
|
+
return ArtifactManifest(
|
|
189
|
+
schema_version=schema_version,
|
|
190
|
+
artifacts=artifacts,
|
|
191
|
+
hf_endpoint=str(data.get("hf_endpoint", "") or ""),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def load_manifest(manifest_path: str | Path) -> ArtifactManifest:
|
|
196
|
+
"""Load and validate a manifest from a local JSON file path."""
|
|
197
|
+
path = Path(manifest_path)
|
|
198
|
+
if not path.exists():
|
|
199
|
+
raise ManifestError(f"manifest_path not found: {path}")
|
|
200
|
+
try:
|
|
201
|
+
data = json.loads(path.read_text())
|
|
202
|
+
except json.JSONDecodeError as exc:
|
|
203
|
+
raise ManifestError(f"manifest is not valid JSON: {path}: {exc}") from exc
|
|
204
|
+
return parse_manifest(data)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def default_manifest_path() -> Path:
|
|
208
|
+
"""Path to the sample manifest bundled with the voxedge package."""
|
|
209
|
+
return Path(__file__).with_name("manifest.json")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""voxedge backend ABCs + mock implementations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from voxedge.backends.base import (
|
|
5
|
+
ASRBackend,
|
|
6
|
+
ASRCapability,
|
|
7
|
+
ASRStream,
|
|
8
|
+
LLMBackend,
|
|
9
|
+
LLMEvent,
|
|
10
|
+
TranscriptionResult,
|
|
11
|
+
TranslationResult,
|
|
12
|
+
TranslatorBackend,
|
|
13
|
+
TranslatorCapability,
|
|
14
|
+
TranslatorConfig,
|
|
15
|
+
TTSBackend,
|
|
16
|
+
TTSCapability,
|
|
17
|
+
VADBackend,
|
|
18
|
+
VADSession,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ASRBackend",
|
|
23
|
+
"ASRCapability",
|
|
24
|
+
"ASRStream",
|
|
25
|
+
"LLMBackend",
|
|
26
|
+
"LLMEvent",
|
|
27
|
+
"TranscriptionResult",
|
|
28
|
+
"TranslationResult",
|
|
29
|
+
"TranslatorBackend",
|
|
30
|
+
"TranslatorCapability",
|
|
31
|
+
"TranslatorConfig",
|
|
32
|
+
"TTSBackend",
|
|
33
|
+
"TTSCapability",
|
|
34
|
+
"VADBackend",
|
|
35
|
+
"VADSession",
|
|
36
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Friendly optional-dependency checks for voxedge backend extras.
|
|
2
|
+
|
|
3
|
+
The heavy backend adapters (jetson / rk / sherpa) import their native runtime
|
|
4
|
+
packages LAZILY (inside ``__init__`` / ``preload`` / per-call methods) so the
|
|
5
|
+
core stays pip-installable on a CUDA-less, NPU-less dev box (see pyproject
|
|
6
|
+
``[project.optional-dependencies]``). The flip side: when a user selects a
|
|
7
|
+
backend whose extra was never installed, the lazy import previously surfaced as
|
|
8
|
+
a bare, opaque ``ModuleNotFoundError: No module named 'sherpa_onnx'`` — with no
|
|
9
|
+
hint about which extra to install.
|
|
10
|
+
|
|
11
|
+
This module centralises a single ``require()`` helper that turns a missing
|
|
12
|
+
package into a clear, actionable ``ImportError`` naming both the missing
|
|
13
|
+
distribution and the exact ``pip install voxedge[<extra>]`` incantation. Each
|
|
14
|
+
backend's lazy import site wraps its import in ``require(...)`` (or calls the
|
|
15
|
+
convenience ``_check_*`` helpers) so the failure mode is explicit.
|
|
16
|
+
|
|
17
|
+
This file imports nothing heavy itself — it is pure stdlib and safe to import
|
|
18
|
+
from the pure-Python core.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import importlib
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def require(
|
|
28
|
+
module: str,
|
|
29
|
+
*,
|
|
30
|
+
extra: str,
|
|
31
|
+
package: Optional[str] = None,
|
|
32
|
+
):
|
|
33
|
+
"""Import ``module`` or raise a clear, actionable ImportError.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
module:
|
|
38
|
+
The importable module name (e.g. ``"sherpa_onnx"``).
|
|
39
|
+
extra:
|
|
40
|
+
The voxedge optional-dependency extra that provides it
|
|
41
|
+
(e.g. ``"sherpa"`` → ``pip install voxedge[sherpa]``).
|
|
42
|
+
package:
|
|
43
|
+
The pip distribution name when it differs from ``module``
|
|
44
|
+
(e.g. module ``cuda`` ships in ``cuda-python``). Used only to make the
|
|
45
|
+
error message accurate; defaults to ``module``.
|
|
46
|
+
|
|
47
|
+
Returns
|
|
48
|
+
-------
|
|
49
|
+
The imported module object.
|
|
50
|
+
|
|
51
|
+
Raises
|
|
52
|
+
------
|
|
53
|
+
ImportError
|
|
54
|
+
With a message naming the missing package + the install command.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
return importlib.import_module(module)
|
|
58
|
+
except ImportError as exc:
|
|
59
|
+
dist = package or module
|
|
60
|
+
raise ImportError(
|
|
61
|
+
f"voxedge[{extra}] requires {dist!r} (module {module!r}), which is "
|
|
62
|
+
f"not installed. Install the extra with: "
|
|
63
|
+
f"pip install 'voxedge[{extra}]'"
|
|
64
|
+
) from exc
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def require_all(specs, *, extra: str) -> None:
|
|
68
|
+
"""Check several modules up front; raise on the first that is missing.
|
|
69
|
+
|
|
70
|
+
``specs`` is an iterable of either ``module`` strings or
|
|
71
|
+
``(module, package)`` tuples. Use this in a backend ``preload()`` / first
|
|
72
|
+
use to fail fast with a friendly message before any partial work.
|
|
73
|
+
"""
|
|
74
|
+
for spec in specs:
|
|
75
|
+
if isinstance(spec, (tuple, list)):
|
|
76
|
+
module, package = spec[0], spec[1]
|
|
77
|
+
else:
|
|
78
|
+
module, package = spec, None
|
|
79
|
+
require(module, extra=extra, package=package)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ── convenience wrappers per extra ───────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def check_sherpa_deps() -> None:
|
|
86
|
+
"""Fail fast with a friendly error if the ``sherpa`` extra is missing."""
|
|
87
|
+
require_all([("sherpa_onnx", "sherpa-onnx")], extra="sherpa")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def check_rk_deps() -> None:
|
|
91
|
+
"""Fail fast with a friendly error if the ``rk`` extra is missing."""
|
|
92
|
+
require_all([("rkvoice_stream", "rkvoice-stream")], extra="rk")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_translator_deps() -> None:
|
|
96
|
+
"""Fail fast with a friendly error if the ``translator`` extra is missing."""
|
|
97
|
+
require_all(
|
|
98
|
+
[("ctranslate2", "ctranslate2"), ("sentencepiece", "sentencepiece")],
|
|
99
|
+
extra="translator",
|
|
100
|
+
)
|