File size: 3,319 Bytes
2e818da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
"""Pinned, resumable asset setup for ResearchMate's pocket-tts default voice and Piper fallback voice."""

from __future__ import annotations

import argparse
import hashlib
import json
from dataclasses import asdict, dataclass
from pathlib import Path


@dataclass(frozen=True)
class DownloadSpec:
    name: str
    repo_id: str
    revision: str
    destination: str
    allow_patterns: tuple[str, ...] = ()


def download_specs() -> list[DownloadSpec]:
    return [
        DownloadSpec(
            "pocket-tts",
            "kyutai/pocket-tts",
            "4c8ad48f8a003909bc4f1122cbe88a4252124621",
            "pocket",
        ),
        DownloadSpec(
            "piper-en-us",
            "rhasspy/piper-voices",
            "e21c7de8d4eab79b902f0d61e662b3f21664b8d2",
            "piper",
            (
                "en/en_US/lessac/medium/en_US-lessac-medium.onnx",
                "en/en_US/lessac/medium/en_US-lessac-medium.onnx.json",
                "en/en_US/lessac/medium/MODEL_CARD",
            ),
        ),
    ]


def setup_assets(root: Path) -> dict:
    from huggingface_hub import snapshot_download

    root.mkdir(parents=True, exist_ok=True)
    rows: list[dict] = []
    for spec in download_specs():
        destination = root / spec.destination
        try:
            snapshot_download(
                repo_id=spec.repo_id,
                revision=spec.revision,
                allow_patterns=list(spec.allow_patterns) or None,
                local_dir=destination,
            )
            rows.append({
                **asdict(spec),
                "status": "installed",
                "bytes": _directory_size(destination),
                "checksums": _checksums(destination),
            })
        except Exception as exc:
            rows.append({**asdict(spec), "status": "error", "error": str(exc)})

    manifest = {"schema_version": 1, "root": str(root), "assets": rows, "total_bytes": _directory_size(root)}
    (root / "installation-manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
    return manifest


def _directory_size(path: Path) -> int:
    return sum(item.stat().st_size for item in path.rglob("*") if item.is_file()) if path.exists() else 0


def _sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def _checksums(path: Path) -> dict[str, str]:
    return {
        item.relative_to(path).as_posix(): _sha256(item)
        for item in path.rglob("*") if item.is_file() and not item.name.startswith(".cache")
    }


def main() -> None:
    parser = argparse.ArgumentParser(description="Download pinned Pocket TTS and Piper assets.")
    parser.add_argument("--root", type=Path, default=Path.home() / ".studybuddy" / "models")
    parser.add_argument("--yes", action="store_true", help="Accept the model download without prompting")
    args = parser.parse_args()
    if not args.yes:
        answer = input("Download Pocket TTS and Piper assets? [y/N] ")
        if answer.strip().lower() not in {"y", "yes"}:
            return
    manifest = setup_assets(args.root)
    print(json.dumps(manifest, indent=2))


if __name__ == "__main__":
    main()