V 3
Browse files- .gitattributes +1 -0
- DOCS.md +27 -83
- README.md +29 -12
- creative-bench/runs/tensorwars.md +130 -0
- vlib/__init__.py +0 -0
- vlib/cache.py +344 -0
- vlib/cmds_misc.py +550 -0
- vlib/cmds_voices.py +551 -0
- vlib/compute.py +1186 -0
- vlib/ctx.py +33 -0
- vlib/delta.py +340 -0
- vlib/fetch.py +188 -0
- vlib/net.py +451 -0
- vlib/pack.py +76 -0
- vlib/registry.py +323 -0
- vlib/reports.py +469 -0
- vlib/sources.py +507 -0
- vlib/tensors.py +488 -0
- vlib/ui.py +97 -0
- voice-cli.tar.gz +3 -0
- voice.py +0 -0
- voice.pyz +3 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
voice.pyz filter=lfs diff=lfs merge=lfs -text
|
DOCS.md
CHANGED
|
@@ -1,22 +1,21 @@
|
|
| 1 |
# voice β Help
|
| 2 |
|
| 3 |
-
Generated from `voice --help` and `voice <cmd> --help` (
|
| 4 |
|
| 5 |
## voice --help
|
| 6 |
|
| 7 |
```text
|
| 8 |
usage: voice [-h] [-v] [--non-interactive] [--version]
|
| 9 |
[--completion {bash,zsh,fish}]
|
| 10 |
-
{get,cast,
|
| 11 |
|
| 12 |
Lift a model's voice and cast it onto another model.
|
| 13 |
|
| 14 |
positional arguments:
|
| 15 |
-
{get,cast,
|
| 16 |
get Get a voice from a model (safetensors repo, .gguf, or
|
| 17 |
URL)
|
| 18 |
cast Cast a voice onto a .gguf or .safetensors file
|
| 19 |
-
tensor Extract or cast any named tensor
|
| 20 |
delta Make a reusable style (voice β base)
|
| 21 |
add Add a voice from local voice.safetensors + voice.json
|
| 22 |
list List stored voices
|
|
@@ -40,15 +39,22 @@ options:
|
|
| 40 |
## voice get
|
| 41 |
|
| 42 |
```text
|
| 43 |
-
usage: voice get [-h] [-q] [--name NAME] [--
|
|
|
|
| 44 |
|
| 45 |
positional arguments:
|
| 46 |
source HF model ID, .gguf/.safetensors path, or URL
|
|
|
|
|
|
|
| 47 |
|
| 48 |
options:
|
| 49 |
-h, --help show this help message and exit
|
| 50 |
-q, --quiet
|
| 51 |
-
--name NAME Voice name to save
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
--yes
|
| 53 |
```
|
| 54 |
|
|
@@ -61,21 +67,27 @@ slugified from the source (`./my model.gguf` β `my-model`); an explicit
|
|
| 61 |
## voice cast
|
| 62 |
|
| 63 |
```text
|
| 64 |
-
usage: voice cast [-h] [--out OUT] [--yes] [--quant QUANT]
|
|
|
|
|
|
|
| 65 |
|
| 66 |
positional arguments:
|
| 67 |
-
target
|
| 68 |
-
voice
|
|
|
|
|
|
|
| 69 |
|
| 70 |
options:
|
| 71 |
-
-h, --help
|
| 72 |
-
--out OUT
|
| 73 |
-
|
| 74 |
--yes
|
| 75 |
-
--quant QUANT
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
| 77 |
```
|
| 78 |
-
|
| 79 |
Philosophy: preserve quality, avoid dequantβquant cycles. Safetensors high-fidelity
|
| 80 |
lm_head is quantized once to Q8_0 (Q4 saves little: Q8_0 1.062 vs Q4_0 0.562 B/param).
|
| 81 |
All GGML quants supported (trivial passthrough): Q8_0/Q4_0/Q4_1/Q5_0/Q5_1/Q8_1/Q8_K/
|
|
@@ -89,74 +101,6 @@ Generic tensors keep target quant (F32 norms stay F32, never forced Q8_0).
|
|
| 89 |
architecture is unknown the output is labeled `llama` (GGUF requires an arch)
|
| 90 |
with a warning. BF16 math uses round-to-nearest-even, not truncation.
|
| 91 |
|
| 92 |
-
## voice tensor
|
| 93 |
-
|
| 94 |
-
```text
|
| 95 |
-
usage: voice tensor [-h] {get,cast} ...
|
| 96 |
-
|
| 97 |
-
positional arguments:
|
| 98 |
-
{get,cast}
|
| 99 |
-
get Extract any named tensor to a .safetensors file
|
| 100 |
-
cast Cast any named tensor onto a .gguf or .safetensors file
|
| 101 |
-
|
| 102 |
-
options:
|
| 103 |
-
-h, --help show this help message and exit
|
| 104 |
-
```
|
| 105 |
-
|
| 106 |
-
### voice tensor get
|
| 107 |
-
|
| 108 |
-
```text
|
| 109 |
-
usage: voice tensor get [-h] [--tensor TENSORS] [--multi] [--out OUT] [--yes]
|
| 110 |
-
source
|
| 111 |
-
|
| 112 |
-
positional arguments:
|
| 113 |
-
source HF model ID, .gguf/.safetensors path, or URL
|
| 114 |
-
|
| 115 |
-
options:
|
| 116 |
-
-h, --help show this help message and exit
|
| 117 |
-
--tensor TENSORS Tensor name (repeatable, or comma-separated; default:
|
| 118 |
-
auto-detect output head)
|
| 119 |
-
--multi Allow multiple tensors -> voicepack.safetensors
|
| 120 |
-
--out OUT Output .safetensors path (default voicepack.safetensors
|
| 121 |
-
when --multi)
|
| 122 |
-
--yes
|
| 123 |
-
```
|
| 124 |
-
|
| 125 |
-
Shows `Voice 'name' is <quant> <shape> β target` by default. BF16 is preserved
|
| 126 |
-
(not downconverted to F16). GGUF quants passthrough bit-exact to `voice.gguf`;
|
| 127 |
-
multi-pack dequants quants once to F32 for `voicepack.safetensors` (unavoidable).
|
| 128 |
-
|
| 129 |
-
### voice tensor cast
|
| 130 |
-
|
| 131 |
-
```text
|
| 132 |
-
usage: voice tensor cast [-h] [--tensor TENSOR] [--out OUT] [--yes]
|
| 133 |
-
[--quant QUANT] [--exclude EXCLUDES]
|
| 134 |
-
target tensor_file
|
| 135 |
-
|
| 136 |
-
positional arguments:
|
| 137 |
-
target Path to the target .gguf or .safetensors
|
| 138 |
-
tensor_file Path to a .safetensors/.gguf containing the tensor(s)
|
| 139 |
-
|
| 140 |
-
options:
|
| 141 |
-
-h, --help show this help message and exit
|
| 142 |
-
--tensor TENSOR Tensor name (default: the file's output head or only
|
| 143 |
-
tensor)
|
| 144 |
-
--out OUT Output file
|
| 145 |
-
--yes
|
| 146 |
-
--quant QUANT Output quant override: keep (default) | target | any quant
|
| 147 |
-
(q8_0,q4_0,q4_k,q6_k,iq4_nl,f16,f32,bf16,...)
|
| 148 |
-
--exclude EXCLUDES Skip this tensor on cast (repeatable or comma-separated,
|
| 149 |
-
voicepack only)
|
| 150 |
-
```
|
| 151 |
-
|
| 152 |
-
Keeps target quant by default (F32 norms stay F32, Q4_0 stays Q4_0, never forced
|
| 153 |
-
Q8_0). All quants kept bit-exact (Q1_0/Q8_1/Q8_K/TQ*/MXFP4/NVFP4/IQ* included).
|
| 154 |
-
Mismatch (safetensors F32 β Q8_0 target) prompts `Enter=target` on TTY,
|
| 155 |
-
silent keep-target on `--yes`/pipe. `--exclude` skips listed tensors (fails if
|
| 156 |
-
all excluded). Voicepack cast requires exact tensor match in target (fails instead
|
| 157 |
-
of silently mapping to another tensor; single-tensor cast keeps auto-detect fallback).
|
| 158 |
-
Progress shows `Kept X MB β <quant> @ Y MB/s` plus GGUF write bar.
|
| 159 |
-
|
| 160 |
## voice delta
|
| 161 |
|
| 162 |
```text
|
|
|
|
| 1 |
# voice β Help
|
| 2 |
|
| 3 |
+
Generated from `voice --help` and `voice <cmd> --help` (v3.0, 12 commands).
|
| 4 |
|
| 5 |
## voice --help
|
| 6 |
|
| 7 |
```text
|
| 8 |
usage: voice [-h] [-v] [--non-interactive] [--version]
|
| 9 |
[--completion {bash,zsh,fish}]
|
| 10 |
+
{get,cast,delta,add,list,info,remove,cleanup,auth,report,path,doctor} ...
|
| 11 |
|
| 12 |
Lift a model's voice and cast it onto another model.
|
| 13 |
|
| 14 |
positional arguments:
|
| 15 |
+
{get,cast,delta,add,list,info,remove,cleanup,auth,report,path,doctor}
|
| 16 |
get Get a voice from a model (safetensors repo, .gguf, or
|
| 17 |
URL)
|
| 18 |
cast Cast a voice onto a .gguf or .safetensors file
|
|
|
|
| 19 |
delta Make a reusable style (voice β base)
|
| 20 |
add Add a voice from local voice.safetensors + voice.json
|
| 21 |
list List stored voices
|
|
|
|
| 39 |
## voice get
|
| 40 |
|
| 41 |
```text
|
| 42 |
+
usage: voice get [-h] [-q] [--name NAME] [--multi] [--out OUT] [--yes]
|
| 43 |
+
source [tensors ...]
|
| 44 |
|
| 45 |
positional arguments:
|
| 46 |
source HF model ID, .gguf/.safetensors path, or URL
|
| 47 |
+
tensors Tensor name(s) to fetch (default: auto-detect output head; 2+
|
| 48 |
+
or --multi saves a voicepack)
|
| 49 |
|
| 50 |
options:
|
| 51 |
-h, --help show this help message and exit
|
| 52 |
-q, --quiet
|
| 53 |
+
--name NAME Voice name to save (registry voices only)
|
| 54 |
+
--multi Fetch several tensors -> voicepack.safetensors + voicepack.json
|
| 55 |
+
(needs 1+ tensor)
|
| 56 |
+
--out OUT Write a standalone file instead of a registry voice (default
|
| 57 |
+
pack name: voicepack.safetensors)
|
| 58 |
--yes
|
| 59 |
```
|
| 60 |
|
|
|
|
| 67 |
## voice cast
|
| 68 |
|
| 69 |
```text
|
| 70 |
+
usage: voice cast [-h] [--out OUT] [--yes] [--quant QUANT]
|
| 71 |
+
[--exclude EXCLUDES] [--speak]
|
| 72 |
+
target voice [tensors ...]
|
| 73 |
|
| 74 |
positional arguments:
|
| 75 |
+
target Path to the target .gguf or .safetensors
|
| 76 |
+
voice Voice name, or a .safetensors/.gguf path
|
| 77 |
+
tensors Only cast these tensors (default: every tensor in the
|
| 78 |
+
voice)
|
| 79 |
|
| 80 |
options:
|
| 81 |
+
-h, --help show this help message and exit
|
| 82 |
+
--out OUT Output file (default:
|
| 83 |
+
~/Voiced/<voice>-<model>/<voice>-<model>.gguf)
|
| 84 |
--yes
|
| 85 |
+
--quant QUANT Output quant override: keep (default, preserve quality)
|
| 86 |
+
| target | any quant
|
| 87 |
+
(q8_0,q4_0,q4_k,q6_k,iq4_nl,f16,f32,bf16,...)
|
| 88 |
+
--exclude EXCLUDES Skip this tensor on cast (repeatable or comma-separated)
|
| 89 |
+
--speak Voice speaks through output.weight (invented if absent)
|
| 90 |
```
|
|
|
|
| 91 |
Philosophy: preserve quality, avoid dequantβquant cycles. Safetensors high-fidelity
|
| 92 |
lm_head is quantized once to Q8_0 (Q4 saves little: Q8_0 1.062 vs Q4_0 0.562 B/param).
|
| 93 |
All GGML quants supported (trivial passthrough): Q8_0/Q4_0/Q4_1/Q5_0/Q5_1/Q8_1/Q8_K/
|
|
|
|
| 101 |
architecture is unknown the output is labeled `llama` (GGUF requires an arch)
|
| 102 |
with a warning. BF16 math uses round-to-nearest-even, not truncation.
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
## voice delta
|
| 105 |
|
| 106 |
```text
|
README.md
CHANGED
|
@@ -6,7 +6,7 @@ language:
|
|
| 6 |
license: apache-2.0
|
| 7 |
base_model:
|
| 8 |
- google/gemma-4-26B-A4B-it
|
| 9 |
-
base_model_relation:
|
| 10 |
tags:
|
| 11 |
- gemma
|
| 12 |
- moe
|
|
@@ -37,13 +37,18 @@ A full model is 26GB. A voice is 1.4GB β smaller models even less. You downloa
|
|
| 37 |
|
| 38 |
## Two steps to start
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
```bash
|
| 43 |
-
python3 voice.py path # once - adds voice to PATH (then use voice directly)
|
| 44 |
voice get Gryphe/Gemma-4-26B-A4B-StyleTune-V2
|
| 45 |
# Name it styletune-v2 when prompted
|
| 46 |
voice cast ./model.gguf styletune-v2 --out ./voiced/
|
|
|
|
| 47 |
```
|
| 48 |
|
| 49 |
Run it:
|
|
@@ -95,13 +100,27 @@ Voice is a **scalpel, not a pipeline** β one idea made trivial: lift a model's
|
|
| 95 |
<summary>What it trivializes (power users, same family & shape)</summary>
|
| 96 |
|
| 97 |
* **Same family, any container:** `voice get` from any `safetensors` repo / `.gguf` / URL via range requests; `voice cast` onto any compatible `.gguf` or `.safetensors` β same `vocab` + same `head shape` required, transposes `BF16/F16/F32βQ8_0` auto (`last dim %32==0` else `F16` fallback).
|
| 98 |
-
* **Any single tensor, precisely:** `voice
|
| 99 |
* **No surprises:** atomic writes, validated header, ETag-aware resume, `0600` token, symlink-safe β resumes or fails cleanly.
|
| 100 |
|
| 101 |
</details>
|
| 102 |
|
| 103 |
Keep the stronger model for reasoning, the better voice for style. `llama serve -m` ready.
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
## How it works
|
| 107 |
|
|
@@ -151,12 +170,11 @@ The notebook is still mostly empty. That's why the next line matters most.
|
|
| 151 |
|
| 152 |
## Commands
|
| 153 |
|
| 154 |
-
Three you need daily β rest in [`DOCS.md`](./DOCS.md) (`voice --help` is truth,
|
| 155 |
|
| 156 |
| Command | What it does |
|
| 157 |
|---|---|
|
| 158 |
-
| `voice
|
| 159 |
-
| `voice cast <target> <voice>` | Cast it onto `.gguf` or `.safetensors` of same shape/vocab (`Q8_0` once for safetensors lm_head, else keep target quant incl. all GGML quants; `--quant keep\|target\|any quant`) |
|
| 160 |
| `voice report -m "note"` | Save a one-line note locally, optionally share to community β small file, big iteration (we pull source/tensor/shape) |
|
| 161 |
|
| 162 |
### Extra commands
|
|
@@ -164,8 +182,7 @@ Three you need daily β rest in [`DOCS.md`](./DOCS.md) (`voice --help` is truth
|
|
| 164 |
| Command | What it does |
|
| 165 |
|---|---|
|
| 166 |
| `voice add <safetensors> <json>` | Add local `voice.safetensors` + `voice.json` to `~/.voices/` (both required, asks name) |
|
| 167 |
-
| `voice
|
| 168 |
-
| `voice tensor cast <target> <file>` | Cast any named tensor, keeps target quant (F32 norms stay F32; `--quant`, `--exclude` for packs) |
|
| 169 |
| `voice delta [name]` | Make a portable `voice β base` style |
|
| 170 |
| `voice list [base]` | List voices or cached bases |
|
| 171 |
| `voice info <voice>` | Show voice details |
|
|
@@ -193,7 +210,7 @@ voice list base
|
|
| 193 |
voice cast ./model.gguf styletune-v2 --out ./voiced/
|
| 194 |
```
|
| 195 |
|
| 196 |
-
> `delta-` is reserved. Voice names cannot start with it. Deltas always do. Full CLI reference: [`DOCS.md`](./DOCS.md) (generated from `voice --help`,
|
| 197 |
|
| 198 |
## What StyleTune showed
|
| 199 |
|
|
@@ -316,7 +333,7 @@ print(pipe("Write a short tavern scene with varied diction:")[0]["generated_text
|
|
| 316 |
# pipe = pipeline("text-generation", model="gryphe/gemma-4-26b-a4b-styletune-v2")
|
| 317 |
```
|
| 318 |
|
| 319 |
-
> **Base-model note:** `google/gemma-4-26b-a4b-it` is the lowercased form of the real HF ID [`google/gemma-4-26B-A4B-it`](https://huggingface.co/google/gemma-4-26B-A4B-it) (verified live, Apache-2.0 via Gemma license). `gryphe/gemma-4-26b-a4b-styletune-v2` is the lowercased illustrative voice sourceβreplace with any compatible `safetensors` model that shares architecture and vocab. Relation: `base_model_relation:
|
| 320 |
|
| 321 |
---
|
| 322 |
|
|
@@ -441,7 +458,7 @@ Every wait shows a bar β `β lm_head.weight 42% Β· 604/1437 MB Β· 87 MB/s Β·
|
|
| 441 |
<summary>What is inside the files</summary>
|
| 442 |
|
| 443 |
- Voice: single-tensor `safetensors`, `lm_head.weight` or `output.weight`, original dtype (`BF16`/`F16`/`F32`), full shape.
|
| 444 |
-
- Delta: single-tensor `safetensors` + tiny marker `delta.voice.marker` (`U8 [1]`) + `__metadata__ {"type":"delta"}`, `F16`, same shape, `voice β base`. Marker lets `voice cast model.gguf delta.safetensors` auto-detect and fuse (just works, no flag, 1 byte overhead, ignored except creation/cast); old `delta-` folders without marker still work via `voice.json type:delta`. Custom `style.delta` (like `Lora/StyleTune/style.safetensors`) is not our marker β intentionally not auto-fused (use `delta-` folder or `voice tensor` explicit if you want it).
|
| 445 |
- Voiced GGUF: copy of your model with the output head replaced and quantized to `Q8_0`. All other tensors and metadata are preserved. Vocab, tokenizer, and architecture stay exactly as they were.
|
| 446 |
|
| 447 |
How it works under the hood:
|
|
|
|
| 6 |
license: apache-2.0
|
| 7 |
base_model:
|
| 8 |
- google/gemma-4-26B-A4B-it
|
| 9 |
+
base_model_relation: finetune
|
| 10 |
tags:
|
| 11 |
- gemma
|
| 12 |
- moe
|
|
|
|
| 37 |
|
| 38 |
## Two steps to start
|
| 39 |
|
| 40 |
+
```bash
|
| 41 |
+
curl -sSL https://huggingface.co/Wiself/voice/resolve/main/voice-cli.tar.gz | tar -xz
|
| 42 |
+
cd voice-cli && python3 voice.py path # once - adds voice to PATH
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
Then two commands. One model, one voice:
|
| 46 |
|
| 47 |
```bash
|
|
|
|
| 48 |
voice get Gryphe/Gemma-4-26B-A4B-StyleTune-V2
|
| 49 |
# Name it styletune-v2 when prompted
|
| 50 |
voice cast ./model.gguf styletune-v2 --out ./voiced/
|
| 51 |
+
voice cast ./model.gguf styletune-v2 --speak --out ./voiced/ # voice speaks through output.weight (invented if absent)
|
| 52 |
```
|
| 53 |
|
| 54 |
Run it:
|
|
|
|
| 100 |
<summary>What it trivializes (power users, same family & shape)</summary>
|
| 101 |
|
| 102 |
* **Same family, any container:** `voice get` from any `safetensors` repo / `.gguf` / URL via range requests; `voice cast` onto any compatible `.gguf` or `.safetensors` β same `vocab` + same `head shape` required, transposes `BF16/F16/F32βQ8_0` auto (`last dim %32==0` else `F16` fallback).
|
| 103 |
+
* **Any single tensor, precisely:** `voice get <repo> <tensor>` / `voice cast <target> <voice> [tensors...]` for any named tensor β bit-exact when quant types match, validated offsets, otherwise `F32` round-trip.
|
| 104 |
* **No surprises:** atomic writes, validated header, ETag-aware resume, `0600` token, symlink-safe β resumes or fails cleanly.
|
| 105 |
|
| 106 |
</details>
|
| 107 |
|
| 108 |
Keep the stronger model for reasoning, the better voice for style. `llama serve -m` ready.
|
| 109 |
|
| 110 |
+
## One voice, several writers
|
| 111 |
+
|
| 112 |
+
Where the voice lands changes how the model sounds. We cast one voice (Scarlett) onto one base (Orion) five ways and read every word β same voice, different slots, different writing. The slot is a second dial next to the choice of voice. Full data: [`creative-bench/runs/tensorwars.md`](./creative-bench/runs/tensorwars.md) (one prompt, five builds, single draws β a first probe, more coming).
|
| 113 |
+
|
| 114 |
+
Every build is Orion with something swapped:
|
| 115 |
+
|
| 116 |
+
- nothing (the base as it ships),
|
| 117 |
+
- Orion reading through Scarlett's embedding,
|
| 118 |
+
- Orion speaking through Scarlett's head (`--speak`),
|
| 119 |
+
- both slots,
|
| 120 |
+
- one Scarlett tensor serving both slots.
|
| 121 |
+
|
| 122 |
+
Default cast puts the voice onto `output.weight`, else the embed slot β a target with neither fails loud instead of guessing.
|
| 123 |
+
|
| 124 |
|
| 125 |
## How it works
|
| 126 |
|
|
|
|
| 170 |
|
| 171 |
## Commands
|
| 172 |
|
| 173 |
+
Three you need daily β rest in [`DOCS.md`](./DOCS.md) (`voice --help` is truth, 12 commands `v3.0`):
|
| 174 |
|
| 175 |
| Command | What it does |
|
| 176 |
|---|---|
|
| 177 |
+
| `voice cast <target> <voice> [tensors...]` | Cast it onto `.gguf` or `.safetensors` of same shape/vocab (`Q8_0` once for safetensors lm_head, else keep target quant incl. all GGML quants; `--quant keep\|target\|any quant`; `--speak` for output.weight, invented if absent; `--exclude` to skip) |
|
|
|
|
| 178 |
| `voice report -m "note"` | Save a one-line note locally, optionally share to community β small file, big iteration (we pull source/tensor/shape) |
|
| 179 |
|
| 180 |
### Extra commands
|
|
|
|
| 182 |
| Command | What it does |
|
| 183 |
|---|---|
|
| 184 |
| `voice add <safetensors> <json>` | Add local `voice.safetensors` + `voice.json` to `~/.voices/` (both required, asks name) |
|
| 185 |
+
| `voice get <repo> [tensors...]` | Extract head (default) or named tensors (`--multi` β `voicepack.safetensors` + json, BF16 preserved) |
|
|
|
|
| 186 |
| `voice delta [name]` | Make a portable `voice β base` style |
|
| 187 |
| `voice list [base]` | List voices or cached bases |
|
| 188 |
| `voice info <voice>` | Show voice details |
|
|
|
|
| 210 |
voice cast ./model.gguf styletune-v2 --out ./voiced/
|
| 211 |
```
|
| 212 |
|
| 213 |
+
> `delta-` is reserved. Voice names cannot start with it. Deltas always do. Full CLI reference: [`DOCS.md`](./DOCS.md) (generated from `voice --help`, 12 commands). Names are lowercased when saved.
|
| 214 |
|
| 215 |
## What StyleTune showed
|
| 216 |
|
|
|
|
| 333 |
# pipe = pipeline("text-generation", model="gryphe/gemma-4-26b-a4b-styletune-v2")
|
| 334 |
```
|
| 335 |
|
| 336 |
+
> **Base-model note:** `google/gemma-4-26b-a4b-it` is the lowercased form of the real HF ID [`google/gemma-4-26B-A4B-it`](https://huggingface.co/google/gemma-4-26B-A4B-it) (verified live, Apache-2.0 via Gemma license). `gryphe/gemma-4-26b-a4b-styletune-v2` is the lowercased illustrative voice sourceβreplace with any compatible `safetensors` model that shares architecture and vocab. Relation: `base_model_relation: finetune` (the voice is lifted from a finetune of this base).
|
| 337 |
|
| 338 |
---
|
| 339 |
|
|
|
|
| 458 |
<summary>What is inside the files</summary>
|
| 459 |
|
| 460 |
- Voice: single-tensor `safetensors`, `lm_head.weight` or `output.weight`, original dtype (`BF16`/`F16`/`F32`), full shape.
|
| 461 |
+
- Delta: single-tensor `safetensors` + tiny marker `delta.voice.marker` (`U8 [1]`) + `__metadata__ {"type":"delta"}`, `F16`, same shape, `voice β base`. Marker lets `voice cast model.gguf delta.safetensors` auto-detect and fuse (just works, no flag, 1 byte overhead, ignored except creation/cast); old `delta-` folders without marker still work via `voice.json type:delta`. Custom `style.delta` (like `Lora/StyleTune/style.safetensors`) is not our marker β intentionally not auto-fused (use `delta-` folder or `voice cast <target> <voice> <tensor>` explicit if you want it).
|
| 462 |
- Voiced GGUF: copy of your model with the output head replaced and quantized to `Q8_0`. All other tensors and metadata are preserved. Vocab, tokenizer, and architecture stay exactly as they were.
|
| 463 |
|
| 464 |
How it works under the hood:
|
creative-bench/runs/tensorwars.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Tensorwars
|
| 2 |
+
|
| 3 |
+
## Why this exists
|
| 4 |
+
|
| 5 |
+
A voice is a single tensor extracted from a donor model. A target model has two
|
| 6 |
+
places that tensor can land: the output head (`output.weight`) and the input
|
| 7 |
+
embedding (`token_embd.weight`). Some architectures tie the two (one tensor
|
| 8 |
+
serves both); most do not.
|
| 9 |
+
|
| 10 |
+
The open question: when casting a voice onto a target, which slot should
|
| 11 |
+
receive it? Output only, input only, both, and the old tied method each place
|
| 12 |
+
the same voice differently. Nothing about the choice is obvious from the weights.
|
| 13 |
+
Donor voice: Scarlett (`dark-scarlett-v1-0-26b-a4b-TheDrummer_Orion-26B-A4B-v1-IQ4_NL.gguf`).
|
| 14 |
+
Base: Orion (`TheDrummer_Orion-26B-A4B-v1-IQ4_NL.gguf`).
|
| 15 |
+
|
| 16 |
+
| build | voice placement | file served |
|
| 17 |
+
|---|---|---|
|
| 18 |
+
| truebase | none (control) | `TheDrummer_Orion-26B-A4B-v1-IQ4_NL.gguf` |
|
| 19 |
+
| inputOnly | voice β `token_embd` only | `input-experimental.gguf` |
|
| 20 |
+
| outputOnly | voice β `output.weight` only | `exprimental.gguf` (filename as-is) |
|
| 21 |
+
| fullShared | voice β both slots (verbatim input path) | `full-experimental.gguf` |
|
| 22 |
+
| oldTied | one tensor serving both slots (tied) | `dark-scarlett-v1-0-26b-a4b-TheDrummer_Orion-26B-A4B-v1-IQ4_NL.gguf` |
|
| 23 |
+
|
| 24 |
+
In plain terms, every build is Orion with something swapped:
|
| 25 |
+
|
| 26 |
+
- truebase: Orion, nothing swapped.
|
| 27 |
+
- inputOnly: Orion reading through Scarlett's embedding, speaking through its own head.
|
| 28 |
+
- outputOnly: Orion reading through its own embedding, speaking through Scarlett's head.
|
| 29 |
+
- fullShared: Orion reading and speaking through Scarlett, both slots.
|
| 30 |
+
- oldTied: one Scarlett tensor serving both slots.
|
| 31 |
+
|
| 32 |
+
Small isolated probes first: single-tensor fake GGUF targets confirmed each
|
| 33 |
+
placement lands where intended (byte checks on untouched slots, quant checks on
|
| 34 |
+
touched ones) before any text was generated. Then one prompt (Gladiator,
|
| 35 |
+
first-person, ~1000 words), temp 0.0, seed 1234, single-slot server. Every
|
| 36 |
+
build was generated 3 times; the two text-identical draws were kept as the
|
| 37 |
+
measured pair, the outlier recorded and set aside.
|
| 38 |
+
|
| 39 |
+
## What the method unlocked
|
| 40 |
+
|
| 41 |
+
Problems: temp-0 generation does not replay on this stack β identical settings
|
| 42 |
+
produce 2β3 distinct outputs per build (within-build char swings: 150β773).
|
| 43 |
+
Every measurement below is one verified draw, not an average. Placement
|
| 44 |
+
proliferates artifacts: five builds to serve, store, and compare per voice.
|
| 45 |
+
|
| 46 |
+
Possibilities: one downloaded voice yields multiple distinct writings depending
|
| 47 |
+
on application. The choice of slot is a second dial next to the choice of
|
| 48 |
+
voice.
|
| 49 |
+
|
| 50 |
+
## The core observation
|
| 51 |
+
|
| 52 |
+
The same voice, applied to different slots, writes differently β and plans
|
| 53 |
+
differently. All five `thinking` traces are mutually distinct (md5-verified);
|
| 54 |
+
shared plan openings range from 48 to 464 chars. The divergence is present
|
| 55 |
+
before the first word of story is written.
|
| 56 |
+
|
| 57 |
+
## Scope
|
| 58 |
+
|
| 59 |
+
Initial steps. One prompt, one voice, one base, single draws. The distances
|
| 60 |
+
are real on this data; how far they generalize needs more prompts, more
|
| 61 |
+
voices, and more readers reporting back.
|
| 62 |
+
|
| 63 |
+
## Data
|
| 64 |
+
|
| 65 |
+
### Draw variance (chars, 3 draws per build; kept pair identical)
|
| 66 |
+
|
| 67 |
+
| build | draws | swing |
|
| 68 |
+
|---|---|---|
|
| 69 |
+
| truebase | 4492 / 5001 / 5001 | 509 |
|
| 70 |
+
| inputOnly | 4646 / 5146 / 5146 | 500 |
|
| 71 |
+
| outputOnly | 5022 / 5795 / 5795 | 773 |
|
| 72 |
+
| fullShared | 5849 / 5250 / 5250 | 599 |
|
| 73 |
+
| oldTied | 4838 / 4688 / 4688 | 150 |
|
| 74 |
+
|
| 75 |
+
### Kept-draw mechanics
|
| 76 |
+
|
| 77 |
+
| build | chars | words | sents | med sent len | paras | dialogue turns | TTR |
|
| 78 |
+
|---|---|---|---|---|---|---|---|
|
| 79 |
+
| truebase | 5001 | 935 | 55 | 14 | 17 | 6 | 0.474 |
|
| 80 |
+
| inputOnly | 5146 | 961 | 77 | 11 | 24 | 9 | 0.456 |
|
| 81 |
+
| outputOnly | 5795 | 1085 | 83 | 13 | 30 | 12 | 0.446 |
|
| 82 |
+
| fullShared | 5250 | 960 | 64 | 13 | 19 | 7 | 0.468 |
|
| 83 |
+
| oldTied | 4688 | 862 | 58 | 14 | 16 | 4 | 0.493 |
|
| 84 |
+
|
| 85 |
+
### Shared stems (common char prefix between kept draws)
|
| 86 |
+
|
| 87 |
+
All five share the first 48 chars (prompt-forced). Base diverges at 48. The
|
| 88 |
+
four voice builds share 55. outputOnly Γ fullShared run together 182 chars;
|
| 89 |
+
outputOnly Γ oldTied 158; fullShared Γ oldTied 158.
|
| 90 |
+
|
| 91 |
+
### Trigram overlap between builds (Jaccard)
|
| 92 |
+
|
| 93 |
+
Highest: outputOnly Γ fullShared 0.0581. Voiceβvoice pairs: 0.033β0.058.
|
| 94 |
+
Baseβvoice pairs: 0.026β0.036. Lowest: base Γ inputOnly 0.0260.
|
| 95 |
+
|
| 96 |
+
### Voice-only phrasing
|
| 97 |
+
|
| 98 |
+
127 four-grams appear in β₯2 voice builds and never in base (sound, color,
|
| 99 |
+
stone, smell families; longest shared spans reach 8 words, e.g. `the clatter
|
| 100 |
+
of cart wheels on basalt paving`). Base-only phrasing (856 four-grams) is
|
| 101 |
+
abstract-connective (`a city that had`, `the fact that in`).
|
| 102 |
+
|
| 103 |
+
### Donor overlap (same prompt generated by the donor model; trigram Jaccard)
|
| 104 |
+
|
| 105 |
+
outputOnly 0.0274 > oldTied 0.0179 β truebase 0.0179 > inputOnly 0.0159 >
|
| 106 |
+
fullShared 0.0138. Four phrases verified present in both donor and outputOnly
|
| 107 |
+
texts: `distant roar of the crowd`, `the grain dole`, `the emperor himself`,
|
| 108 |
+
`basalt paving stones`.
|
| 109 |
+
|
| 110 |
+
### Casting
|
| 111 |
+
|
| 112 |
+
Base names its protagonist Aurelius with Varro as a fellow gladiator. Every
|
| 113 |
+
voice build names him Caius (Verus in oldTied) and makes Varro the lanista or
|
| 114 |
+
his assistant. All five follow one skeleton: dawn β body toll β games
|
| 115 |
+
announced β training β political meditation β evening.
|
| 116 |
+
|
| 117 |
+
### Subordination markers per 100 words
|
| 118 |
+
|
| 119 |
+
oldTied 3.36, inputOnly 3.02, fullShared 3.02, truebase 2.35, outputOnly 2.03.
|
| 120 |
+
|
| 121 |
+
### Generic-clichΓ© screen (16-item starter list)
|
| 122 |
+
|
| 123 |
+
fullShared 0 hits, oldTied 0, inputOnly 1 (`whisper`), truebase 1
|
| 124 |
+
(`symphony of`), outputOnly 2 (`whisper` Γ2, `echoed`).
|
| 125 |
+
|
| 126 |
+
### Files
|
| 127 |
+
|
| 128 |
+
`runs/p1-{truebase,inputOnly,outputOnly,fullShared,oldTied}-t0-orion-id1.jsonl`
|
| 129 |
+
with text-identical `-mate.jsonl` repeats and `.provenance.txt` per build.
|
| 130 |
+
Full measurement log: `runs/p1-fiveway-analysis.md`.
|
vlib/__init__.py
ADDED
|
File without changes
|
vlib/cache.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Base-model tensor cache."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import struct
|
| 7 |
+
from vlib import ctx
|
| 8 |
+
from vlib.ui import _fail, _now_iso, _step, _warn
|
| 9 |
+
from vlib.net import _cleanup_tmp, _safe_id, http_get_json
|
| 10 |
+
from vlib.tensors import _is_ggml_quant, _write_safetensors_streaming, read_safetensors, write_safetensors
|
| 11 |
+
from vlib.sources import _base_paths, open_source
|
| 12 |
+
from vlib.fetch import _fetch_tensor, _resolve_output_name, _source_arch
|
| 13 |
+
from vlib.registry import _assert_not_symlink
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _base_rev(base_id, base_file):
|
| 17 |
+
"""Pinned commit sha for a base cache: sidecar .rev wins, else API HEAD (then pin)."""
|
| 18 |
+
rev_path = base_file.with_suffix(".rev")
|
| 19 |
+
try:
|
| 20 |
+
if rev_path.exists():
|
| 21 |
+
rev = rev_path.read_text().strip()
|
| 22 |
+
if rev:
|
| 23 |
+
return rev
|
| 24 |
+
except Exception:
|
| 25 |
+
pass
|
| 26 |
+
rev = None
|
| 27 |
+
try:
|
| 28 |
+
rev = http_get_json(f"https://huggingface.co/api/models/{base_id}", timeout=20).get("sha")
|
| 29 |
+
except Exception:
|
| 30 |
+
rev = None
|
| 31 |
+
if rev:
|
| 32 |
+
try:
|
| 33 |
+
base_file.parent.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
tmp = rev_path.with_suffix(".rev.tmp")
|
| 35 |
+
tmp.write_text(rev + "\n")
|
| 36 |
+
tmp.replace(rev_path)
|
| 37 |
+
except Exception:
|
| 38 |
+
pass
|
| 39 |
+
return rev
|
| 40 |
+
return "main"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _cache_validate(base_file, names):
|
| 44 |
+
"""Names neither in the header nor fully covered by file bytes. Empty = trusted."""
|
| 45 |
+
try:
|
| 46 |
+
with open(base_file, "rb") as f:
|
| 47 |
+
hs = struct.unpack("<Q", f.read(8))[0]
|
| 48 |
+
hdr = json.loads(f.read(hs))
|
| 49 |
+
size = os.path.getsize(base_file)
|
| 50 |
+
except Exception:
|
| 51 |
+
return list(names)
|
| 52 |
+
missing = []
|
| 53 |
+
for n in names:
|
| 54 |
+
info = hdr.get(n)
|
| 55 |
+
if not isinstance(info, dict):
|
| 56 |
+
missing.append(n)
|
| 57 |
+
continue
|
| 58 |
+
try:
|
| 59 |
+
off = info["data_offsets"]
|
| 60 |
+
if not (isinstance(off, (list, tuple)) and len(off) == 2
|
| 61 |
+
and 0 <= int(off[0]) <= int(off[1]) and 8 + hs + int(off[1]) <= size):
|
| 62 |
+
missing.append(n)
|
| 63 |
+
except Exception:
|
| 64 |
+
missing.append(n)
|
| 65 |
+
return missing
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _cache_topup_write(base_file, staged):
|
| 69 |
+
"""Append staged tensors to a base cache + atomically rewrite its header.
|
| 70 |
+
staged: [(name, blob|None, dtype, shape, raw_path|None)] β blob XOR raw_path.
|
| 71 |
+
Offsets are data-relative so existing entries never shift. Returns new header.
|
| 72 |
+
Raises OSError on failure; base_file is only ever swapped in whole (tmp+rename)."""
|
| 73 |
+
_assert_not_symlink(base_file)
|
| 74 |
+
import shutil
|
| 75 |
+
# append + atomic header rewrite (offsets are data-relative: existing entries don't shift)
|
| 76 |
+
with open(base_file, "rb") as f:
|
| 77 |
+
old_hs = struct.unpack("<Q", f.read(8))[0]
|
| 78 |
+
old_hdr = json.loads(f.read(old_hs))
|
| 79 |
+
old_size = os.path.getsize(base_file)
|
| 80 |
+
old_data_len = old_size - 8 - old_hs
|
| 81 |
+
new_hdr = {k: v for k, v in old_hdr.items()}
|
| 82 |
+
off = old_data_len
|
| 83 |
+
for m, blob, dtype, shape, _rp in staged:
|
| 84 |
+
nbytes = len(blob) if blob is not None else Path(_rp).stat().st_size
|
| 85 |
+
new_hdr[m] = {"dtype": dtype, "shape": list(shape), "data_offsets": [off, off + nbytes]}
|
| 86 |
+
off += nbytes
|
| 87 |
+
new_hj = json.dumps(new_hdr).encode("utf-8")
|
| 88 |
+
tmp_new = base_file.with_suffix(".cache.tmp")
|
| 89 |
+
with open(tmp_new, "wb") as out:
|
| 90 |
+
out.write(struct.pack("<Q", len(new_hj)))
|
| 91 |
+
out.write(new_hj)
|
| 92 |
+
with open(base_file, "rb") as f:
|
| 93 |
+
f.seek(8 + old_hs)
|
| 94 |
+
shutil.copyfileobj(f, out, 1 << 20)
|
| 95 |
+
for _m, blob, _dt, _sh, _rp in staged:
|
| 96 |
+
if blob is not None:
|
| 97 |
+
out.write(blob)
|
| 98 |
+
else:
|
| 99 |
+
with open(_rp, "rb") as f:
|
| 100 |
+
shutil.copyfileobj(f, out, 1 << 20)
|
| 101 |
+
out.flush()
|
| 102 |
+
try:
|
| 103 |
+
os.fsync(out.fileno())
|
| 104 |
+
except Exception:
|
| 105 |
+
pass
|
| 106 |
+
try:
|
| 107 |
+
try:
|
| 108 |
+
os.chmod(tmp_new, 0o600)
|
| 109 |
+
except Exception:
|
| 110 |
+
pass
|
| 111 |
+
tmp_new.replace(base_file)
|
| 112 |
+
except Exception as e:
|
| 113 |
+
try:
|
| 114 |
+
tmp_new.unlink(missing_ok=True)
|
| 115 |
+
except Exception:
|
| 116 |
+
pass
|
| 117 |
+
raise OSError(f"Could not update base cache: {e}")
|
| 118 |
+
return new_hdr
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _ensure_base_tensors(base_id, names, args=None):
|
| 122 |
+
"""Base cache with per-tensor trust: top-ups what's missing, self-heals partial caches.
|
| 123 |
+
All fetches pin one revision so a repo update mid-cache can't mix commits. Returns base_file."""
|
| 124 |
+
tmp = _base_paths(base_id)
|
| 125 |
+
if tmp is None:
|
| 126 |
+
_fail(f" β Invalid base '{base_id}'")
|
| 127 |
+
base_file, base_json = tmp
|
| 128 |
+
want = [n for n in names if n != "delta.voice.marker"]
|
| 129 |
+
if not want:
|
| 130 |
+
_fail(" β No tensors requested from base.")
|
| 131 |
+
import fcntl
|
| 132 |
+
base_file.parent.mkdir(parents=True, exist_ok=True)
|
| 133 |
+
lock_path = base_file.with_suffix(".lock")
|
| 134 |
+
try:
|
| 135 |
+
lock_fh = open(lock_path, "w")
|
| 136 |
+
except Exception:
|
| 137 |
+
lock_fh = None
|
| 138 |
+
try:
|
| 139 |
+
if lock_fh is not None:
|
| 140 |
+
try:
|
| 141 |
+
fcntl.flock(lock_fh, fcntl.LOCK_EX)
|
| 142 |
+
except Exception:
|
| 143 |
+
pass
|
| 144 |
+
return _ensure_base_tensors_locked(base_id, base_file, base_json, want)
|
| 145 |
+
finally:
|
| 146 |
+
if lock_fh is not None:
|
| 147 |
+
try:
|
| 148 |
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
| 149 |
+
except Exception:
|
| 150 |
+
pass
|
| 151 |
+
try:
|
| 152 |
+
lock_fh.close()
|
| 153 |
+
except Exception:
|
| 154 |
+
pass
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _ensure_base_tensors_locked(base_id, base_file, base_json, want):
|
| 158 |
+
import shutil
|
| 159 |
+
rev_path = base_file.with_suffix(".rev")
|
| 160 |
+
legacy = base_file.exists() and not rev_path.exists()
|
| 161 |
+
if not base_file.exists():
|
| 162 |
+
_cache_base(base_id, base_file, base_json)
|
| 163 |
+
elif legacy:
|
| 164 |
+
# Pre-rev cache of unknown lineage: re-fetch one held tensor at HEAD and
|
| 165 |
+
# compare bytes. Match -> same lineage, pin HEAD. Differ -> wipe, start over.
|
| 166 |
+
# Probe failure (offline?) keeps the cache with a loud warning, never wipes.
|
| 167 |
+
try:
|
| 168 |
+
with open(base_file, "rb") as f:
|
| 169 |
+
hs = struct.unpack("<Q", f.read(8))[0]
|
| 170 |
+
hdr = json.loads(f.read(hs))
|
| 171 |
+
held = [k for k in hdr.keys() if k != "__metadata__"]
|
| 172 |
+
except Exception:
|
| 173 |
+
held = []
|
| 174 |
+
verified, same = False, False
|
| 175 |
+
if held:
|
| 176 |
+
probe = held[0]
|
| 177 |
+
probe_dir = ctx.VOICES_DIR / ".parts" / _safe_id("base-probe", base_id)
|
| 178 |
+
try:
|
| 179 |
+
src = open_source(base_id)
|
| 180 |
+
pf = _fetch_tensor(src, probe, probe_dir)
|
| 181 |
+
fresh = Path(pf[1]).read_bytes() if pf[0] == "file" else bytes(pf[2])
|
| 182 |
+
with open(base_file, "rb") as f:
|
| 183 |
+
f.seek(8 + hs + hdr[probe]["data_offsets"][0])
|
| 184 |
+
have = f.read(hdr[probe]["data_offsets"][1] - hdr[probe]["data_offsets"][0])
|
| 185 |
+
verified, same = True, (fresh == have)
|
| 186 |
+
except SystemExit:
|
| 187 |
+
raise
|
| 188 |
+
except Exception:
|
| 189 |
+
verified, same = False, False
|
| 190 |
+
finally:
|
| 191 |
+
_cleanup_tmp(probe_dir)
|
| 192 |
+
if verified and not same:
|
| 193 |
+
_warn(" Base cache predates revision pinning and no longer matches HEAD β re-fetching.")
|
| 194 |
+
try:
|
| 195 |
+
base_file.unlink(missing_ok=True)
|
| 196 |
+
except Exception:
|
| 197 |
+
pass
|
| 198 |
+
_cache_base(base_id, base_file, base_json)
|
| 199 |
+
elif not verified:
|
| 200 |
+
_warn(" Base cache lineage unverified (offline?) β proceeding, mixed revisions possible.")
|
| 201 |
+
rev = _base_rev(base_id, base_file)
|
| 202 |
+
missing = _cache_validate(base_file, want)
|
| 203 |
+
if not missing:
|
| 204 |
+
return base_file
|
| 205 |
+
_step(f"Topping up base {base_id} ({len(missing)} tensor(s) missing)β¦")
|
| 206 |
+
try:
|
| 207 |
+
src = open_source(base_id, rev=rev)
|
| 208 |
+
except SystemExit:
|
| 209 |
+
raise
|
| 210 |
+
except Exception as e:
|
| 211 |
+
_fail(f" β Could not reach base {base_id} for top-up: {e}")
|
| 212 |
+
tmp_dir = ctx.VOICES_DIR / ".parts" / _safe_id("base", base_id)
|
| 213 |
+
staged = []
|
| 214 |
+
try:
|
| 215 |
+
for m in missing:
|
| 216 |
+
try:
|
| 217 |
+
fetched = _fetch_tensor(src, m, tmp_dir)
|
| 218 |
+
except (KeyError, ValueError) as e:
|
| 219 |
+
_fail(f" β Base {base_id} has no tensor '{m}'. Delta needs same names on both sides.")
|
| 220 |
+
kind, raw_path, data, dtype, shape = fetched
|
| 221 |
+
if _is_ggml_quant(dtype):
|
| 222 |
+
# quant blocks can't live in a .safetensors cache: dequant once to F32
|
| 223 |
+
f32 = src.read_f32(m)
|
| 224 |
+
staged.append((m, f32.astype("float32").tobytes(), "F32", tuple(int(x) for x in f32.shape), None))
|
| 225 |
+
elif kind == "file":
|
| 226 |
+
staged.append((m, None, dtype, tuple(int(x) for x in shape), raw_path))
|
| 227 |
+
else:
|
| 228 |
+
staged.append((m, bytes(data), dtype, tuple(int(x) for x in shape), None))
|
| 229 |
+
except SystemExit:
|
| 230 |
+
_cleanup_tmp(tmp_dir)
|
| 231 |
+
raise
|
| 232 |
+
try:
|
| 233 |
+
new_hdr = _cache_topup_write(base_file, staged)
|
| 234 |
+
except OSError as e:
|
| 235 |
+
_cleanup_tmp(tmp_dir)
|
| 236 |
+
_fail(f" β {e}")
|
| 237 |
+
_cleanup_tmp(tmp_dir)
|
| 238 |
+
# refresh base json with the full tensor list
|
| 239 |
+
try:
|
| 240 |
+
meta = {"source_hf_model": base_id, "revision": rev, "downloaded_at": _now_iso(),
|
| 241 |
+
"tensors": [{"name": k, "shape": list(v["shape"]), "dtype": v["dtype"]}
|
| 242 |
+
for k, v in new_hdr.items() if k != "__metadata__"]}
|
| 243 |
+
jtmp = base_json.with_suffix(".tmp")
|
| 244 |
+
fd = os.open(str(jtmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
| 245 |
+
try:
|
| 246 |
+
os.write(fd, json.dumps(meta, indent=2).encode("utf-8") + b"\n")
|
| 247 |
+
try:
|
| 248 |
+
os.fsync(fd)
|
| 249 |
+
except Exception:
|
| 250 |
+
pass
|
| 251 |
+
finally:
|
| 252 |
+
os.close(fd)
|
| 253 |
+
try:
|
| 254 |
+
jtmp.replace(base_json)
|
| 255 |
+
except FileExistsError:
|
| 256 |
+
jtmp.unlink(missing_ok=True)
|
| 257 |
+
except Exception:
|
| 258 |
+
pass
|
| 259 |
+
return base_file
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _cache_base(base_id, base_file, base_json):
|
| 263 |
+
_step(f"Caching base {base_id}β¦")
|
| 264 |
+
src = open_source(base_id)
|
| 265 |
+
names = src.names()
|
| 266 |
+
bname = _resolve_output_name(src, None, names)
|
| 267 |
+
if bname is None:
|
| 268 |
+
_fail(f" β Could not find the output tensor in base {base_id}.")
|
| 269 |
+
ref = src.ref(bname)
|
| 270 |
+
tmp_dir = ctx.VOICES_DIR / ".parts" / _safe_id("base", base_id)
|
| 271 |
+
fetched = _fetch_tensor(src, bname, tmp_dir)
|
| 272 |
+
# size now: _cleanup_tmp below deletes raw.bin before the metadata write
|
| 273 |
+
fetched_bytes = fetched[1].stat().st_size if fetched[0] == "file" else len(fetched[2])
|
| 274 |
+
_assert_not_symlink(base_file.parent if base_file.parent.exists() else base_file)
|
| 275 |
+
base_file.parent.mkdir(parents=True, exist_ok=True)
|
| 276 |
+
try:
|
| 277 |
+
os.chmod(base_file.parent, 0o700)
|
| 278 |
+
except Exception:
|
| 279 |
+
pass
|
| 280 |
+
# avoid TOCTOU race if two processes cache same base
|
| 281 |
+
if base_file.exists():
|
| 282 |
+
_cleanup_tmp(tmp_dir)
|
| 283 |
+
return
|
| 284 |
+
tmp_st = base_file.parent / "base.tmp"
|
| 285 |
+
if fetched[0] == "file":
|
| 286 |
+
_write_safetensors_streaming(bname, str(fetched[1]), fetched[1].stat().st_size, fetched[3], fetched[4], str(tmp_st))
|
| 287 |
+
else:
|
| 288 |
+
write_safetensors({bname: (fetched[2], fetched[3], fetched[4])}, str(tmp_st))
|
| 289 |
+
try:
|
| 290 |
+
tmp_st.replace(base_file)
|
| 291 |
+
except FileExistsError:
|
| 292 |
+
tmp_st.unlink(missing_ok=True)
|
| 293 |
+
_cleanup_tmp(tmp_dir)
|
| 294 |
+
# atomic 0600 json
|
| 295 |
+
jtmp = base_json.with_suffix(".tmp")
|
| 296 |
+
fd = os.open(str(jtmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
| 297 |
+
try:
|
| 298 |
+
os.write(fd, json.dumps({
|
| 299 |
+
"source_hf_model": base_id, "tensor_name": bname, "dtype": fetched[3],
|
| 300 |
+
"shape": list(fetched[4]), "bytes": fetched_bytes,
|
| 301 |
+
"tensors": [{"name": bname, "shape": list(fetched[4]), "dtype": fetched[3]}],
|
| 302 |
+
"downloaded_at": _now_iso(),
|
| 303 |
+
}, indent=2).encode("utf-8") + b"\n")
|
| 304 |
+
try:
|
| 305 |
+
os.fsync(fd)
|
| 306 |
+
except Exception:
|
| 307 |
+
pass
|
| 308 |
+
finally:
|
| 309 |
+
os.close(fd)
|
| 310 |
+
try:
|
| 311 |
+
jtmp.replace(base_json)
|
| 312 |
+
except FileExistsError:
|
| 313 |
+
jtmp.unlink(missing_ok=True)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def _load_safetensors_or_gguf_f32(path):
|
| 317 |
+
# Returns (tensors_dict, dtype_map) for delta math. Safetensors preferred (no dequant).
|
| 318 |
+
# GGUF quant falls back to full F32 dequant once (unavoidable, warn; efficient for <50MB tests).
|
| 319 |
+
try:
|
| 320 |
+
hdr, tens = read_safetensors(str(path))
|
| 321 |
+
return hdr, tens, False
|
| 322 |
+
except Exception:
|
| 323 |
+
pass
|
| 324 |
+
try:
|
| 325 |
+
src = open_source(str(path))
|
| 326 |
+
names = [n for n in src.names() if n != "delta.voice.marker"]
|
| 327 |
+
if not names:
|
| 328 |
+
raise ValueError("no tensors")
|
| 329 |
+
# Pick output head or first for delta math (single-tensor delta path).
|
| 330 |
+
tname = None
|
| 331 |
+
try:
|
| 332 |
+
arch, cfg = _source_arch(src)
|
| 333 |
+
tname = _resolve_output_name(src, cfg, names)
|
| 334 |
+
except Exception:
|
| 335 |
+
tname = None
|
| 336 |
+
if tname is None or tname not in names:
|
| 337 |
+
tname = names[0]
|
| 338 |
+
ref = src.ref(tname)
|
| 339 |
+
_warn(f" {Path(path).name} is {ref.dtype} ({src.kind}) β dequanting once to F32 for delta math (unavoidable).")
|
| 340 |
+
arr = src.read_f32(tname)
|
| 341 |
+
# Build synthetic safetensors-like dict for chunked math below.
|
| 342 |
+
return {"__gguf_f32__": {"dtype": "F32", "shape": list(arr.shape), "_arr": arr}, tname: {"dtype": "F32", "shape": list(arr.shape), "_arr": arr}}, {tname: {"dtype": "F32", "shape": list(arr.shape)}}, True
|
| 343 |
+
except Exception as e:
|
| 344 |
+
raise ValueError(f"{path} is not a valid .safetensors or .gguf for delta: {e}")
|
vlib/cmds_misc.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Misc commands: list, info, remove, cleanup, auth, path, doctor."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import getpass
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import textwrap
|
| 9 |
+
import time
|
| 10 |
+
import urllib.error
|
| 11 |
+
import urllib.request
|
| 12 |
+
from vlib import ctx
|
| 13 |
+
from vlib.ui import _confirm, _fail, _noninteractive, _note, _ok, _say, _step, _term_width, _trunc, _warn
|
| 14 |
+
from vlib.net import _get_hf_token, _hf_auth_headers
|
| 15 |
+
from vlib.tensors import read_safetensors
|
| 16 |
+
from vlib.sources import _base_paths
|
| 17 |
+
from vlib.registry import _iter_voices, _read_meta, _registry_resolve, log_operation
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def cmd_list(args):
|
| 21 |
+
if getattr(args, "target", None) == "base" or getattr(args, "list_base", False):
|
| 22 |
+
_list_bases()
|
| 23 |
+
return
|
| 24 |
+
|
| 25 |
+
voices = [d for d in _iter_voices() if (d / "voice.json").exists()]
|
| 26 |
+
if not voices:
|
| 27 |
+
_say(" No voices stored yet. Use 'voice get <model>' to save one.")
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
tw = _term_width()
|
| 31 |
+
w_name = 26 if tw >= 80 else max(14, tw - 54)
|
| 32 |
+
w_src = 40 if tw >= 80 else max(18, tw - w_name - 16)
|
| 33 |
+
w_type = 8
|
| 34 |
+
_say(f" {_trunc('Name', w_name):<{w_name}} {_trunc('Source', w_src):<{w_src}} {_trunc('Type', w_type):<{w_type}}")
|
| 35 |
+
_say(f" {'β'*w_name} {'β'*w_src} {'β'*w_type}")
|
| 36 |
+
for d in voices:
|
| 37 |
+
meta = _read_meta(d / "voice.json") or {}
|
| 38 |
+
name = meta.get("name", d.name)
|
| 39 |
+
src = meta.get("source_hf_model") or meta.get("source") or "β"
|
| 40 |
+
typ = meta.get("type", "voice")
|
| 41 |
+
_say(f" {_trunc(name, w_name):<{w_name}} {_trunc(src, w_src):<{w_src}} {_trunc(typ, w_type):<{w_type}}")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _list_bases():
|
| 45 |
+
base_root = ctx.VOICES_DIR / "base"
|
| 46 |
+
if not base_root.exists() or not any(base_root.rglob("*.json")):
|
| 47 |
+
_say(" No bases cached yet. Use 'voice delta <voice>' to fetch one.")
|
| 48 |
+
return
|
| 49 |
+
tw = _term_width()
|
| 50 |
+
w_base = 40 if tw >= 80 else max(20, tw - 30)
|
| 51 |
+
_say(f" {_trunc('Base', w_base):<{w_base}} {'Bytes':<18} {'Fetched':<12}")
|
| 52 |
+
_say(f" {'β'*w_base} {'β'*18} {'β'*12}")
|
| 53 |
+
seen = set()
|
| 54 |
+
for jpath in sorted(base_root.rglob("*.json")):
|
| 55 |
+
try:
|
| 56 |
+
meta = json.loads(jpath.read_text())
|
| 57 |
+
except Exception:
|
| 58 |
+
continue
|
| 59 |
+
parts = jpath.relative_to(base_root).parts
|
| 60 |
+
base_id = f"{parts[0]}/{Path(parts[1]).stem}" if len(parts) == 2 else f"{parts[0]}/{parts[1]}"
|
| 61 |
+
if base_id in seen:
|
| 62 |
+
continue
|
| 63 |
+
seen.add(base_id)
|
| 64 |
+
b = meta.get("bytes")
|
| 65 |
+
b_str = f"{b} ({b/1e6:.1f} MB)" if isinstance(b, int) else "β"
|
| 66 |
+
fetched = meta.get("downloaded_at", "β")[:10]
|
| 67 |
+
_say(f" {_trunc(base_id, w_base):<{w_base}} {_trunc(b_str, 18):<18} {_trunc(fetched, 12):<12}")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _voice_tensors(meta):
|
| 71 |
+
"""Normalize voice metadata to a (tensors, synthetic) pair.
|
| 72 |
+
Flat-only files (tensor_name/shape/dtype, no tensors list) become a
|
| 73 |
+
synthetic 1-entry list so readers share one code path."""
|
| 74 |
+
claims = meta.get("tensors")
|
| 75 |
+
if isinstance(claims, list) and claims:
|
| 76 |
+
return list(claims), False
|
| 77 |
+
if meta.get("tensor_name"):
|
| 78 |
+
return [{"name": meta.get("tensor_name"), "shape": meta.get("shape"),
|
| 79 |
+
"dtype": meta.get("dtype")}], True
|
| 80 |
+
return [], False
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _verify_voice_file(path, meta):
|
| 84 |
+
"""Cross-check voice.json claims against the artifact's own header.
|
| 85 |
+
Returns warning strings (empty = agrees). Never raises: an unreadable
|
| 86 |
+
file is a note, not a failure."""
|
| 87 |
+
out = []
|
| 88 |
+
if str(path).endswith(".gguf"):
|
| 89 |
+
return out # quant type names differ; nothing truthful to compare
|
| 90 |
+
try:
|
| 91 |
+
header, tens = read_safetensors(str(path))
|
| 92 |
+
except Exception:
|
| 93 |
+
return [" ? Could not read the artifact header; showing voice.json as-is."]
|
| 94 |
+
names = [k for k in tens if k != "delta.voice.marker"]
|
| 95 |
+
claims, _synthetic = _voice_tensors(meta)
|
| 96 |
+
if claims:
|
| 97 |
+
for entry in claims:
|
| 98 |
+
tname = entry.get("name")
|
| 99 |
+
if tname not in tens:
|
| 100 |
+
out.append(f" ! MISMATCH tensor: voice.json lists '{tname}', file doesn't hold it.")
|
| 101 |
+
continue
|
| 102 |
+
info = tens[tname]
|
| 103 |
+
claimed_shape = entry.get("shape")
|
| 104 |
+
if claimed_shape is not None and list(claimed_shape) != list(info["shape"]):
|
| 105 |
+
out.append(f" ! MISMATCH shape '{tname}': voice.json says {list(claimed_shape)}, "
|
| 106 |
+
f"file holds {list(info['shape'])} β file wins.")
|
| 107 |
+
claimed_dtype = (entry.get("dtype") or "").upper()
|
| 108 |
+
if claimed_dtype and claimed_dtype != str(info["dtype"]).upper():
|
| 109 |
+
out.append(f" ! MISMATCH dtype '{tname}': voice.json says {entry.get('dtype')}, "
|
| 110 |
+
f"file holds {info['dtype']} β file wins.")
|
| 111 |
+
claimed_names = {e.get("name") for e in claims}
|
| 112 |
+
for extra in names:
|
| 113 |
+
if extra not in claimed_names:
|
| 114 |
+
out.append(f" ! File holds '{extra}' which voice.json doesn't list.")
|
| 115 |
+
if not names:
|
| 116 |
+
out.append(" ? No tensors found in the artifact.")
|
| 117 |
+
has_marker = "delta.voice.marker" in tens
|
| 118 |
+
if meta.get("type") == "delta" and not has_marker:
|
| 119 |
+
out.append(" ! voice.json claims delta but the file has no delta marker.")
|
| 120 |
+
if meta.get("type") != "delta" and has_marker:
|
| 121 |
+
out.append(" ! File carries a delta marker but voice.json type is "
|
| 122 |
+
f"'{meta.get('type', 'voice')}'.")
|
| 123 |
+
return out
|
| 124 |
+
# No tensor claims at all (minimal legacy json): only the delta-marker
|
| 125 |
+
# check below is truthful; shape/dtype have nothing to compare against.
|
| 126 |
+
if not names:
|
| 127 |
+
return [" ? No tensors found in the artifact."]
|
| 128 |
+
has_marker = "delta.voice.marker" in tens
|
| 129 |
+
if meta.get("type") == "delta" and not has_marker:
|
| 130 |
+
out.append(" ! voice.json claims delta but the file has no delta marker.")
|
| 131 |
+
if meta.get("type") != "delta" and has_marker:
|
| 132 |
+
out.append(" ! File carries a delta marker but voice.json type is "
|
| 133 |
+
f"'{meta.get('type', 'voice')}'.")
|
| 134 |
+
return out
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def cmd_info(args):
|
| 138 |
+
voicename = args.voicename.strip()
|
| 139 |
+
voice_path, voice_meta = _registry_resolve(voicename)
|
| 140 |
+
if voice_path is None or not voice_path.exists():
|
| 141 |
+
bps = _base_paths(voicename)
|
| 142 |
+
if bps and bps[0].exists():
|
| 143 |
+
meta = _read_meta(bps[1]) or {}
|
| 144 |
+
_say(f" Name: {voicename}")
|
| 145 |
+
_say(f" Type: base")
|
| 146 |
+
_say(f" Source: https://huggingface.co/{meta.get('source_hf_model', voicename)}")
|
| 147 |
+
_say(f" Tensor: {meta.get('tensor_name', 'β')}")
|
| 148 |
+
_say(f" Shape: {meta.get('shape', 'β')}")
|
| 149 |
+
_say(f" Dtype: {meta.get('dtype', 'β')}")
|
| 150 |
+
_say(f" File: {bps[0]}")
|
| 151 |
+
return
|
| 152 |
+
_fail(f" β Voice '{voicename}' not found. Try voice list")
|
| 153 |
+
|
| 154 |
+
meta = _read_meta(Path(voice_path).parent / "voice.json") or voice_meta or {}
|
| 155 |
+
name = meta.get("name", voicename)
|
| 156 |
+
source = meta.get("source_hf_model") or meta.get("source") or "β"
|
| 157 |
+
typ = meta.get("type", "voice")
|
| 158 |
+
_say(f" Name: {name}")
|
| 159 |
+
_say(f" Type: {typ}")
|
| 160 |
+
_say(f" Source: {source}")
|
| 161 |
+
if meta.get("family") or meta.get("architecture"):
|
| 162 |
+
_say(f" Family: {meta.get('family', 'β')} ({meta.get('architecture', 'β')})")
|
| 163 |
+
tensors, _synthetic = _voice_tensors(meta)
|
| 164 |
+
only = tensors[0] if len(tensors) == 1 else {}
|
| 165 |
+
_say(f" Tensor: {meta.get('tensor_name') or only.get('name', 'β')}")
|
| 166 |
+
_say(f" Shape: {meta.get('shape') or only.get('shape', 'β')}")
|
| 167 |
+
_say(f" Dtype: {meta.get('dtype') or only.get('dtype', 'β')}")
|
| 168 |
+
if len(tensors) > 1:
|
| 169 |
+
for entry in tensors:
|
| 170 |
+
_say(f" - {entry.get('name')}: {entry.get('shape')} Β· {entry.get('dtype')}")
|
| 171 |
+
b = meta.get("bytes")
|
| 172 |
+
if isinstance(b, int):
|
| 173 |
+
_say(f" Bytes: {b} ({b/1e6:.1f} MB)")
|
| 174 |
+
if meta.get("base"):
|
| 175 |
+
_say(f" Base: {meta.get('base')}")
|
| 176 |
+
if meta.get("license"):
|
| 177 |
+
_say(f" License: {_trunc(meta['license'], 80)}")
|
| 178 |
+
_say(f" Saved: {meta.get('downloaded_at', meta.get('fetched_at', 'β'))}")
|
| 179 |
+
_say(f" File: {voice_path}")
|
| 180 |
+
for w in _verify_voice_file(str(voice_path), meta):
|
| 181 |
+
_warn(w)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def cmd_remove(args):
|
| 185 |
+
name = args.voice.strip().lower()
|
| 186 |
+
target = next((d for d in _iter_voices() if d.name.lower() == name), None)
|
| 187 |
+
if target is None:
|
| 188 |
+
_fail(f" β Voice '{args.voice}' not found.")
|
| 189 |
+
if _noninteractive(args) and not getattr(args, "yes", False):
|
| 190 |
+
_fail(f" β Refusing to remove '{target.name}' in non-interactive mode without --yes.")
|
| 191 |
+
if not _confirm(f" Remove voice '{target.name}'? [y/N]: ", args):
|
| 192 |
+
_say(" Cancelled.")
|
| 193 |
+
sys.exit(0)
|
| 194 |
+
import shutil
|
| 195 |
+
shutil.rmtree(target)
|
| 196 |
+
log_operation({"op": "remove", "voice": target.name, "output": str(target), "status": "ok"})
|
| 197 |
+
_ok(f"Removed '{target.name}'.")
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def cmd_cleanup(args):
|
| 201 |
+
parts_dir = ctx.VOICES_DIR / ".parts"
|
| 202 |
+
if not parts_dir.exists() or not any(parts_dir.iterdir()):
|
| 203 |
+
_ok("No incomplete downloads to clean.")
|
| 204 |
+
return
|
| 205 |
+
removed, reclaimed = 0, 0
|
| 206 |
+
import shutil
|
| 207 |
+
force = bool(getattr(args, "force", False))
|
| 208 |
+
for p in sorted(parts_dir.iterdir()):
|
| 209 |
+
try:
|
| 210 |
+
if p.is_dir():
|
| 211 |
+
lock = p / ".lock"
|
| 212 |
+
if lock.exists() and not force:
|
| 213 |
+
try:
|
| 214 |
+
if time.time() - lock.stat().st_mtime < 600:
|
| 215 |
+
_note(f"Skipping active download {p.name}")
|
| 216 |
+
continue
|
| 217 |
+
except Exception:
|
| 218 |
+
pass
|
| 219 |
+
sz = sum(f.stat().st_size for f in p.rglob("*") if f.is_file())
|
| 220 |
+
shutil.rmtree(p)
|
| 221 |
+
removed += 1
|
| 222 |
+
reclaimed += sz
|
| 223 |
+
_ok(f"Removed {p.name}/ ({sz/1e6:.1f} MB)")
|
| 224 |
+
else:
|
| 225 |
+
sz = p.stat().st_size if p.is_file() else 0
|
| 226 |
+
p.unlink()
|
| 227 |
+
removed += 1
|
| 228 |
+
reclaimed += sz
|
| 229 |
+
_ok(f"Removed {p.name}")
|
| 230 |
+
except Exception as e:
|
| 231 |
+
if ctx.VERBOSE:
|
| 232 |
+
_warn(f"Could not remove {p}: {e}")
|
| 233 |
+
_ok(f"Cleaned {removed} entry(s), reclaimed {reclaimed/1e6:.1f} MB.")
|
| 234 |
+
try:
|
| 235 |
+
if not any(parts_dir.iterdir()):
|
| 236 |
+
parts_dir.rmdir()
|
| 237 |
+
except Exception:
|
| 238 |
+
pass
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def cmd_auth(args):
|
| 242 |
+
tok_path = ctx.HF_TOKEN_PATH
|
| 243 |
+
if getattr(args, "clear", False):
|
| 244 |
+
try:
|
| 245 |
+
tok_path.unlink(missing_ok=True)
|
| 246 |
+
_ok("Removed stored HF token.")
|
| 247 |
+
except Exception as e:
|
| 248 |
+
_fail(f" β Could not remove token: {e}")
|
| 249 |
+
return
|
| 250 |
+
if getattr(args, "show", False):
|
| 251 |
+
tok = _get_hf_token()
|
| 252 |
+
if not tok:
|
| 253 |
+
_say(" β No HF token stored. Run: voice auth")
|
| 254 |
+
return
|
| 255 |
+
masked = tok[:4] + "Β·" * (len(tok) - 8) + tok[-4:] if len(tok) > 8 else "Β·" * len(tok)
|
| 256 |
+
src = "environment" if os.environ.get("HF_TOKEN") or os.environ.get("HF_HUB_TOKEN") else str(tok_path)
|
| 257 |
+
_say(f" β HF token present: {masked} ({src})")
|
| 258 |
+
return
|
| 259 |
+
_say(" Paste your HF token (hf_β¦): it stays on this machine, 0600, never logged.")
|
| 260 |
+
try:
|
| 261 |
+
try:
|
| 262 |
+
tok = getpass.getpass(" Paste HF token: ").strip()
|
| 263 |
+
except Exception:
|
| 264 |
+
tok = input(" Paste HF token: ").strip()
|
| 265 |
+
except (EOFError, KeyboardInterrupt):
|
| 266 |
+
_say(" β Cancelled.")
|
| 267 |
+
sys.exit(0)
|
| 268 |
+
if not tok:
|
| 269 |
+
_say(" β No token entered.")
|
| 270 |
+
sys.exit(0)
|
| 271 |
+
if not tok.startswith("hf_") or len(tok) < 10:
|
| 272 |
+
_fail(" β That doesn't look like an HF token (should start with hf_β¦).")
|
| 273 |
+
_step("Verifyingβ¦")
|
| 274 |
+
try:
|
| 275 |
+
req = urllib.request.Request("https://huggingface.co/api/whoami-v2",
|
| 276 |
+
headers=_hf_auth_headers("https://huggingface.co/api/whoami-v2", {"Authorization": f"Bearer {tok}"}))
|
| 277 |
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
| 278 |
+
data = json.loads(resp.read())
|
| 279 |
+
_ok(f"Verified as {data.get('name') or data.get('id') or 'ok'}.")
|
| 280 |
+
except urllib.error.HTTPError as e:
|
| 281 |
+
if e.code in (401, 403):
|
| 282 |
+
_fail(" β Token rejected (401). Check it at https://huggingface.co/settings/tokens")
|
| 283 |
+
_warn(f"Verification skipped (HTTP {e.code}), saving anyway.")
|
| 284 |
+
except Exception:
|
| 285 |
+
_warn("Could not verify now β saving anyway.")
|
| 286 |
+
try:
|
| 287 |
+
ctx.VOICES_DIR.mkdir(parents=True, exist_ok=True)
|
| 288 |
+
try:
|
| 289 |
+
os.chmod(ctx.VOICES_DIR, 0o700)
|
| 290 |
+
except Exception:
|
| 291 |
+
pass
|
| 292 |
+
tmp = tok_path.with_suffix(".tmp")
|
| 293 |
+
# atomic 0600: create with restricted perms, avoid 0644 race
|
| 294 |
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
| 295 |
+
try:
|
| 296 |
+
os.write(fd, (tok.strip() + "\n").encode("utf-8"))
|
| 297 |
+
try:
|
| 298 |
+
os.fsync(fd)
|
| 299 |
+
except Exception:
|
| 300 |
+
pass
|
| 301 |
+
finally:
|
| 302 |
+
os.close(fd)
|
| 303 |
+
try:
|
| 304 |
+
os.chmod(tmp, 0o600)
|
| 305 |
+
except Exception:
|
| 306 |
+
pass
|
| 307 |
+
tmp.replace(tok_path)
|
| 308 |
+
try:
|
| 309 |
+
os.chmod(tok_path, 0o600)
|
| 310 |
+
except Exception:
|
| 311 |
+
pass
|
| 312 |
+
_ok(f"Saved to {tok_path} (0600).")
|
| 313 |
+
except Exception as e:
|
| 314 |
+
_fail(f" β Could not save token: {e}")
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def cmd_path(_args):
|
| 318 |
+
script = _entry_script()
|
| 319 |
+
if script is None:
|
| 320 |
+
_fail(" β Cannot locate the voice entry script in this interpreter. Run voice.py directly.")
|
| 321 |
+
script = Path(script).resolve()
|
| 322 |
+
vlib_src = script.parent / "vlib"
|
| 323 |
+
if sys.platform.startswith("win"):
|
| 324 |
+
import shutil as _sh
|
| 325 |
+
bindir = Path(os.environ.get("LOCALAPPDATA", "")) / "voice-cli"
|
| 326 |
+
if str(bindir) == "voice-cli":
|
| 327 |
+
_fail(" β Couldn't find a Windows app-data folder to install into.")
|
| 328 |
+
bindir.mkdir(parents=True, exist_ok=True)
|
| 329 |
+
_copy_tree(vlib_src, bindir / "vlib")
|
| 330 |
+
_sh.copy2(str(script), str(bindir / "voice.py"))
|
| 331 |
+
if _sh.which("voice"):
|
| 332 |
+
_ok("Voice updated on your PATH.")
|
| 333 |
+
else:
|
| 334 |
+
_ok("Voice is ready. Add this to your PATH (PowerShell):")
|
| 335 |
+
_say(f' setx PATH "%PATH%;{bindir}"')
|
| 336 |
+
return
|
| 337 |
+
import shutil
|
| 338 |
+
existing = shutil.which("voice")
|
| 339 |
+
if existing:
|
| 340 |
+
try:
|
| 341 |
+
same = Path(existing).resolve() == script
|
| 342 |
+
except Exception:
|
| 343 |
+
same = False
|
| 344 |
+
if same:
|
| 345 |
+
_ok("Voice is already on your PATH.")
|
| 346 |
+
return
|
| 347 |
+
# Stale install: relink only when the old entry is our symlink
|
| 348 |
+
# (points at a voice.py); never touch a foreign binary.
|
| 349 |
+
try:
|
| 350 |
+
old_target = os.readlink(existing)
|
| 351 |
+
except OSError:
|
| 352 |
+
_fail(f" β 'voice' on PATH isn't ours ({existing}). Remove or rename it, then retry.")
|
| 353 |
+
if Path(old_target).name != "voice.py":
|
| 354 |
+
_fail(f" β '{existing}' isn't a voice install (points at {old_target}). Remove or rename it, then retry.")
|
| 355 |
+
try:
|
| 356 |
+
Path(existing).unlink()
|
| 357 |
+
Path(existing).symlink_to(str(script))
|
| 358 |
+
except (PermissionError, OSError):
|
| 359 |
+
_fail(f" β Couldn't relink {existing}. Try: sudo ln -sf {script} {existing}")
|
| 360 |
+
_ok(f"Voice updated: {existing} now points at {script}. Open a new terminal and run: voice")
|
| 361 |
+
return
|
| 362 |
+
candidates = [Path("/usr/local/bin"), Path.home() / ".local" / "bin", Path("/usr/bin"), Path("/bin")]
|
| 363 |
+
on_path = next((d for d in candidates if d.is_dir() and str(d) in os.environ.get("PATH", "").split(":")), None)
|
| 364 |
+
if on_path is None:
|
| 365 |
+
_fail(" β No writable directory on PATH found. Add one and retry.")
|
| 366 |
+
target = on_path / "voice"
|
| 367 |
+
try:
|
| 368 |
+
target.symlink_to(str(script))
|
| 369 |
+
except FileExistsError:
|
| 370 |
+
target.unlink()
|
| 371 |
+
target.symlink_to(str(script))
|
| 372 |
+
except (PermissionError, OSError):
|
| 373 |
+
_fail(f" β Couldn't create a link in {on_path}. Try: sudo ln -s {script} {target}")
|
| 374 |
+
_ok(f"Voice is ready at {target}. Open a new terminal and run: voice")
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def cmd_doctor(_args):
|
| 378 |
+
import platform
|
| 379 |
+
import shutil
|
| 380 |
+
_say(f" voice {ctx.VERSION} β doctor")
|
| 381 |
+
_say(f" Python {platform.python_version()} ({sys.executable})")
|
| 382 |
+
uv = shutil.which("uv")
|
| 383 |
+
_say(f" uv {'found: ' + uv if uv else 'NOT FOUND β curl -LsSf https://astral.sh/uv/install.sh | sh'}")
|
| 384 |
+
venv = ctx.VOICES_DIR / ".venv"
|
| 385 |
+
if (venv / "bin" / "python").exists():
|
| 386 |
+
_say(f" venv {venv} present")
|
| 387 |
+
else:
|
| 388 |
+
_say(f" venv not created yet (created on first get/cast/pack/delta)")
|
| 389 |
+
try:
|
| 390 |
+
free = shutil.disk_usage(ctx.VOICES_DIR if ctx.VOICES_DIR.exists() else Path.home()).free
|
| 391 |
+
_say(f" disk free {free/1e9:.1f} GB")
|
| 392 |
+
except Exception:
|
| 393 |
+
pass
|
| 394 |
+
tok = _get_hf_token()
|
| 395 |
+
_say(f" HF token {'present' if tok else 'not present'}")
|
| 396 |
+
if ctx.OPERATIONS_LOG.exists():
|
| 397 |
+
try:
|
| 398 |
+
n = sum(1 for _ in open(ctx.OPERATIONS_LOG))
|
| 399 |
+
_say(f" operations logged: {n} ({ctx.OPERATIONS_LOG})")
|
| 400 |
+
except Exception:
|
| 401 |
+
pass
|
| 402 |
+
if ctx.REPORTS_JSONL.exists():
|
| 403 |
+
try:
|
| 404 |
+
n = sum(1 for _ in open(ctx.REPORTS_JSONL))
|
| 405 |
+
pending = len(list(ctx.REPORTS_OUTBOX.glob("*.json"))) if ctx.REPORTS_OUTBOX.exists() else 0
|
| 406 |
+
_say(f" reports: {n} local, {pending} pending in outbox ({ctx.REPORTS_JSONL})")
|
| 407 |
+
if pending > 5000:
|
| 408 |
+
_warn(f" outbox >5000 pending β run: voice report --sync --yes")
|
| 409 |
+
# headroom
|
| 410 |
+
try:
|
| 411 |
+
sz = ctx.REPORTS_JSONL.stat().st_size
|
| 412 |
+
if sz > 50 * 1024 * 1024:
|
| 413 |
+
_warn(f" reports.jsonl {sz/1e6:.1f} MB β consider archiving")
|
| 414 |
+
except Exception:
|
| 415 |
+
pass
|
| 416 |
+
except Exception:
|
| 417 |
+
pass
|
| 418 |
+
else:
|
| 419 |
+
_say(f" reports: none yet β run: voice report")
|
| 420 |
+
try:
|
| 421 |
+
req = urllib.request.Request("https://huggingface.co/api/whoami-v2" if tok else "https://huggingface.co",
|
| 422 |
+
headers=_hf_auth_headers("https://huggingface.co"))
|
| 423 |
+
with urllib.request.urlopen(req, timeout=3) as resp:
|
| 424 |
+
_say(f" connectivity huggingface.co ok ({resp.status})")
|
| 425 |
+
except Exception as e:
|
| 426 |
+
_warn(f"connectivity failed: {e}")
|
| 427 |
+
# check push target
|
| 428 |
+
try:
|
| 429 |
+
_say(f" community reports: {ctx.REPORTS_DATASET_ID} (share prompt on voice report)")
|
| 430 |
+
except Exception:
|
| 431 |
+
pass
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def _print_version():
|
| 435 |
+
import platform
|
| 436 |
+
_say(f" voice {ctx.VERSION}")
|
| 437 |
+
_say(f" Python {platform.python_version()} ({sys.executable})")
|
| 438 |
+
for mod in ("gguf", "numpy", "huggingface_hub", "hf_xet"):
|
| 439 |
+
try:
|
| 440 |
+
m = __import__(mod)
|
| 441 |
+
_say(f" {mod} {getattr(m, '__version__', 'installed')}")
|
| 442 |
+
except Exception:
|
| 443 |
+
_say(f" {mod} not installed")
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _print_completion(shell):
|
| 447 |
+
cmds = ["get", "cast", "delta", "list", "info", "remove", "cleanup", "auth", "report", "path", "doctor"]
|
| 448 |
+
voices = [d.name for d in _iter_voices() if (d / "voice.json").exists()]
|
| 449 |
+
vlist = " ".join(voices)
|
| 450 |
+
clist = " ".join(cmds)
|
| 451 |
+
if shell == "bash":
|
| 452 |
+
print(textwrap.dedent(f"""\
|
| 453 |
+
_voice_completions() {{
|
| 454 |
+
local cur="${{COMP_WORDS[COMP_CWORD]}}"
|
| 455 |
+
local cmds="{clist}"
|
| 456 |
+
if [[ $COMP_CWORD -eq 1 ]]; then
|
| 457 |
+
COMPREPLY=($(compgen -W "$cmds --help --version --completion" -- "$cur"))
|
| 458 |
+
return
|
| 459 |
+
fi
|
| 460 |
+
case "${{COMP_WORDS[1]}}" in
|
| 461 |
+
cast|delta|info|remove) COMPREPLY=($(compgen -W "{vlist}" -- "$cur")) ;;
|
| 462 |
+
list) COMPREPLY=($(compgen -W "base --base" -- "$cur")) ;;
|
| 463 |
+
*) COMPREPLY=() ;;
|
| 464 |
+
esac
|
| 465 |
+
}}
|
| 466 |
+
complete -F _voice_completions voice
|
| 467 |
+
"""))
|
| 468 |
+
elif shell == "zsh":
|
| 469 |
+
print(textwrap.dedent(f"""\
|
| 470 |
+
#compdef voice
|
| 471 |
+
_voice() {{
|
| 472 |
+
local -a cmds; cmds=({clist})
|
| 473 |
+
local -a voices; voices=({vlist})
|
| 474 |
+
_arguments -C "1: :($cmds)" "*::arg:->args"
|
| 475 |
+
case $line[1] in
|
| 476 |
+
cast|delta|info|remove) _describe voices voices ;;
|
| 477 |
+
list) _values "target" base ;;
|
| 478 |
+
esac
|
| 479 |
+
}}
|
| 480 |
+
compdef _voice voice
|
| 481 |
+
"""))
|
| 482 |
+
elif shell == "fish":
|
| 483 |
+
print(textwrap.dedent(f"""\
|
| 484 |
+
complete -c voice -f
|
| 485 |
+
complete -c voice -n "__fish_use_subcommand" -a "{clist}" -d "voice command"
|
| 486 |
+
complete -c voice -n "__fish_seen_subcommand_from cast delta info remove" -a "{vlist}"
|
| 487 |
+
"""))
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def _copy_tree(src, dst):
|
| 491 |
+
"""Refresh-copy a directory (Windows PATH install needs vlib/ beside voice.py)."""
|
| 492 |
+
import shutil as _sh
|
| 493 |
+
if Path(src).is_dir():
|
| 494 |
+
_sh.copytree(str(src), str(dst), dirs_exist_ok=True,
|
| 495 |
+
ignore=_sh.ignore_patterns("__pycache__"))
|
| 496 |
+
return dst
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
def _entry_script():
|
| 500 |
+
"""Absolute path of the CLI entry (voice.py), or None if unknown.
|
| 501 |
+
The split moved commands into vlib/, so __file__ here is the module β
|
| 502 |
+
resolve via __main__ instead. Single seam for every entry-path need."""
|
| 503 |
+
_main = getattr(sys.modules.get("__main__"), "__file__", None)
|
| 504 |
+
if not _main:
|
| 505 |
+
return None
|
| 506 |
+
return os.path.realpath(_main)
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def _ensure_venv(cmd):
|
| 510 |
+
if os.environ.get("VOICE_NO_VENV") in ("1", "true", "True"):
|
| 511 |
+
return
|
| 512 |
+
if cmd not in ("get", "cast", "delta"):
|
| 513 |
+
return
|
| 514 |
+
venv = ctx.VOICES_DIR / ".venv"
|
| 515 |
+
venv_py = venv / "bin" / "python"
|
| 516 |
+
try:
|
| 517 |
+
if venv_py.exists() and (sys.prefix == str(venv) or Path(sys.executable).resolve().parent.parent == venv.resolve()):
|
| 518 |
+
return
|
| 519 |
+
except Exception:
|
| 520 |
+
pass
|
| 521 |
+
import shutil
|
| 522 |
+
if shutil.which("uv") is None:
|
| 523 |
+
_fail(" β uv not found. Install it, then retry:\n curl -LsSf https://astral.sh/uv/install.sh | sh")
|
| 524 |
+
if venv_py.exists():
|
| 525 |
+
try:
|
| 526 |
+
_main = _entry_script()
|
| 527 |
+
if not _main:
|
| 528 |
+
_warn(" venv re-exec skipped: entry script unknown, continuing in-process.")
|
| 529 |
+
return
|
| 530 |
+
os.execv(str(venv_py), [str(venv_py), _main, *sys.argv[1:]])
|
| 531 |
+
except Exception as e:
|
| 532 |
+
if ctx.VERBOSE:
|
| 533 |
+
_warn(f"could not re-exec into venv: {e}")
|
| 534 |
+
return
|
| 535 |
+
_step(f"Creating a virtual environment at {venv}β¦")
|
| 536 |
+
import subprocess
|
| 537 |
+
try:
|
| 538 |
+
subprocess.run(["uv", "venv", str(venv), "--python", sys.executable], capture_output=True, check=True)
|
| 539 |
+
subprocess.run(["uv", "pip", "install", "--quiet", "--python", str(venv_py),
|
| 540 |
+
"gguf", "numpy", "huggingface_hub[hf_xet]", "hf_xet"], capture_output=True, check=True)
|
| 541 |
+
except Exception as e:
|
| 542 |
+
_fail(f" β Failed to create venv: {e}")
|
| 543 |
+
_main = _entry_script()
|
| 544 |
+
if not _main:
|
| 545 |
+
_warn(" venv re-exec skipped: entry script unknown, continuing in-process.")
|
| 546 |
+
return
|
| 547 |
+
try:
|
| 548 |
+
os.execv(str(venv_py), [str(venv_py), _main, *sys.argv[1:]])
|
| 549 |
+
except Exception as e:
|
| 550 |
+
_fail(f" β Could not re-exec into venv: {e}")
|
vlib/cmds_voices.py
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Voice commands: get, cast, delta, add."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import argparse
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import urllib.error
|
| 9 |
+
import urllib.request
|
| 10 |
+
from vlib import ctx
|
| 11 |
+
from vlib.ui import _fail, _noninteractive, _note, _now_iso, _ok, _say, _step
|
| 12 |
+
from vlib.net import _cleanup_tmp, _safe_id
|
| 13 |
+
from vlib.tensors import _is_delta_safetensors, _is_gguf_file, read_safetensors, write_safetensors
|
| 14 |
+
from vlib.sources import _NAME_RULES, _base_paths, _family, _hf_license, _nested, _public_source, _sanitize_voice_name, _slugify_voice_name, open_source
|
| 15 |
+
from vlib.fetch import _fetch_pack, _fetch_tensor, _resolve_output_name, _source_arch, _write_fetched, _write_voicepack_json
|
| 16 |
+
from vlib.registry import _assert_not_symlink, _check_overwrite, _out_path, _read_meta, _registry_folder, _registry_name_exists, _registry_resolve, _resolve_name, _write_voice, log_operation
|
| 17 |
+
from vlib.cache import _ensure_base_tensors
|
| 18 |
+
from vlib.compute import _cast_core, _cast_pack, _load_tensor, _load_voice, _parse_exclude_list, _parse_tensor_list, _resolve_one_tensor, _tensor_src_meta
|
| 19 |
+
from vlib.delta import _choose_voice, _compute_delta, _resolve_base
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def cmd_get(args):
|
| 23 |
+
source_id = args.source.strip().rstrip("/")
|
| 24 |
+
if not source_id:
|
| 25 |
+
_fail(" β Enter a model name β for example: voice get google/gemma-3-1b")
|
| 26 |
+
_step(f"Finding the voice in {source_id}β¦")
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
source = open_source(source_id)
|
| 30 |
+
names = source.names()
|
| 31 |
+
if not names:
|
| 32 |
+
_fail(f" β No tensors found in '{source_id}'.")
|
| 33 |
+
arch, config = _source_arch(source)
|
| 34 |
+
requested = _parse_tensor_list(args)
|
| 35 |
+
want_multi = bool(getattr(args, "multi", False)) or len(requested) > 1
|
| 36 |
+
if not requested and not want_multi:
|
| 37 |
+
tname = _resolve_output_name(source, config, names)
|
| 38 |
+
if tname is None:
|
| 39 |
+
_fail(f' β "{source_id}" doesn\'t expose an output head. Try `voice get {source_id} <tensor>` with a specific tensor.')
|
| 40 |
+
requested = [tname]
|
| 41 |
+
elif not requested and want_multi:
|
| 42 |
+
_fail(" β --multi needs tensor names (pass them as positionals).")
|
| 43 |
+
resolved = [_resolve_one_tensor(r, names) for r in requested]
|
| 44 |
+
# Dedupe preserve order.
|
| 45 |
+
seen = set()
|
| 46 |
+
uniq = []
|
| 47 |
+
for r in resolved:
|
| 48 |
+
if r not in seen:
|
| 49 |
+
seen.add(r)
|
| 50 |
+
uniq.append(r)
|
| 51 |
+
resolved = uniq
|
| 52 |
+
except urllib.error.HTTPError as e:
|
| 53 |
+
_fail(f" β Model '{source_id}' was not found or is not accessible ({e.code}).")
|
| 54 |
+
except Exception as e:
|
| 55 |
+
if ctx.VERBOSE:
|
| 56 |
+
import traceback
|
| 57 |
+
_say(traceback.format_exc())
|
| 58 |
+
_fail(f" β Could not reach the model. Check your connection ({type(e).__name__}).")
|
| 59 |
+
|
| 60 |
+
if want_multi or len(resolved) > 1:
|
| 61 |
+
# Multi-pack -> voicepack.safetensors + voicepack.json (file output, not the registry).
|
| 62 |
+
if getattr(args, "name", None):
|
| 63 |
+
_fail(" β --name is for registry voices; packs save to files. Drop --name or the extra tensors.")
|
| 64 |
+
out = Path(getattr(args, "out", None) or (Path.cwd() / "voicepack.safetensors"))
|
| 65 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 66 |
+
_check_overwrite(str(out), args)
|
| 67 |
+
pack = _fetch_pack(source, source_id, resolved)
|
| 68 |
+
_step(f"Packing {len(pack)} tensors β {out.name}β¦")
|
| 69 |
+
write_safetensors(pack, str(out))
|
| 70 |
+
_write_voicepack_json(out, source_id, pack)
|
| 71 |
+
log_operation({"op": "tensor_get", "source": source_id, "format": source.kind,
|
| 72 |
+
"tensors": list(pack.keys()), "output": str(out), "status": "ok"})
|
| 73 |
+
_ok(f"Done β {out} ({len(pack)} tensors)")
|
| 74 |
+
return
|
| 75 |
+
|
| 76 |
+
# Single tensor from here.
|
| 77 |
+
tname = resolved[0]
|
| 78 |
+
ref = source.ref(tname)
|
| 79 |
+
if getattr(args, "out", None):
|
| 80 |
+
# Standalone file; --name has no meaning here.
|
| 81 |
+
if getattr(args, "name", None):
|
| 82 |
+
_fail(" β --name is for registry voices; --out writes a standalone file. Drop one.")
|
| 83 |
+
out = Path(args.out)
|
| 84 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
_check_overwrite(str(out), args)
|
| 86 |
+
_step(f"Extracting {tname} ({ref.dtype} {tuple(ref.shape)})β¦")
|
| 87 |
+
tmp_dir = ctx.VOICES_DIR / ".parts" / _safe_id(source_id, tname)
|
| 88 |
+
try:
|
| 89 |
+
fetched = _fetch_tensor(source, tname, tmp_dir)
|
| 90 |
+
except Exception as e:
|
| 91 |
+
_fail(f" β Could not extract tensor: {e}")
|
| 92 |
+
actual_out = _write_fetched(fetched, tname, out)
|
| 93 |
+
_cleanup_tmp(tmp_dir)
|
| 94 |
+
log_operation({"op": "tensor_get", "source": source_id, "format": source.kind, "tensor": tname,
|
| 95 |
+
"dtype": fetched[3], "shape": list(fetched[4]), "output": str(actual_out), "status": "ok"})
|
| 96 |
+
_ok(f"Done β {actual_out}")
|
| 97 |
+
return
|
| 98 |
+
|
| 99 |
+
if ctx.VERBOSE:
|
| 100 |
+
_note(f"{_family(arch)} Β· {tname} Β· {ref.dtype} Β· {ref.shape}")
|
| 101 |
+
|
| 102 |
+
# name the voice
|
| 103 |
+
default_name = source_id.split("/")[-1].split("@")[0].split(":")[0].replace(".gguf", "").replace(".safetensors", "")
|
| 104 |
+
name = _resolve_name(args, default_name)
|
| 105 |
+
folder = _registry_folder(name)
|
| 106 |
+
if _registry_name_exists(name) and folder.exists() and ((folder / "voice.safetensors").exists() or (folder / "voice.gguf").exists()):
|
| 107 |
+
_check_overwrite(str(folder), args)
|
| 108 |
+
|
| 109 |
+
_step(f"Downloading {tname}β¦")
|
| 110 |
+
tmp_dir = ctx.VOICES_DIR / ".parts" / _safe_id(source_id, tname)
|
| 111 |
+
try:
|
| 112 |
+
fetched = _fetch_tensor(source, tname, tmp_dir)
|
| 113 |
+
except urllib.error.HTTPError as e:
|
| 114 |
+
_fail(f" β Could not download the tensor ({e.code}). Check your connection.")
|
| 115 |
+
except Exception as e:
|
| 116 |
+
if ctx.VERBOSE:
|
| 117 |
+
import traceback
|
| 118 |
+
_say(traceback.format_exc())
|
| 119 |
+
_fail(f" β Could not download the tensor: {e}")
|
| 120 |
+
|
| 121 |
+
vocab = _nested(config, "vocab_size") if config else None
|
| 122 |
+
hidden = _nested(config, "hidden_size", "n_embd") if config else None
|
| 123 |
+
license_text = None if getattr(args, "quiet", False) else (_hf_license(getattr(source, "repo", None)) if getattr(source, "repo", None) else None)
|
| 124 |
+
|
| 125 |
+
meta = {
|
| 126 |
+
"name": name,
|
| 127 |
+
"source": _public_source(source_id),
|
| 128 |
+
"source_format": source.kind,
|
| 129 |
+
"tensor_name": tname,
|
| 130 |
+
"tensors": [{"name": tname, "shape": list(fetched[4]), "dtype": fetched[3]}],
|
| 131 |
+
"vocab_size": vocab,
|
| 132 |
+
"hidden_dim": hidden,
|
| 133 |
+
"dtype": fetched[3],
|
| 134 |
+
"shape": list(fetched[4]),
|
| 135 |
+
"architecture": arch,
|
| 136 |
+
"family": _family(arch),
|
| 137 |
+
"license": license_text,
|
| 138 |
+
"downloaded_at": _now_iso(),
|
| 139 |
+
}
|
| 140 |
+
_write_voice(folder, tname, fetched, meta)
|
| 141 |
+
_cleanup_tmp(tmp_dir)
|
| 142 |
+
|
| 143 |
+
log_operation({"op": "get", "source": source_id, "format": source.kind, "tensor": tname,
|
| 144 |
+
"dtype": fetched[3], "shape": list(fetched[4]), "output": str(folder), "status": "ok"})
|
| 145 |
+
_ok("Done. Your new voice is ready.")
|
| 146 |
+
_say(f" Saved to: {folder}")
|
| 147 |
+
_say(f" Use it: voice cast <your-model.gguf> {name}")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def cmd_cast(args):
|
| 151 |
+
target = args.target
|
| 152 |
+
if not os.path.exists(target):
|
| 153 |
+
_fail(f' β "{target}" couldn\'t be found.')
|
| 154 |
+
if os.path.isdir(target):
|
| 155 |
+
_fail(f' β "{target}" is a folder, not a model file.')
|
| 156 |
+
|
| 157 |
+
voice_arg = args.voice
|
| 158 |
+
voice_path, voice_meta = _registry_resolve(voice_arg)
|
| 159 |
+
if voice_path is None or not voice_path.exists():
|
| 160 |
+
_fail(f" β Voice '{voice_arg}' not found. Use 'voice list' or pass a .safetensors/.gguf path.")
|
| 161 |
+
|
| 162 |
+
target_format = "gguf" if _is_gguf_file(target) else "safetensors"
|
| 163 |
+
out_path = _out_path(args, target, voice_arg)
|
| 164 |
+
_check_overwrite(out_path, args)
|
| 165 |
+
# Voicepack in the registry (or a pack path): cast every tensor, not just the first.
|
| 166 |
+
try:
|
| 167 |
+
_probe = open_source(str(voice_path))
|
| 168 |
+
_pack_names = [n for n in _probe.names() if n != "delta.voice.marker"]
|
| 169 |
+
except Exception:
|
| 170 |
+
_pack_names = []
|
| 171 |
+
requested = _parse_tensor_list(args)
|
| 172 |
+
excludes = _parse_exclude_list(args)
|
| 173 |
+
if requested:
|
| 174 |
+
# Tensor filter: resolve each positional (exact or single substring), then cast the selection.
|
| 175 |
+
resolved = [_resolve_one_tensor(r, _pack_names) for r in requested]
|
| 176 |
+
seen = set()
|
| 177 |
+
selected = []
|
| 178 |
+
for r in resolved:
|
| 179 |
+
if r not in seen:
|
| 180 |
+
seen.add(r)
|
| 181 |
+
selected.append(r)
|
| 182 |
+
resolved_ex = set()
|
| 183 |
+
for e in excludes:
|
| 184 |
+
if e in _pack_names:
|
| 185 |
+
resolved_ex.add(e)
|
| 186 |
+
elif len([n for n in _pack_names if e in n]) == 1:
|
| 187 |
+
resolved_ex.add([n for n in _pack_names if e in n][0])
|
| 188 |
+
selected = [n for n in selected if n not in resolved_ex]
|
| 189 |
+
if not selected:
|
| 190 |
+
_fail(" β --exclude removes all selected tensors. Nothing to cast.")
|
| 191 |
+
if len(selected) > 1:
|
| 192 |
+
_cast_pack(target, str(voice_path), out_path, args, op="cast",
|
| 193 |
+
excludes=[n for n in _pack_names if n not in selected], meta=voice_meta)
|
| 194 |
+
return
|
| 195 |
+
f32, ref, raw, qtype = _load_tensor(str(voice_path), selected[0])
|
| 196 |
+
src_meta = voice_meta if voice_meta is not None else _tensor_src_meta(str(voice_path))
|
| 197 |
+
_cast_core(target, target_format, out_path, f32, ref, src_meta, args, op="cast", vraw=raw, vqtype=qtype)
|
| 198 |
+
return
|
| 199 |
+
if len(_pack_names) > 1:
|
| 200 |
+
_cast_pack(target, str(voice_path), out_path, args, op="cast", excludes=excludes, meta=voice_meta)
|
| 201 |
+
return
|
| 202 |
+
|
| 203 |
+
# auto-detect delta file via marker even without voice.json (e.g. shipped style.safetensors)
|
| 204 |
+
# do this after _check_overwrite so prompt and Detected don't interleave
|
| 205 |
+
if (voice_meta is None or voice_meta.get("type") != "delta") and voice_path.is_file():
|
| 206 |
+
try:
|
| 207 |
+
if _is_delta_safetensors(str(voice_path)):
|
| 208 |
+
voice_meta = {"type": "delta", "source": str(voice_path)}
|
| 209 |
+
_note("Detected delta voice β fusing style (delta + head)β¦")
|
| 210 |
+
except Exception:
|
| 211 |
+
pass
|
| 212 |
+
|
| 213 |
+
vf32, vref, vraw, vqtype = _load_voice(voice_path)
|
| 214 |
+
_cast_core(target, target_format, out_path, vf32, vref, voice_meta, args, op="cast", vraw=vraw, vqtype=vqtype)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def cmd_delta(args):
|
| 220 |
+
import numpy as np
|
| 221 |
+
|
| 222 |
+
voicename = getattr(args, "voicename", None)
|
| 223 |
+
if not voicename or not str(voicename).strip():
|
| 224 |
+
voicename = _choose_voice(args)
|
| 225 |
+
voicename = voicename.strip()
|
| 226 |
+
# If voicename is an existing file/dir path, treat as direct voice file (e.g. /path/to/voice.safetensors)
|
| 227 |
+
_voice_file_mode = False
|
| 228 |
+
_voice_file_path = None
|
| 229 |
+
_candidate = Path(voicename).expanduser()
|
| 230 |
+
if _candidate.exists():
|
| 231 |
+
if _candidate.is_file():
|
| 232 |
+
_voice_file_mode = True
|
| 233 |
+
_voice_file_path = _candidate.resolve()
|
| 234 |
+
# derive default name: if file is inside ~/.voices/, use the voice folder name (e.g. ~/.voices/styletune-v2/voice.safetensors -> styletune-v2)
|
| 235 |
+
# otherwise use file stem. This lets delta-voice be named delta-<voice-name> when source is from registry
|
| 236 |
+
try:
|
| 237 |
+
is_inside_voices = str(_candidate.resolve()).startswith(str(ctx.VOICES_DIR.resolve()) + os.sep)
|
| 238 |
+
except Exception:
|
| 239 |
+
is_inside_voices = False
|
| 240 |
+
if is_inside_voices:
|
| 241 |
+
# e.g. ~/.voices/styletune-v2/voice.safetensors -> styletune-v2
|
| 242 |
+
raw = _candidate.parent.name
|
| 243 |
+
# if parent is base/ org dir, fallback to stem
|
| 244 |
+
if raw in ("base", ".voices") or raw.startswith("."):
|
| 245 |
+
raw = _candidate.stem
|
| 246 |
+
else:
|
| 247 |
+
raw = _candidate.stem
|
| 248 |
+
# prompt for delta name, default is raw (or voice.json name if available)
|
| 249 |
+
try:
|
| 250 |
+
meta_tmp = _read_meta(_candidate.parent / "voice.json")
|
| 251 |
+
default_raw = (meta_tmp or {}).get("name") or raw
|
| 252 |
+
except Exception:
|
| 253 |
+
default_raw = raw
|
| 254 |
+
default_raw = str(default_raw).strip() or raw
|
| 255 |
+
# ask user for name, default from meta or file
|
| 256 |
+
if _noninteractive(args):
|
| 257 |
+
sanitized = _sanitize_voice_name(default_raw) or _slugify_voice_name(default_raw)
|
| 258 |
+
else:
|
| 259 |
+
try:
|
| 260 |
+
prompt = f" Name the delta or press enter to use [{default_raw}]: "
|
| 261 |
+
entered = input(prompt).strip()
|
| 262 |
+
chosen = entered or default_raw
|
| 263 |
+
sanitized = _sanitize_voice_name(chosen) or _slugify_voice_name(chosen)
|
| 264 |
+
except (EOFError, KeyboardInterrupt):
|
| 265 |
+
_say(" β Cancelled.")
|
| 266 |
+
sys.exit(0)
|
| 267 |
+
if sanitized is None:
|
| 268 |
+
sanitized = "voice"
|
| 269 |
+
# keep original path for _compute_delta, but use sanitized name for delta naming / messages
|
| 270 |
+
voice_path = _voice_file_path
|
| 271 |
+
voice_meta = _read_meta(_candidate.parent / "voice.json")
|
| 272 |
+
# use sanitized as voicename for delta naming downstream (without delta- prefix, added later)
|
| 273 |
+
voicename = sanitized
|
| 274 |
+
elif _candidate.is_dir():
|
| 275 |
+
voice_path, voice_meta = _registry_resolve(str(_candidate))
|
| 276 |
+
if voice_path is None or not voice_path.exists():
|
| 277 |
+
_fail(f" β Voice '{voicename}' not found. Try voice list")
|
| 278 |
+
# use directory name as voicename if it looks like a voice folder
|
| 279 |
+
dir_name = _candidate.name
|
| 280 |
+
if _sanitize_voice_name(dir_name.replace("delta-", "", 1) if dir_name.lower().startswith("delta-") else dir_name) is not None:
|
| 281 |
+
voicename = dir_name
|
| 282 |
+
# fall through to existing meta handling
|
| 283 |
+
else:
|
| 284 |
+
# exists but not file/dir? treat as registry name
|
| 285 |
+
if "/" in voicename or "\\" in voicename or "." in voicename or _sanitize_voice_name(voicename.replace("delta-", "", 1) if voicename.lower().startswith("delta-") else voicename) is None:
|
| 286 |
+
_fail(" β Invalid voice name. Use letters, numbers, hyphens, underscores.")
|
| 287 |
+
voice_path, voice_meta = _registry_resolve(voicename)
|
| 288 |
+
if voice_path is None or not voice_path.exists():
|
| 289 |
+
_fail(f" β Voice '{voicename}' not found. Try voice list")
|
| 290 |
+
else:
|
| 291 |
+
if "/" in voicename or "\\" in voicename or "." in voicename or _sanitize_voice_name(voicename.replace("delta-", "", 1) if voicename.lower().startswith("delta-") else voicename) is None:
|
| 292 |
+
_fail(" β Invalid voice name. Use letters, numbers, hyphens, underscores.")
|
| 293 |
+
voice_path, voice_meta = _registry_resolve(voicename)
|
| 294 |
+
if voice_path is None or not voice_path.exists():
|
| 295 |
+
_fail(f" β Voice '{voicename}' not found. Try voice list")
|
| 296 |
+
# handle file-mode delta naming already set; for registry mode voice_path/voice_meta already resolved
|
| 297 |
+
if _voice_file_mode:
|
| 298 |
+
# voice_path already set, ensure it exists
|
| 299 |
+
if not voice_path.exists():
|
| 300 |
+
_fail(f" β Voice file '{_voice_file_path}' not found.")
|
| 301 |
+
else:
|
| 302 |
+
# registry mode already validated above
|
| 303 |
+
pass
|
| 304 |
+
|
| 305 |
+
source_hf = (voice_meta or {}).get("source_hf_model") or (voice_meta or {}).get("source") or "β"
|
| 306 |
+
_say(f" '{voicename}' is {source_hf}")
|
| 307 |
+
|
| 308 |
+
# --base may be an HF repo (org/model) or a local file/dir path (e.g. /path/to/lm_head.safetensors)
|
| 309 |
+
base_arg = getattr(args, "base", None)
|
| 310 |
+
base_is_local = False
|
| 311 |
+
base_file = None
|
| 312 |
+
base_json = None
|
| 313 |
+
base_id = None
|
| 314 |
+
if base_arg and Path(base_arg).expanduser().exists():
|
| 315 |
+
p = Path(base_arg).expanduser().resolve()
|
| 316 |
+
if p.is_file():
|
| 317 |
+
base_file = p
|
| 318 |
+
base_id = str(p)
|
| 319 |
+
base_is_local = True
|
| 320 |
+
elif p.is_dir():
|
| 321 |
+
# directory containing model.safetensors or any safetensors
|
| 322 |
+
cand = p / "model.safetensors"
|
| 323 |
+
if cand.exists():
|
| 324 |
+
base_file = cand.resolve()
|
| 325 |
+
base_is_local = True
|
| 326 |
+
base_id = str(base_file)
|
| 327 |
+
else:
|
| 328 |
+
safes = sorted(p.glob("*.safetensors"))
|
| 329 |
+
if safes:
|
| 330 |
+
base_file = safes[0].resolve()
|
| 331 |
+
base_is_local = True
|
| 332 |
+
base_id = str(base_file)
|
| 333 |
+
else:
|
| 334 |
+
_fail(f" β No .safetensors found in {p}")
|
| 335 |
+
# if local, no base_json needed; will be used directly
|
| 336 |
+
if not base_is_local:
|
| 337 |
+
base_id = _resolve_base(args, source_hf)
|
| 338 |
+
# _resolve_base may return a local path if user typed a path at the prompt
|
| 339 |
+
_maybe_local = Path(str(base_id)).expanduser()
|
| 340 |
+
if _maybe_local.exists():
|
| 341 |
+
p = _maybe_local.resolve()
|
| 342 |
+
if p.is_file():
|
| 343 |
+
base_file = p
|
| 344 |
+
base_is_local = True
|
| 345 |
+
base_json = None
|
| 346 |
+
elif p.is_dir():
|
| 347 |
+
cand = p / "model.safetensors"
|
| 348 |
+
if cand.exists():
|
| 349 |
+
base_file = cand.resolve()
|
| 350 |
+
base_is_local = True
|
| 351 |
+
base_id = str(base_file)
|
| 352 |
+
base_json = None
|
| 353 |
+
else:
|
| 354 |
+
safes = sorted(p.glob("*.safetensors"))
|
| 355 |
+
if safes:
|
| 356 |
+
base_file = safes[0].resolve()
|
| 357 |
+
base_is_local = True
|
| 358 |
+
base_id = str(base_file)
|
| 359 |
+
base_json = None
|
| 360 |
+
else:
|
| 361 |
+
_fail(f" β No .safetensors found in {p}")
|
| 362 |
+
if not base_is_local:
|
| 363 |
+
tmp = _base_paths(base_id)
|
| 364 |
+
if tmp is None:
|
| 365 |
+
_fail(f" β Invalid base '{base_id}'")
|
| 366 |
+
base_file, base_json = tmp
|
| 367 |
+
try:
|
| 368 |
+
_vsrc = open_source(str(voice_path))
|
| 369 |
+
_need = [n for n in _vsrc.names() if n != "delta.voice.marker"]
|
| 370 |
+
except Exception:
|
| 371 |
+
_need = []
|
| 372 |
+
if not _need:
|
| 373 |
+
_fail(" β No tensors found for delta.")
|
| 374 |
+
base_file = _ensure_base_tensors(base_id, _need, args)
|
| 375 |
+
|
| 376 |
+
delta_name = f"delta-{voicename.lower()}"
|
| 377 |
+
delta_folder = ctx.VOICES_DIR / delta_name
|
| 378 |
+
if delta_folder.exists() and ((delta_folder / "voice.safetensors").exists() or (delta_folder / "voice.gguf").exists()):
|
| 379 |
+
_check_overwrite(str(delta_folder), args)
|
| 380 |
+
|
| 381 |
+
_step("Crafting your styleβ¦")
|
| 382 |
+
_compute_delta(voice_path, base_file, delta_folder, delta_name, args)
|
| 383 |
+
|
| 384 |
+
meta = {
|
| 385 |
+
"name": delta_name, "type": "delta", "source": voicename,
|
| 386 |
+
"source_hf_model": _public_source(source_hf),
|
| 387 |
+
"base": _public_source(base_id),
|
| 388 |
+
"dtype": "F16",
|
| 389 |
+
"downloaded_at": _now_iso(),
|
| 390 |
+
}
|
| 391 |
+
# enrich with shape/dtype from the delta file (all tensors, not just the first)
|
| 392 |
+
try:
|
| 393 |
+
hdr, tens = read_safetensors(str(delta_folder / "voice.safetensors"))
|
| 394 |
+
keys = [k for k in tens.keys() if k != "delta.voice.marker"]
|
| 395 |
+
meta["tensors"] = [{"name": k, "shape": list(tens[k]["shape"]), "dtype": tens[k]["dtype"],
|
| 396 |
+
"bytes": tens[k]["data_offsets"][1] - tens[k]["data_offsets"][0]} for k in keys]
|
| 397 |
+
if len(keys) == 1:
|
| 398 |
+
# backward compat: single-tensor voices keep flat fields
|
| 399 |
+
meta["tensor_name"] = keys[0]
|
| 400 |
+
meta["shape"] = list(tens[keys[0]]["shape"])
|
| 401 |
+
meta["dtype"] = tens[keys[0]]["dtype"]
|
| 402 |
+
meta["bytes"] = tens[keys[0]]["data_offsets"][1]
|
| 403 |
+
except Exception:
|
| 404 |
+
pass
|
| 405 |
+
jtmp = delta_folder / "voice.json.tmp"
|
| 406 |
+
jtmp.write_text(json.dumps(meta, indent=2) + "\n")
|
| 407 |
+
jtmp.replace(delta_folder / "voice.json")
|
| 408 |
+
|
| 409 |
+
log_operation({"op": "delta", "voice": voicename, "base": base_id, "tensor": meta.get("tensor_name"),
|
| 410 |
+
"dtype": meta.get("dtype"), "shape": meta.get("shape"), "output": str(delta_folder), "status": "ok"})
|
| 411 |
+
_ok(f"Done β {delta_folder}")
|
| 412 |
+
_say(f" Use it: voice cast <your-model.gguf> {delta_name}")
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def cmd_add(args):
|
| 416 |
+
import shutil
|
| 417 |
+
st_path = Path(getattr(args, "safetensors", "") or "").expanduser()
|
| 418 |
+
js_path = Path(getattr(args, "json", "") or "").expanduser()
|
| 419 |
+
if not st_path.is_file():
|
| 420 |
+
_fail(f' β "{st_path}" couldn\'t be found. Need voice.safetensors')
|
| 421 |
+
if not js_path.is_file():
|
| 422 |
+
_fail(f' β "{js_path}" couldn\'t be found. Need voice.json β both are required')
|
| 423 |
+
# validate safetensors
|
| 424 |
+
try:
|
| 425 |
+
hdr, tens = read_safetensors(str(st_path))
|
| 426 |
+
except Exception as e:
|
| 427 |
+
_fail(f" β {st_path} is not a valid .safetensors file: {e}")
|
| 428 |
+
if not tens:
|
| 429 |
+
_fail(f" β No tensors found in {st_path}")
|
| 430 |
+
# validate json
|
| 431 |
+
try:
|
| 432 |
+
meta = json.loads(js_path.read_text(encoding="utf-8"))
|
| 433 |
+
except Exception as e:
|
| 434 |
+
_fail(f" β {js_path} is not valid JSON: {e}")
|
| 435 |
+
if not isinstance(meta, dict):
|
| 436 |
+
_fail(f" β {js_path} must contain a JSON object")
|
| 437 |
+
# determine name: --name > json name > prompt
|
| 438 |
+
default_name = (meta.get("name") or st_path.stem or "voice").strip()
|
| 439 |
+
# sanitize default for display
|
| 440 |
+
name_arg = getattr(args, "name", None)
|
| 441 |
+
if name_arg and str(name_arg).strip():
|
| 442 |
+
name = str(name_arg).strip()
|
| 443 |
+
elif _noninteractive(args):
|
| 444 |
+
name = default_name
|
| 445 |
+
else:
|
| 446 |
+
try:
|
| 447 |
+
# show default from json
|
| 448 |
+
prompt = f" Name the voice or press enter to use [{default_name}]: "
|
| 449 |
+
entered = input(prompt).strip()
|
| 450 |
+
name = entered or default_name
|
| 451 |
+
except (EOFError, KeyboardInterrupt):
|
| 452 |
+
_say(" β Cancelled.")
|
| 453 |
+
sys.exit(0)
|
| 454 |
+
if _sanitize_voice_name(name) is None:
|
| 455 |
+
_fail(f" β Invalid name '{name}'. {_NAME_RULES}")
|
| 456 |
+
folder = _registry_folder(name)
|
| 457 |
+
if _registry_name_exists(name) and folder.exists() and ((folder / "voice.safetensors").exists() or (folder / "voice.gguf").exists()):
|
| 458 |
+
_check_overwrite(str(folder), args)
|
| 459 |
+
# remove old to allow replace
|
| 460 |
+
try:
|
| 461 |
+
shutil.rmtree(folder)
|
| 462 |
+
except Exception:
|
| 463 |
+
pass
|
| 464 |
+
# also handle case where folder exists as partial
|
| 465 |
+
_assert_not_symlink(folder)
|
| 466 |
+
folder.mkdir(parents=True, exist_ok=True)
|
| 467 |
+
try:
|
| 468 |
+
os.chmod(folder, 0o700)
|
| 469 |
+
except Exception:
|
| 470 |
+
pass
|
| 471 |
+
_assert_not_symlink(folder)
|
| 472 |
+
# copy files atomically with 0600 and fsync β tensor FIRST, then metadata,
|
| 473 |
+
# so a crash never leaves a valid voice.json pointing at a partial artifact.
|
| 474 |
+
dest_st = folder / "voice.safetensors"
|
| 475 |
+
dest_js = folder / "voice.json"
|
| 476 |
+
# ensure meta name matches sanitized folder name (lowercased)
|
| 477 |
+
meta = dict(meta)
|
| 478 |
+
meta["name"] = name.strip().lower() if _sanitize_voice_name(name) else name
|
| 479 |
+
# copy safetensors atomically: use tmp + fsync + replace, 0600
|
| 480 |
+
tmp_st = dest_st.with_suffix(".tmp")
|
| 481 |
+
# stream copy with fsync
|
| 482 |
+
with open(st_path, "rb") as src, open(tmp_st, "wb") as dst:
|
| 483 |
+
# ensure 0600
|
| 484 |
+
try:
|
| 485 |
+
os.chmod(tmp_st, 0o600)
|
| 486 |
+
except Exception:
|
| 487 |
+
pass
|
| 488 |
+
while True:
|
| 489 |
+
b = src.read(8 << 20)
|
| 490 |
+
if not b:
|
| 491 |
+
break
|
| 492 |
+
dst.write(b)
|
| 493 |
+
dst.flush()
|
| 494 |
+
try:
|
| 495 |
+
os.fsync(dst.fileno())
|
| 496 |
+
except Exception:
|
| 497 |
+
pass
|
| 498 |
+
try:
|
| 499 |
+
os.chmod(tmp_st, 0o600)
|
| 500 |
+
except Exception:
|
| 501 |
+
pass
|
| 502 |
+
tmp_st.replace(dest_st)
|
| 503 |
+
try:
|
| 504 |
+
os.chmod(dest_st, 0o600)
|
| 505 |
+
except Exception:
|
| 506 |
+
pass
|
| 507 |
+
# verify tensor before writing metadata
|
| 508 |
+
try:
|
| 509 |
+
rhdr, rtens = read_safetensors(str(dest_st))
|
| 510 |
+
if not rtens:
|
| 511 |
+
try:
|
| 512 |
+
dest_st.unlink(missing_ok=True)
|
| 513 |
+
except Exception:
|
| 514 |
+
pass
|
| 515 |
+
_fail(f" β No tensors found in {st_path} β nothing added.")
|
| 516 |
+
except SystemExit:
|
| 517 |
+
raise
|
| 518 |
+
except Exception as e:
|
| 519 |
+
try:
|
| 520 |
+
dest_st.unlink(missing_ok=True)
|
| 521 |
+
except Exception:
|
| 522 |
+
pass
|
| 523 |
+
_fail(f" β Validation failed for {st_path}: {e} β nothing added.")
|
| 524 |
+
# write json atomically 0600 (only after the tensor is known good)
|
| 525 |
+
tmp_js = dest_js.with_suffix(".json.tmp")
|
| 526 |
+
fd = os.open(str(tmp_js), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
| 527 |
+
try:
|
| 528 |
+
os.write(fd, (json.dumps(meta, indent=2) + "\n").encode("utf-8"))
|
| 529 |
+
try:
|
| 530 |
+
os.fsync(fd)
|
| 531 |
+
except Exception:
|
| 532 |
+
pass
|
| 533 |
+
finally:
|
| 534 |
+
os.close(fd)
|
| 535 |
+
try:
|
| 536 |
+
os.chmod(tmp_js, 0o600)
|
| 537 |
+
except Exception:
|
| 538 |
+
pass
|
| 539 |
+
tmp_js.replace(dest_js)
|
| 540 |
+
try:
|
| 541 |
+
dirfd = os.open(str(folder), os.O_DIRECTORY)
|
| 542 |
+
try:
|
| 543 |
+
os.fsync(dirfd)
|
| 544 |
+
finally:
|
| 545 |
+
os.close(dirfd)
|
| 546 |
+
except Exception:
|
| 547 |
+
pass
|
| 548 |
+
log_operation({"op": "add", "source_st": str(st_path), "source_json": str(js_path), "output": str(folder), "status": "ok"})
|
| 549 |
+
_ok(f"Added voice '{name}' β {folder}")
|
| 550 |
+
_say(f" File: {dest_st} ({dest_st.stat().st_size/1e6:.1f} MB)")
|
| 551 |
+
_say(f" Use it: voice cast <your-model.gguf> {name}")
|
vlib/compute.py
ADDED
|
@@ -0,0 +1,1186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cast math: orient, fuse, quantize, pack writes."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import os
|
| 5 |
+
import struct
|
| 6 |
+
import sys
|
| 7 |
+
import time
|
| 8 |
+
from vlib import ctx
|
| 9 |
+
from vlib.ui import _confirm, _fail, _noninteractive, _note, _ok, _progress_pref, _say, _step, _transform, _warn
|
| 10 |
+
from vlib.tensors import _gguf_raw_to_f32, _is_delta_safetensors, _is_ggml_quant, _is_gguf_file, _safetensors_write_repl, decode_to_f32, encode_from_f32, read_safetensors, rewrite_safetensors_tensor
|
| 11 |
+
from vlib.sources import OUTPUT_ALIASES, _nested, detect_output_tensor, largest_2d, open_source
|
| 12 |
+
from vlib.registry import log_operation
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _load_voice(voice_path):
|
| 16 |
+
"""Load a voice artifact. Returns (f32_array_or_None, ref, raw_bytes_or_None, ggml_type_or_None).
|
| 17 |
+
Quantized GGUF artifacts carry raw blocks; safetensors artifacts decode to f32."""
|
| 18 |
+
src = open_source(str(voice_path))
|
| 19 |
+
names = [n for n in src.names() if n != "delta.voice.marker"]
|
| 20 |
+
if not names:
|
| 21 |
+
_fail(f" β No tensors found in {voice_path}.")
|
| 22 |
+
tname = names[0] if len(names) == 1 else "output.weight" if "output.weight" in names else names[0]
|
| 23 |
+
ref = src.ref(tname)
|
| 24 |
+
if src.kind == "gguf" and _is_ggml_quant(ref.dtype):
|
| 25 |
+
return None, ref, src.read_raw(tname), ref.dtype
|
| 26 |
+
return src.read_f32(tname), ref, None, None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _orient(arr, shape, target_shape):
|
| 30 |
+
import numpy as np
|
| 31 |
+
if tuple(shape) == tuple(target_shape):
|
| 32 |
+
return arr
|
| 33 |
+
if tuple(shape) == tuple(target_shape[::-1]):
|
| 34 |
+
if ctx.VERBOSE:
|
| 35 |
+
_note("Transposing to match the target layoutβ¦")
|
| 36 |
+
return np.ascontiguousarray(arr.T)
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _cast_st_prepare(target, tensors, vf32, vref, meta, args, vraw=None, vqtype=None, strict_target=False, target_name=None):
|
| 41 |
+
"""Per-tensor compute for a safetensors target. Returns (tname, data, tdtype, tshape).
|
| 42 |
+
target_name (from _resolve_pack_targets) overrides lookup; None keeps the
|
| 43 |
+
original single-tensor behavior byte-for-byte."""
|
| 44 |
+
import numpy as np
|
| 45 |
+
if vraw is not None:
|
| 46 |
+
# quant artifact -> safetensors target: dequant is the only requant path
|
| 47 |
+
from gguf.constants import GGMLQuantizationType as Q
|
| 48 |
+
from gguf.quants import quant_shape_to_byte_shape
|
| 49 |
+
vf32 = _gguf_raw_to_f32(vraw, Q[vqtype], tuple(reversed(tuple(int(x) for x in vref.shape))))
|
| 50 |
+
names = list(tensors.keys())
|
| 51 |
+
if target_name is not None:
|
| 52 |
+
tname = target_name if target_name in names else None
|
| 53 |
+
if tname is None:
|
| 54 |
+
_fail(f" β Resolved target '{target_name}' for '{vref.name}' not in {target}.")
|
| 55 |
+
else:
|
| 56 |
+
tname = vref.name if vref.name in names else None
|
| 57 |
+
if tname is None and strict_target:
|
| 58 |
+
_fail(f" β Tensor '{vref.name}' not found in target {target}. Voicepack requires exact match (not mapping to another tensor).")
|
| 59 |
+
if tname is None:
|
| 60 |
+
tname = detect_output_tensor(names, None) or largest_2d(names, {n: tensors[n]["shape"] for n in names})
|
| 61 |
+
if tname is None:
|
| 62 |
+
_fail(" β Could not find a matching tensor in the target.")
|
| 63 |
+
tshape = tuple(tensors[tname]["shape"])
|
| 64 |
+
tdtype = tensors[tname]["dtype"]
|
| 65 |
+
# --quant override for safetensors target: only F32/F16/BF16/keep/target make sense (no GGUF quants).
|
| 66 |
+
_qo = _parse_quant_override(getattr(args, "quant", None) or "keep")
|
| 67 |
+
if _qo is None:
|
| 68 |
+
_fail(f" β Invalid --quant '{getattr(args, 'quant', None)}' for safetensors target. Use keep|target|f32|f16|bf16.")
|
| 69 |
+
if _qo not in ("keep", "target", "f32", "f16", "bf16"):
|
| 70 |
+
_fail(f" β --quant {_qo} needs GGUF target (safetensors has no quant {_qo}). Use keep|target|f32|f16|bf16.")
|
| 71 |
+
if _qo in ("f32", "f16", "bf16"):
|
| 72 |
+
_want = {"f32": "F32", "f16": "F16", "bf16": "BF16"}[_qo]
|
| 73 |
+
if _want != tdtype.upper():
|
| 74 |
+
_warn(f" Re-encoding target {tdtype} β {_want} for --quant {_qo} (extra conversion).")
|
| 75 |
+
tdtype = _want
|
| 76 |
+
# Show quant/target by default for tensor commands (advanced) and verbose for voice.
|
| 77 |
+
try:
|
| 78 |
+
_svq = vqtype if vraw is not None else vref.dtype
|
| 79 |
+
except Exception:
|
| 80 |
+
_svq = "?"
|
| 81 |
+
if ctx.VERBOSE:
|
| 82 |
+
_note(f"Voice '{vref.name}' is {_svq} {tuple(vref.shape)} β target '{tname}' is {tdtype} {tshape}")
|
| 83 |
+
if vraw is not None and ctx.VERBOSE:
|
| 84 |
+
_note(f"Dequanting {vqtype} once to F32 for safetensors target (unavoidable, safetensors has no quant).")
|
| 85 |
+
oriented = _orient(vf32, vref.shape, tshape)
|
| 86 |
+
if oriented is None:
|
| 87 |
+
_fail(f" β Voice shape {vref.shape} doesn't fit target tensor {tname} {tshape}.")
|
| 88 |
+
if (meta or {}).get("type") == "delta":
|
| 89 |
+
_step(f"Fusing style into {tname} (delta + head β {tdtype}, kept)β¦")
|
| 90 |
+
with open(target, "rb") as f:
|
| 91 |
+
_hs = struct.unpack("<Q", f.read(8))[0]
|
| 92 |
+
info = tensors[tname]
|
| 93 |
+
with open(target, "rb") as f:
|
| 94 |
+
f.seek(8 + _hs + info["data_offsets"][0])
|
| 95 |
+
head_f32 = decode_to_f32(f.read(info["data_offsets"][1] - info["data_offsets"][0]), tdtype, tshape)
|
| 96 |
+
oriented = np.ascontiguousarray(head_f32 + oriented)
|
| 97 |
+
data = encode_from_f32(oriented, tdtype)
|
| 98 |
+
return tname, data, tdtype, tshape
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _cast_core(target, target_format, out_path, vf32, vref, meta, args, op="cast", vraw=None, vqtype=None, strict_target=False):
|
| 102 |
+
if getattr(args, "speak", False) and target_format != "gguf":
|
| 103 |
+
_fail(" β --speak needs a .gguf target.")
|
| 104 |
+
if target_format == "gguf":
|
| 105 |
+
_cast_gguf(target, out_path, vf32, vref, meta, args, op, vraw=vraw, vqtype=vqtype, strict_target=strict_target)
|
| 106 |
+
return
|
| 107 |
+
# safetensors target
|
| 108 |
+
header, tensors = read_safetensors(target)
|
| 109 |
+
t0s = time.time()
|
| 110 |
+
tname, data, tdtype, tshape = _cast_st_prepare(target, tensors, vf32, vref, meta, args,
|
| 111 |
+
vraw=vraw, vqtype=vqtype, strict_target=strict_target)
|
| 112 |
+
_step(f"Replacing {tname} ({tdtype} {tshape})β¦")
|
| 113 |
+
rewrite_safetensors_tensor(target, out_path, tname, data, tdtype, tshape)
|
| 114 |
+
file_size = os.path.getsize(out_path)
|
| 115 |
+
try:
|
| 116 |
+
_note(f"Kept {len(data)/1e6:.1f} MB β {tdtype} @ {(len(data)/1e6)/max(time.time()-t0s,1e-6):.0f} MB/s")
|
| 117 |
+
except Exception:
|
| 118 |
+
pass
|
| 119 |
+
log_operation({"op": op, "target": target, "format": "safetensors", "tensor": tname,
|
| 120 |
+
"dtype": tdtype, "shape": list(tshape), "output": out_path, "status": "ok"})
|
| 121 |
+
_ok(f"Done β {out_path}")
|
| 122 |
+
_say(f" Tensor replaced at {tdtype} ({file_size/1e6:.1f} MB).")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _is_head_name(name):
|
| 126 |
+
if not name:
|
| 127 |
+
return False
|
| 128 |
+
try:
|
| 129 |
+
if name in OUTPUT_ALIASES or name in ("token_embd.weight",):
|
| 130 |
+
return True
|
| 131 |
+
for a in OUTPUT_ALIASES:
|
| 132 |
+
if name.endswith(a):
|
| 133 |
+
return True
|
| 134 |
+
except Exception:
|
| 135 |
+
pass
|
| 136 |
+
return False
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _parse_quant_override(s):
|
| 140 |
+
# Returns normalized key: keep|target or Q-type name upper (e.g. Q4_0). Trivial: accept all GGML quants.
|
| 141 |
+
if s is None:
|
| 142 |
+
return "keep"
|
| 143 |
+
k = str(s).strip().lower().replace("-", "_")
|
| 144 |
+
if k in ("keep", "target"):
|
| 145 |
+
return k
|
| 146 |
+
# Aliases for float family.
|
| 147 |
+
if k in ("fp32",):
|
| 148 |
+
k = "f32"
|
| 149 |
+
if k in ("fp16",):
|
| 150 |
+
k = "f16"
|
| 151 |
+
# Try any GGMLQuantizationType name.
|
| 152 |
+
try:
|
| 153 |
+
from gguf.constants import GGMLQuantizationType as _Qp
|
| 154 |
+
# Case-insensitive match against all Q names.
|
| 155 |
+
for _q in _Qp:
|
| 156 |
+
if _q.name.lower() == k:
|
| 157 |
+
return k
|
| 158 |
+
except Exception:
|
| 159 |
+
pass
|
| 160 |
+
# Fallback known list (when gguf import fails).
|
| 161 |
+
_known = {"f32", "f16", "bf16", "q8_0", "q8_1", "q8_k", "q1_0", "q4_0", "q4_1", "q5_0", "q5_1",
|
| 162 |
+
"q2_k", "q3_k", "q4_k", "q5_k", "q6_k", "iq1_s", "iq1_m", "iq2_xxs", "iq2_xs", "iq2_s",
|
| 163 |
+
"iq3_xxs", "iq3_s", "iq4_nl", "iq4_xs", "tq1_0", "tq2_0", "mxfp4", "nvfp4"}
|
| 164 |
+
if k in _known:
|
| 165 |
+
return k
|
| 166 |
+
return None
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _quant_from_key(key):
|
| 170 |
+
# key normalized via _parse_quant_override (lower). Returns Q type or None for keep/target.
|
| 171 |
+
if key in ("keep", "target", None):
|
| 172 |
+
return None
|
| 173 |
+
try:
|
| 174 |
+
from gguf.constants import GGMLQuantizationType as _Qq
|
| 175 |
+
for _q in _Qq:
|
| 176 |
+
if _q.name.lower() == key:
|
| 177 |
+
return _q
|
| 178 |
+
# Upper direct (e.g. Q4_0).
|
| 179 |
+
return _Qq[key.upper()]
|
| 180 |
+
except Exception:
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _qerr(e):
|
| 185 |
+
"""Readable lib error: gguf often raises bare NotImplementedError ('')."""
|
| 186 |
+
s = str(e).strip()
|
| 187 |
+
return f"{type(e).__name__}: {s}" if s else type(e).__name__
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
_NEAREST_CACHE = {}
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _nearest_encodable(target_q):
|
| 194 |
+
"""Nearest gguf-encodable quant to target_q by bits/param (ties β larger =
|
| 195 |
+
quality-preserving). The lib decodes everything but encodes only a subset
|
| 196 |
+
(no K/IQ quants); this keeps fused tensors near the original size instead
|
| 197 |
+
of ballooning to F16. Support probed once per process (version-proof)."""
|
| 198 |
+
import numpy as np
|
| 199 |
+
from gguf.constants import GGMLQuantizationType as Q
|
| 200 |
+
from gguf.constants import GGML_QUANT_SIZES as S
|
| 201 |
+
from gguf.quants import quantize
|
| 202 |
+
key = getattr(target_q, "name", str(target_q))
|
| 203 |
+
if key in _NEAREST_CACHE:
|
| 204 |
+
return _NEAREST_CACHE[key]
|
| 205 |
+
tbpp = S[target_q][1] * 8 / S[target_q][0]
|
| 206 |
+
probe = (np.arange(64 * 64, dtype=np.float32).reshape(64, 64) - 2048) / 64
|
| 207 |
+
scored = []
|
| 208 |
+
for name in ("Q8_0", "Q5_1", "Q5_0", "Q4_1", "Q4_0", "MXFP4", "F16"):
|
| 209 |
+
cand = getattr(Q, name, None)
|
| 210 |
+
if cand is None or cand == target_q:
|
| 211 |
+
continue
|
| 212 |
+
try:
|
| 213 |
+
quantize(probe, cand)
|
| 214 |
+
except Exception:
|
| 215 |
+
continue
|
| 216 |
+
cbpp = S[cand][1] * 8 / S[cand][0]
|
| 217 |
+
scored.append((abs(cbpp - tbpp), -cbpp, cand))
|
| 218 |
+
best = min(scored)[2] if scored else None
|
| 219 |
+
_NEAREST_CACHE[key] = best
|
| 220 |
+
return best
|
| 221 |
+
|
| 222 |
+
def _quantize_to_Q(fused, req_q):
|
| 223 |
+
# Quantize F32 fused array to any Q (F32/F16/BF16 via astype, else gguf quantize).
|
| 224 |
+
# Returns (q_head, out_qtype). Raises on unsupported (e.g. Q1_0/Q8_1/Q8_K NotImplemented).
|
| 225 |
+
import numpy as np
|
| 226 |
+
from gguf.constants import GGMLQuantizationType as Q
|
| 227 |
+
from gguf.quants import quantize
|
| 228 |
+
if req_q in (Q.F32,):
|
| 229 |
+
return fused.astype(np.float32), Q.F32
|
| 230 |
+
if req_q in (Q.F16,):
|
| 231 |
+
return fused.astype(np.float16), Q.F16
|
| 232 |
+
try:
|
| 233 |
+
_b = getattr(Q, "BF16", None)
|
| 234 |
+
except Exception:
|
| 235 |
+
_b = None
|
| 236 |
+
if _b is not None and req_q == _b:
|
| 237 |
+
_u32 = fused.astype(np.float32).view(np.uint32)
|
| 238 |
+
return (((_u32 + 0x7FFF + ((_u32 >> 16) & 1)) >> 16).astype(np.uint16), _b)
|
| 239 |
+
# All other GGML quants (Q4_0/Q4_K/Q6_K/IQ*/TQ*/MXFP4/...) via single quantize call (trivial).
|
| 240 |
+
return quantize(fused, req_q), req_q
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def _fuse_delta(target_tensor, delta_f32, strict=False):
|
| 244 |
+
"""Dequant the target head once to F32 and fuse the delta (head + delta).
|
| 245 |
+
Single seam for the three dequantβfuse sites in _gguf_fuse_one.
|
| 246 |
+
strict (safetensors delta path only): fail clean on shape mismatch, exactly as before."""
|
| 247 |
+
import numpy as np
|
| 248 |
+
from gguf.constants import GGMLQuantizationType as Q
|
| 249 |
+
if target_tensor.tensor_type in (Q.F32, Q.F16):
|
| 250 |
+
try:
|
| 251 |
+
head_f32 = target_tensor.data.astype(np.float32)
|
| 252 |
+
except Exception:
|
| 253 |
+
head_f32 = np.asarray(target_tensor.data, dtype=np.float32)
|
| 254 |
+
else:
|
| 255 |
+
from gguf.quants import dequantize as _deq
|
| 256 |
+
head_f32 = _deq(target_tensor.data, target_tensor.tensor_type)
|
| 257 |
+
if strict and head_f32.shape != delta_f32.shape:
|
| 258 |
+
sys.stdout.write("\n")
|
| 259 |
+
_fail(f" β Style shape {delta_f32.shape} doesn't match model head {head_f32.shape}.")
|
| 260 |
+
return np.ascontiguousarray(head_f32 + delta_f32)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _gguf_fuse_one(target, target_tensor, tname, logical, vf32, vref, meta, args, op, vraw=None, vqtype=None):
|
| 264 |
+
"""Fuse one voice tensor into its target GGUF tensor form. Returns (q_head, out_qtype)."""
|
| 265 |
+
import numpy as np
|
| 266 |
+
from gguf.constants import GGMLQuantizationType as Q
|
| 267 |
+
from gguf.quants import quantize
|
| 268 |
+
# Philosophy: preserve quality, avoid dequant->quant cycles.
|
| 269 |
+
# - GGUF quant voice (vraw) is kept at its obtained quant (never upscale Q2->Q6, no inventing data).
|
| 270 |
+
# - Safetensors high-fidelity lm_head (voice) is quantized once to Q8_0.
|
| 271 |
+
# - Generic tensors keep target quant (F32 norms stay F32, never forced Q8_0).
|
| 272 |
+
# - All GGML quants supported (trivial passthrough + quantize where implemented).
|
| 273 |
+
_quant_override = _parse_quant_override(getattr(args, "quant", None) or "keep")
|
| 274 |
+
if _quant_override is None:
|
| 275 |
+
_fail(f" β Invalid --quant '{getattr(args, 'quant', None)}'. Use keep|target|<any quant like q8_0,q4_0,q4_k,q6_k,iq4_nl,f16,f32,bf16>.")
|
| 276 |
+
_is_head = _is_head_name(vref.name) or _is_head_name(tname)
|
| 277 |
+
_is_delta = (meta or {}).get("type") == "delta"
|
| 278 |
+
# Show quant/target by default for advanced tensor commands (and verbose for voice)
|
| 279 |
+
try:
|
| 280 |
+
_tq = target_tensor.tensor_type.name
|
| 281 |
+
except Exception:
|
| 282 |
+
_tq = "?"
|
| 283 |
+
try:
|
| 284 |
+
_vq = vqtype if vraw is not None else vref.dtype
|
| 285 |
+
except Exception:
|
| 286 |
+
_vq = "?"
|
| 287 |
+
if ctx.VERBOSE:
|
| 288 |
+
_note(f"Voice '{vref.name}' is {_vq} {tuple(vref.shape)} β target '{tname}' is {_tq} {logical}")
|
| 289 |
+
if vraw is not None:
|
| 290 |
+
# GGUF quant voice: keep obtained quant (no upscale, no extra cycle).
|
| 291 |
+
try:
|
| 292 |
+
_vqtype = Q[vqtype]
|
| 293 |
+
except Exception:
|
| 294 |
+
_fail(f" β Unknown voice quant '{vqtype}'.")
|
| 295 |
+
if tuple(vref.shape) != logical:
|
| 296 |
+
_fail(f" β Voice shape {vref.shape} doesn't fit target tensor {tname} {logical}.")
|
| 297 |
+
if _quant_override in ("keep",):
|
| 298 |
+
# Transplant quant type as-is (may differ from target type β intentional, preserves quality).
|
| 299 |
+
if _vqtype.name != target_tensor.tensor_type.name and ctx.VERBOSE:
|
| 300 |
+
_note(f"Keeping voice quant {_vqtype.name} (target was {target_tensor.tensor_type.name}, not upscaling).")
|
| 301 |
+
_step(f"Casting onto {os.path.basename(target)} ({_vqtype.name}, kept)β¦")
|
| 302 |
+
from gguf.quants import quant_shape_to_byte_shape as _q2b_keep
|
| 303 |
+
_keep_shape = _q2b_keep(tuple(int(x) for x in vref.shape), _vqtype)
|
| 304 |
+
_need = 1
|
| 305 |
+
for _d in _keep_shape:
|
| 306 |
+
_need *= _d
|
| 307 |
+
_raw = np.frombuffer(vraw, dtype=np.uint8)
|
| 308 |
+
if _raw.size < _need:
|
| 309 |
+
_fail(f" β Short voice data for transplant ({_raw.size}/{_need} bytes).")
|
| 310 |
+
q_head, out_qtype = _raw[:_need].reshape(_keep_shape), _vqtype
|
| 311 |
+
elif _quant_override == "target":
|
| 312 |
+
# Explicit: dequant once then quant to target (extra cycle, warn).
|
| 313 |
+
_warn(f"Re-quanting voice {_vqtype.name} β target {target_tensor.tensor_type.name} (extra dequantβquant cycle).")
|
| 314 |
+
from gguf.quants import dequantize as _deq2
|
| 315 |
+
from gguf.quants import quant_shape_to_byte_shape as _q2b
|
| 316 |
+
# vref.shape is logical already checked
|
| 317 |
+
_rshape = tuple(vref.shape)
|
| 318 |
+
try:
|
| 319 |
+
_f32v = _deq2(np.frombuffer(vraw, dtype=np.uint8).reshape(_q2b(_rshape, _vqtype)), _vqtype)
|
| 320 |
+
except Exception as e:
|
| 321 |
+
_fail(f" β Could not dequant voice for --quant target: {e}")
|
| 322 |
+
if _is_delta:
|
| 323 |
+
_f32v = _fuse_delta(target_tensor, _f32v)
|
| 324 |
+
t_quant0 = time.time()
|
| 325 |
+
_tq2 = target_tensor.tensor_type
|
| 326 |
+
if _tq2 in (Q.F32,):
|
| 327 |
+
q_head, out_qtype = _f32v.astype(np.float32), Q.F32
|
| 328 |
+
elif _tq2 in (Q.F16,):
|
| 329 |
+
q_head, out_qtype = _f32v.astype(np.float16), Q.F16
|
| 330 |
+
else:
|
| 331 |
+
try:
|
| 332 |
+
_bf = getattr(Q, "BF16", None)
|
| 333 |
+
except Exception:
|
| 334 |
+
_bf = None
|
| 335 |
+
if _bf is not None and _tq2 == _bf:
|
| 336 |
+
# BF16 target: gguf lib has no BF16 encoder here; keep F32 to avoid loss
|
| 337 |
+
q_head, out_qtype = _f32v.astype(np.float32), Q.F32
|
| 338 |
+
_warn(" BF16 target: keeping F32 to avoid loss (use --quant f16/bf16 explicitly if needed).")
|
| 339 |
+
else:
|
| 340 |
+
try:
|
| 341 |
+
q_head, out_qtype = quantize(_f32v, _tq2), _tq2
|
| 342 |
+
except Exception as e:
|
| 343 |
+
_fail(f" β Could not quant to target {_tq2.name}: {e}")
|
| 344 |
+
_note(f"Re-quantized {_f32v.nbytes/1e6:.1f} MB β {_tq2.name} @ {(_f32v.nbytes/1e6)/max(time.time()-t_quant0,1e-6):.0f} MB/s")
|
| 345 |
+
else:
|
| 346 |
+
# Explicit q8_0/f16/f32/bf16 for quant voice: dequant once then quant.
|
| 347 |
+
from gguf.quants import dequantize as _deq2
|
| 348 |
+
from gguf.quants import quant_shape_to_byte_shape as _q2b
|
| 349 |
+
_rshape = tuple(vref.shape)
|
| 350 |
+
try:
|
| 351 |
+
_f32v = _deq2(np.frombuffer(vraw, dtype=np.uint8).reshape(_q2b(_rshape, _vqtype)), _vqtype)
|
| 352 |
+
except Exception as e:
|
| 353 |
+
_fail(f" β Could not dequant voice for --quant {_quant_override}: {e}")
|
| 354 |
+
if _is_delta:
|
| 355 |
+
_f32v = _fuse_delta(target_tensor, _f32v)
|
| 356 |
+
_qn = _quant_from_key(_quant_override)
|
| 357 |
+
if _qn is None:
|
| 358 |
+
_fail(f" β Invalid --quant '{_quant_override}'.")
|
| 359 |
+
t_quant0 = time.time()
|
| 360 |
+
try:
|
| 361 |
+
q_head, out_qtype = _quantize_to_Q(_f32v, _qn)
|
| 362 |
+
except NotImplementedError as e:
|
| 363 |
+
_fail(f" β Quant {getattr(_qn, 'name', _quant_override)} not implemented by gguf lib ({e}). Keeping original is recommended.")
|
| 364 |
+
except Exception as e:
|
| 365 |
+
_fail(f" β Could not quant to {_quant_override}: {e}")
|
| 366 |
+
_note(f"Quantized {_f32v.nbytes/1e6:.1f} MB β {out_qtype.name} @ {(_f32v.nbytes/1e6)/max(time.time()-t_quant0,1e-6):.0f} MB/s")
|
| 367 |
+
else:
|
| 368 |
+
oriented = _orient(vf32, vref.shape, logical)
|
| 369 |
+
if oriented is None:
|
| 370 |
+
_fail(f" β Voice shape {vref.shape} doesn't fit target tensor {tname} {logical}.")
|
| 371 |
+
t_fuse0 = time.time()
|
| 372 |
+
# Decide out quant: Q8_0 once only for safetensors high-fidelity heads; else keep target.
|
| 373 |
+
# vref.dtype here is F32/F16/BF16 (high-fidelity, since vraw is None).
|
| 374 |
+
_target_q = target_tensor.tensor_type
|
| 375 |
+
# Explicit override wins (any quant trivially supported via _quantize_to_Q).
|
| 376 |
+
_req = None
|
| 377 |
+
if _quant_override != "keep":
|
| 378 |
+
if _quant_override == "target":
|
| 379 |
+
_req = _target_q
|
| 380 |
+
else:
|
| 381 |
+
_req = _quant_from_key(_quant_override)
|
| 382 |
+
if _req is None:
|
| 383 |
+
_fail(f" β Invalid --quant '{_quant_override}'.")
|
| 384 |
+
t_fuse0 = time.time()
|
| 385 |
+
if _is_delta:
|
| 386 |
+
# Fuse first (F32 math preserves quality), decide quant after.
|
| 387 |
+
fused = _fuse_delta(target_tensor, oriented, strict=True)
|
| 388 |
+
if _req is not None:
|
| 389 |
+
# Explicit --quant for delta (all quants trivial via helper).
|
| 390 |
+
_step(f"Fusing style into {tname} (delta + head β {_req.name})β¦")
|
| 391 |
+
t_quant0 = time.time()
|
| 392 |
+
try:
|
| 393 |
+
q_head, out_qtype = _quantize_to_Q(fused, _req)
|
| 394 |
+
except NotImplementedError as e:
|
| 395 |
+
_fail(f" β Quant {_req.name} not implemented by gguf lib ({e}). Keeping original is recommended.")
|
| 396 |
+
except Exception as e:
|
| 397 |
+
_fail(f" β Could not quant delta to {_req.name}: {e}")
|
| 398 |
+
_note(f"Quantized {fused.nbytes/1e6:.1f} MB β {_req.name} @ {(fused.nbytes/1e6)/max(time.time()-t_quant0,1e-6):.0f} MB/s")
|
| 399 |
+
elif _is_head:
|
| 400 |
+
# Head delta from high-fidelity: Q8_0 once (old behavior, with %32 fallback).
|
| 401 |
+
if fused.shape[-1] % 32 != 0:
|
| 402 |
+
_warn(f"Head last dim {fused.shape[-1]} not Γ32 β Q8_0 needs %32, keeping F16 (larger, compatible).")
|
| 403 |
+
_step(f"Fusing style into {tname} (delta + head β F16 fallback)β¦")
|
| 404 |
+
q_head, out_qtype = fused.astype(np.float16), Q.F16
|
| 405 |
+
else:
|
| 406 |
+
_step(f"Fusing style into {tname} (delta + head β Q8_0)β¦")
|
| 407 |
+
t_quant0 = time.time()
|
| 408 |
+
try:
|
| 409 |
+
q_head, out_qtype = quantize(fused, Q.Q8_0), Q.Q8_0
|
| 410 |
+
except Exception as e:
|
| 411 |
+
_fail(f" β Could not quant delta to Q8_0: {e}")
|
| 412 |
+
_note(f"Quantized {fused.nbytes/1e6:.1f} MB β Q8_0 @ {(fused.nbytes/1e6)/max(time.time()-t_quant0,1e-6):.0f} MB/s")
|
| 413 |
+
else:
|
| 414 |
+
# Generic delta: keep target quant (never force Q8_0). Never upscale implicitly.
|
| 415 |
+
_step(f"Fusing style into {tname} (delta + head β {_target_q.name}, kept)β¦")
|
| 416 |
+
t_quant0 = time.time()
|
| 417 |
+
if _target_q in (Q.F32,):
|
| 418 |
+
q_head, out_qtype = fused.astype(np.float32), Q.F32
|
| 419 |
+
elif _target_q in (Q.F16,):
|
| 420 |
+
q_head, out_qtype = fused.astype(np.float16), Q.F16
|
| 421 |
+
else:
|
| 422 |
+
try:
|
| 423 |
+
_b = getattr(Q, "BF16", None)
|
| 424 |
+
except Exception:
|
| 425 |
+
_b = None
|
| 426 |
+
if _b is not None and _target_q == _b:
|
| 427 |
+
q_head, out_qtype = fused.astype(np.float32), Q.F32
|
| 428 |
+
_warn(" BF16 target: keeping F32 to avoid loss (use --quant bf16 explicitly if needed).")
|
| 429 |
+
out_qtype = Q.F32
|
| 430 |
+
else:
|
| 431 |
+
try:
|
| 432 |
+
q_head, out_qtype = quantize(fused, _target_q), _target_q
|
| 433 |
+
except Exception as e:
|
| 434 |
+
_near = _nearest_encodable(_target_q)
|
| 435 |
+
if _near is not None:
|
| 436 |
+
_warn(f" {_target_q.name} not encodable ({_qerr(e)}); keeping {_near.name} (nearest size) instead of F16.")
|
| 437 |
+
q_head, out_qtype = _quantize_to_Q(fused, _near)
|
| 438 |
+
else:
|
| 439 |
+
_warn(f" Could not quant to {_target_q.name} ({_qerr(e)}), keeping F16.")
|
| 440 |
+
q_head, out_qtype = fused.astype(np.float16), Q.F16
|
| 441 |
+
try:
|
| 442 |
+
_note(f"Fused {fused.nbytes/1e6:.1f} MB β {out_qtype.name} @ {(fused.nbytes/1e6)/max(time.time()-t_quant0,1e-6):.0f} MB/s")
|
| 443 |
+
except Exception:
|
| 444 |
+
pass
|
| 445 |
+
else:
|
| 446 |
+
# Not delta: voice replace (all quants trivial).
|
| 447 |
+
if _req is not None:
|
| 448 |
+
_step(f"Casting onto {os.path.basename(target)} ({_req.name})β¦")
|
| 449 |
+
fused = oriented
|
| 450 |
+
t_quant0 = time.time()
|
| 451 |
+
try:
|
| 452 |
+
q_head, out_qtype = _quantize_to_Q(fused, _req)
|
| 453 |
+
except NotImplementedError as e:
|
| 454 |
+
_fail(f" β Quant {_req.name} not implemented by gguf lib ({e}).")
|
| 455 |
+
except Exception as e:
|
| 456 |
+
_fail(f" β Could not quant to {_req.name}: {e}")
|
| 457 |
+
_note(f"Quantized {fused.nbytes/1e6:.1f} MB β {_req.name} @ {(fused.nbytes/1e6)/max(time.time()-t_quant0,1e-6):.0f} MB/s")
|
| 458 |
+
elif _is_head:
|
| 459 |
+
# lm_head high-fidelity: quant once to Q8_0 (preserve quality, Q4 saves little).
|
| 460 |
+
if oriented.shape[-1] % 32 != 0:
|
| 461 |
+
_warn(f"Head last dim {oriented.shape[-1]} not Γ32 β Q8_0 needs %32, keeping F16 (larger, compatible).")
|
| 462 |
+
_step(f"Casting onto {os.path.basename(target)} (F16 fallback)β¦")
|
| 463 |
+
fused = oriented
|
| 464 |
+
q_head, out_qtype = fused.astype(np.float16), Q.F16
|
| 465 |
+
else:
|
| 466 |
+
_step(f"Casting onto {os.path.basename(target)} (Q8_0)β¦")
|
| 467 |
+
fused = oriented
|
| 468 |
+
t_quant0 = time.time()
|
| 469 |
+
try:
|
| 470 |
+
q_head, out_qtype = quantize(fused, Q.Q8_0), Q.Q8_0
|
| 471 |
+
except Exception as e:
|
| 472 |
+
sys.stdout.write("\n")
|
| 473 |
+
if ctx.VERBOSE:
|
| 474 |
+
_say(f" Details: {e}")
|
| 475 |
+
_fail(" β Could not prepare the voiced model. Try again.")
|
| 476 |
+
_note(f"Quantized {fused.nbytes/1e6:.1f} MB β Q8_0 @ {(fused.nbytes/1e6)/max(time.time()-t_quant0,1e-6):.0f} MB/s")
|
| 477 |
+
else:
|
| 478 |
+
# Generic tensor: keep target quant (fix crash: F32 norms stay F32, never forced Q8_0).
|
| 479 |
+
_tq = _target_q
|
| 480 |
+
try:
|
| 481 |
+
_tqn = _tq.name
|
| 482 |
+
except Exception:
|
| 483 |
+
_tqn = str(_tq)
|
| 484 |
+
try:
|
| 485 |
+
_vqn = vref.dtype
|
| 486 |
+
except Exception:
|
| 487 |
+
_vqn = "?"
|
| 488 |
+
# Mismatch prompt only for safetensors non-head high-fidelity differing from target.
|
| 489 |
+
_need_prompt = (_vqn.upper() in ("F32", "F16", "BF16", "FP32", "FP16") and _tqn.upper() != _vqn.upper())
|
| 490 |
+
_custom_done = False
|
| 491 |
+
if _need_prompt and _quant_override == "keep" and not _noninteractive(args):
|
| 492 |
+
try:
|
| 493 |
+
_say(f" Target '{tname}' is {_tqn}, fetched is {_vqn}.")
|
| 494 |
+
_ans = input(f" Press enter to quantize to {_tqn} or enter custom quant (any like Q8_0/Q4_0/Q6_K/F16/F32/BF16): ").strip()
|
| 495 |
+
except (EOFError, KeyboardInterrupt):
|
| 496 |
+
_say(" β Cancelled.")
|
| 497 |
+
sys.exit(0)
|
| 498 |
+
_ans_n = _ans.strip().upper().replace("-", "_")
|
| 499 |
+
if _ans.strip() == "" or _ans_n == _tqn.upper():
|
| 500 |
+
pass
|
| 501 |
+
else:
|
| 502 |
+
_ck = _parse_quant_override(_ans)
|
| 503 |
+
_cq = _quant_from_key(_ck) if _ck not in (None, "keep", "target") else None
|
| 504 |
+
if _ck is None or (_cq is None and _ck not in ("keep", "target")):
|
| 505 |
+
_warn(f" Unknown quant '{_ans}', using target {_tqn}.")
|
| 506 |
+
elif _ck == "target" or _ans_n == _tqn.upper():
|
| 507 |
+
pass
|
| 508 |
+
else:
|
| 509 |
+
_warn(f"Forcing {_ans} for generic tensor (extra quant cycle).")
|
| 510 |
+
fused = oriented
|
| 511 |
+
t_quant0 = time.time()
|
| 512 |
+
try:
|
| 513 |
+
q_head, out_qtype = _quantize_to_Q(fused, _cq)
|
| 514 |
+
except NotImplementedError as e:
|
| 515 |
+
_fail(f" β Quant {_cq.name} not implemented ({e}).")
|
| 516 |
+
except Exception as e:
|
| 517 |
+
_fail(f" β Could not quant to {_cq.name}: {e}")
|
| 518 |
+
_note(f"Quantized {fused.nbytes/1e6:.1f} MB β {_cq.name} @ {(fused.nbytes/1e6)/max(time.time()-t_quant0,1e-6):.0f} MB/s")
|
| 519 |
+
_note(f"Fusing done in {time.time()-t_fuse0:.1f}s")
|
| 520 |
+
_custom_done = True
|
| 521 |
+
# keep-target path (default, silent when match, Enter=target when mismatch)
|
| 522 |
+
if not _custom_done:
|
| 523 |
+
_step(f"Casting '{vref.name}' ({_vqn}) onto '{tname}' ({_tqn}, kept)β¦")
|
| 524 |
+
fused = oriented
|
| 525 |
+
t_quant0 = time.time()
|
| 526 |
+
if _tq in (Q.F32,):
|
| 527 |
+
q_head, out_qtype = fused.astype(np.float32), Q.F32
|
| 528 |
+
elif _tq in (Q.F16,):
|
| 529 |
+
q_head, out_qtype = fused.astype(np.float16), Q.F16
|
| 530 |
+
else:
|
| 531 |
+
try:
|
| 532 |
+
_b = getattr(Q, "BF16", None)
|
| 533 |
+
except Exception:
|
| 534 |
+
_b = None
|
| 535 |
+
if _b is not None and _tq == _b:
|
| 536 |
+
q_head, out_qtype = fused.astype(np.float32), Q.F32
|
| 537 |
+
_warn(" BF16 target: keeping F32 to avoid loss (use --quant bf16 explicitly if needed).")
|
| 538 |
+
out_qtype = Q.F32
|
| 539 |
+
else:
|
| 540 |
+
try:
|
| 541 |
+
q_head, out_qtype = quantize(fused, _tq), _tq
|
| 542 |
+
except Exception as e:
|
| 543 |
+
_near = _nearest_encodable(_tq)
|
| 544 |
+
if _near is not None:
|
| 545 |
+
_warn(f" {_tqn} not encodable ({_qerr(e)}); keeping {_near.name} (nearest size) instead of F16.")
|
| 546 |
+
q_head, out_qtype = _quantize_to_Q(fused, _near)
|
| 547 |
+
else:
|
| 548 |
+
_warn(f" Could not quant to {_tqn} ({_qerr(e)}), keeping F16.")
|
| 549 |
+
q_head, out_qtype = fused.astype(np.float16), Q.F16
|
| 550 |
+
try:
|
| 551 |
+
_note(f"Kept {fused.nbytes/1e6:.1f} MB β {out_qtype.name} @ {(fused.nbytes/1e6)/max(time.time()-t_quant0,1e-6):.0f} MB/s")
|
| 552 |
+
except Exception:
|
| 553 |
+
pass
|
| 554 |
+
try:
|
| 555 |
+
t_fuse0
|
| 556 |
+
except Exception:
|
| 557 |
+
t_fuse0 = time.time()
|
| 558 |
+
_note(f"Fusing done in {time.time()-t_fuse0:.1f}s")
|
| 559 |
+
return q_head, out_qtype
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def _gguf_write_repl(target, reader, arch, repl, out_path, args, op, detail=None, _extra_names=()):
|
| 563 |
+
"""Single-pass GGUF write. repl maps tensor name -> (array, ggml_type).
|
| 564 |
+
detail (single-tensor callers): (tname, logical, vref, vraw, vqtype, meta) for rich messages."""
|
| 565 |
+
from gguf import GGUFWriter
|
| 566 |
+
from gguf.gguf_writer import GGUFValue
|
| 567 |
+
from gguf.constants import GGMLQuantizationType as Q
|
| 568 |
+
if not arch:
|
| 569 |
+
_warn(" Target architecture unknown β labeling output 'llama' (GGUF requires an arch).")
|
| 570 |
+
writer = GGUFWriter(out_path, arch or "llama", use_temp_file=True)
|
| 571 |
+
for field in reader.fields.values():
|
| 572 |
+
if field.name in ("general.architecture",) or field.name.startswith("GGUF."):
|
| 573 |
+
continue
|
| 574 |
+
try:
|
| 575 |
+
val = field.contents()
|
| 576 |
+
except Exception:
|
| 577 |
+
continue
|
| 578 |
+
vtype = field.types[0] if field.types else None
|
| 579 |
+
if vtype is None:
|
| 580 |
+
continue
|
| 581 |
+
subtype = field.types[-1] if len(field.types) > 1 else None
|
| 582 |
+
try:
|
| 583 |
+
writer.kv_data[0][field.name] = GGUFValue(value=val, type=vtype, sub_type=subtype)
|
| 584 |
+
except Exception:
|
| 585 |
+
try:
|
| 586 |
+
writer.add_key_value(field.name, val, vtype, subtype)
|
| 587 |
+
except Exception:
|
| 588 |
+
if ctx.VERBOSE:
|
| 589 |
+
_warn(f"Could not copy field {field.name}")
|
| 590 |
+
|
| 591 |
+
for t in reader.tensors:
|
| 592 |
+
if t.name in repl:
|
| 593 |
+
_qh, _qt = repl[t.name]
|
| 594 |
+
writer.add_tensor(t.name, _qh, raw_dtype=_qt)
|
| 595 |
+
elif t.tensor_type in (Q.F32, Q.F16):
|
| 596 |
+
writer.add_tensor(t.name, t.data)
|
| 597 |
+
else:
|
| 598 |
+
writer.add_tensor(t.name, t.data, raw_dtype=t.tensor_type)
|
| 599 |
+
for name in _extra_names:
|
| 600 |
+
if name in repl and name not in [t.name for t in reader.tensors]:
|
| 601 |
+
_qh, _qt = repl[name]
|
| 602 |
+
writer.add_tensor(name, _qh, raw_dtype=_qt)
|
| 603 |
+
|
| 604 |
+
# writing GGUF is the long wait (often 5-30s for 26GB) β show bar with speed, internet or local
|
| 605 |
+
_step(f"Writing {out_path} β¦")
|
| 606 |
+
# estimate total bytes for progress β use data.nbytes
|
| 607 |
+
try:
|
| 608 |
+
total_write = 8192
|
| 609 |
+
for t in reader.tensors:
|
| 610 |
+
if t.name in repl:
|
| 611 |
+
continue
|
| 612 |
+
try:
|
| 613 |
+
total_write += int(t.data.nbytes)
|
| 614 |
+
except Exception as e:
|
| 615 |
+
if ctx.VERBOSE:
|
| 616 |
+
_warn(f"progress estimate for {t.name} failed: {e}")
|
| 617 |
+
try:
|
| 618 |
+
for _qh, _qt in repl.values():
|
| 619 |
+
total_write += int(_qh.nbytes)
|
| 620 |
+
except Exception:
|
| 621 |
+
try:
|
| 622 |
+
for _qh, _qt in repl.values():
|
| 623 |
+
total_write += int(_qh.size * _qh.itemsize)
|
| 624 |
+
except Exception:
|
| 625 |
+
pass
|
| 626 |
+
if ctx.VERBOSE:
|
| 627 |
+
_note(f"Estimated write {total_write/1e6:.1f} MB ({len(repl)} replaced + {len([t for t in reader.tensors if t.name not in repl])} copied)")
|
| 628 |
+
except Exception as e:
|
| 629 |
+
if ctx.VERBOSE:
|
| 630 |
+
_warn(f"progress estimate failed: {e}")
|
| 631 |
+
total_write = 0
|
| 632 |
+
# ensure total_write is at least header + heads for small models, never 0 for progress
|
| 633 |
+
if total_write < 1000:
|
| 634 |
+
try:
|
| 635 |
+
total_write = 8192
|
| 636 |
+
for _qh, _qt in repl.values():
|
| 637 |
+
try:
|
| 638 |
+
total_write += int(_qh.nbytes)
|
| 639 |
+
except Exception:
|
| 640 |
+
pass
|
| 641 |
+
for t in reader.tensors:
|
| 642 |
+
if t.name not in repl:
|
| 643 |
+
try:
|
| 644 |
+
total_write += int(t.data.nbytes)
|
| 645 |
+
except Exception:
|
| 646 |
+
pass
|
| 647 |
+
except Exception:
|
| 648 |
+
pass
|
| 649 |
+
if total_write < 1000:
|
| 650 |
+
total_write = 0
|
| 651 |
+
t_write0 = time.time()
|
| 652 |
+
last_w = [0.0]
|
| 653 |
+
# progress poll for GGUF write (local disk, but user is waiting)
|
| 654 |
+
import threading
|
| 655 |
+
_stop_poll = [False]
|
| 656 |
+
def _poll_gguf():
|
| 657 |
+
# poll output file (or temp file) size
|
| 658 |
+
target_path = getattr(writer, 'fname', out_path)
|
| 659 |
+
# GGUFWriter with use_temp_file=True writes to temp file with suffix
|
| 660 |
+
# fallback to out_path
|
| 661 |
+
poll_path = target_path if os.path.exists(target_path) else out_path
|
| 662 |
+
# try to find temp file if not yet at final path
|
| 663 |
+
while not _stop_poll[0]:
|
| 664 |
+
try:
|
| 665 |
+
cur = 0
|
| 666 |
+
if os.path.exists(poll_path):
|
| 667 |
+
cur = os.path.getsize(poll_path)
|
| 668 |
+
else:
|
| 669 |
+
# try temp file pattern
|
| 670 |
+
import glob
|
| 671 |
+
tmps = glob.glob(str(out_path) + "*")
|
| 672 |
+
for p in tmps:
|
| 673 |
+
if os.path.isfile(p):
|
| 674 |
+
try:
|
| 675 |
+
cur = max(cur, os.path.getsize(p))
|
| 676 |
+
except Exception:
|
| 677 |
+
pass
|
| 678 |
+
elapsed = time.time() - t_write0 or 1e-6
|
| 679 |
+
mbps = cur / elapsed / 1e6
|
| 680 |
+
pct = (cur / total_write * 100) if total_write else 0
|
| 681 |
+
if pct > 100:
|
| 682 |
+
pct = 100
|
| 683 |
+
if total_write:
|
| 684 |
+
msg = f" β writing {pct:3.0f}% Β· {cur/1e6:.1f}/{total_write/1e6:.1f} MB Β· {mbps:.0f} MB/s "
|
| 685 |
+
else:
|
| 686 |
+
msg = f" β writing {cur/1e6:.1f} MB Β· {mbps:.0f} MB/s "
|
| 687 |
+
sys.stdout.write(_transform(msg) + ("\n" if _progress_pref() else "\r"))
|
| 688 |
+
sys.stdout.flush()
|
| 689 |
+
except Exception:
|
| 690 |
+
pass
|
| 691 |
+
time.sleep(0.25)
|
| 692 |
+
_thr = threading.Thread(target=_poll_gguf, daemon=True)
|
| 693 |
+
_thr.start()
|
| 694 |
+
try:
|
| 695 |
+
writer.write_header_to_file()
|
| 696 |
+
writer.write_kv_data_to_file()
|
| 697 |
+
writer.write_tensors_to_file()
|
| 698 |
+
writer.close()
|
| 699 |
+
except Exception as e:
|
| 700 |
+
_stop_poll[0] = True
|
| 701 |
+
try:
|
| 702 |
+
_thr.join(timeout=0.5)
|
| 703 |
+
except Exception:
|
| 704 |
+
pass
|
| 705 |
+
sys.stdout.write("\n")
|
| 706 |
+
if ctx.VERBOSE:
|
| 707 |
+
_say(f" Details: {e}")
|
| 708 |
+
_fail(f" β Could not save the voiced model to {out_path}.")
|
| 709 |
+
_stop_poll[0] = True
|
| 710 |
+
try:
|
| 711 |
+
_thr.join(timeout=0.5)
|
| 712 |
+
except Exception:
|
| 713 |
+
pass
|
| 714 |
+
sys.stdout.write("\n")
|
| 715 |
+
elapsed_w = time.time() - t_write0 or 1e-6
|
| 716 |
+
try:
|
| 717 |
+
final_sz = os.path.getsize(out_path)
|
| 718 |
+
_note(f"Wrote {final_sz/1e6:.1f} MB @ {final_sz/elapsed_w/1e6:.0f} MB/s")
|
| 719 |
+
except Exception:
|
| 720 |
+
pass
|
| 721 |
+
|
| 722 |
+
file_size = os.path.getsize(out_path)
|
| 723 |
+
if detail is None:
|
| 724 |
+
summed = [(n, _qt.name if hasattr(_qt, "name") else str(_qt)) for n, (_qh, _qt) in repl.items()]
|
| 725 |
+
log_operation({"op": op, "target": target, "format": "gguf", "tensors": summed,
|
| 726 |
+
"output": out_path, "status": "ok"})
|
| 727 |
+
_ok(f"Done β {out_path}")
|
| 728 |
+
for n, _qt in summed:
|
| 729 |
+
_say(f" Cast '{n}' β {_qt} ({file_size/1e6:.1f} MB total).")
|
| 730 |
+
_say(f" Load it: llama serve -m {out_path}")
|
| 731 |
+
return
|
| 732 |
+
tname, logical, vref, vraw, vqtype, meta = detail
|
| 733 |
+
_qh0, out_qtype = repl[tname]
|
| 734 |
+
log_operation({"op": op, "target": target, "format": "gguf", "tensor": tname,
|
| 735 |
+
"dtype": out_qtype.name, "shape": list(logical), "output": out_path, "status": "ok"})
|
| 736 |
+
_ok(f"Done β {out_path}")
|
| 737 |
+
is_fallback = 'fallback_to_f16' in locals() and locals().get('fallback_to_f16')
|
| 738 |
+
_is_head_msg = _is_head_name(tname) or _is_head_name(getattr(vref, "name", None))
|
| 739 |
+
_label = "Head" if _is_head_msg else "Tensor"
|
| 740 |
+
if (meta or {}).get("type") == "delta" and is_fallback:
|
| 741 |
+
_say(f" Style fused and head kept as F16 (Q8_0 needs %32, fallback) ({file_size/1e6:.1f} MB).")
|
| 742 |
+
elif (meta or {}).get("type") == "delta":
|
| 743 |
+
# Delta head keeps Q8_0 for heads, keeps target for generic.
|
| 744 |
+
if _is_head_msg and out_qtype == Q.Q8_0:
|
| 745 |
+
_say(f" Style fused and head quantized to Q8_0 ({file_size/1e6:.1f} MB).")
|
| 746 |
+
else:
|
| 747 |
+
_say(f" Style fused into '{tname}' β {out_qtype.name}, kept ({file_size/1e6:.1f} MB).")
|
| 748 |
+
elif is_fallback:
|
| 749 |
+
_say(f" Head kept as F16 (Q8_0 needs last dim %32, fallback) ({file_size/1e6:.1f} MB).")
|
| 750 |
+
elif out_qtype == Q.Q8_0 and _is_head_msg:
|
| 751 |
+
_qo = _parse_quant_override(getattr(args, "quant", None) or "keep")
|
| 752 |
+
_kept_q8 = (vraw is not None and isinstance(vqtype, str) and vqtype == Q.Q8_0.name and _qo in ("keep",))
|
| 753 |
+
if _kept_q8:
|
| 754 |
+
_say(f" Head kept as Q8_0 (from {vqtype}, {file_size/1e6:.1f} MB).")
|
| 755 |
+
else:
|
| 756 |
+
_say(f" Head quantized to Q8_0 ({file_size/1e6:.1f} MB).")
|
| 757 |
+
else:
|
| 758 |
+
_from = vqtype if vraw is not None else getattr(vref, "dtype", "?")
|
| 759 |
+
_say(f" {_label} '{tname}' kept as {out_qtype.name} (from {_from}, {file_size/1e6:.1f} MB).")
|
| 760 |
+
_say(f" Load it: llama serve -m {out_path}")
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
def _read_gguf_arch(reader):
|
| 764 |
+
"""Single arch-reader for GGUF targets. Returns the general.architecture string or None."""
|
| 765 |
+
import numpy as np
|
| 766 |
+
for field in reader.fields.values():
|
| 767 |
+
if field.name == "general.architecture":
|
| 768 |
+
try:
|
| 769 |
+
raw = field.parts[field.data[0]]
|
| 770 |
+
return raw.tobytes().decode() if isinstance(raw, np.ndarray) else str(raw)
|
| 771 |
+
except Exception:
|
| 772 |
+
return None
|
| 773 |
+
return None
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
def _confirm_arch_mismatch(varch, arch, args):
|
| 777 |
+
"""Single mismatch warn (+ confirm) shared by single and pack GGUF casts."""
|
| 778 |
+
if varch and arch and varch.lower() != arch.lower():
|
| 779 |
+
_warn(f"This voice was trained for a {varch} model, but the target is {arch}. It may not sound right.")
|
| 780 |
+
if not _confirm(" Continue anyway? [y/N]: ", args):
|
| 781 |
+
_note("Cancelled.")
|
| 782 |
+
sys.exit(0)
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
def _cast_gguf_speak(target, reader, arch, out_path, vf32, vref, meta, args, op, vraw=None, vqtype=None):
|
| 786 |
+
"""--speak: voice onto output.weight, invented if absent. GGUF only."""
|
| 787 |
+
import numpy as np
|
| 788 |
+
from gguf.constants import GGMLQuantizationType as Q
|
| 789 |
+
from gguf.quants import quantize, quant_shape_to_byte_shape
|
| 790 |
+
if (meta or {}).get("type") == "delta":
|
| 791 |
+
_fail(" β --speak cannot place a delta voice (differences are not weights).")
|
| 792 |
+
names = [t.name for t in reader.tensors]
|
| 793 |
+
ot = next((t for t in reader.tensors if t.name == "output.weight"), None)
|
| 794 |
+
et = next((t for t in reader.tensors if t.name == "token_embd.weight"), None)
|
| 795 |
+
if ot is not None:
|
| 796 |
+
olog = tuple(reversed(tuple(int(x) for x in ot.shape)))
|
| 797 |
+
invented = False
|
| 798 |
+
elif et is not None:
|
| 799 |
+
olog = tuple(reversed(tuple(int(x) for x in et.shape)))
|
| 800 |
+
invented = True
|
| 801 |
+
else:
|
| 802 |
+
_fail(" β Target has no output.weight or token_embd.weight to mirror geometry from.")
|
| 803 |
+
varch = (meta or {}).get("architecture")
|
| 804 |
+
_confirm_arch_mismatch(varch, arch, args)
|
| 805 |
+
tname = "output.weight"
|
| 806 |
+
if vraw is not None:
|
| 807 |
+
_vqtype = Q[vqtype]
|
| 808 |
+
if tuple(vref.shape) != olog:
|
| 809 |
+
_fail(f" β Voice shape {vref.shape} doesn't fit {tname} {olog}.")
|
| 810 |
+
_qo = _parse_quant_override(getattr(args, "quant", None) or "keep")
|
| 811 |
+
if _qo not in ("keep",):
|
| 812 |
+
fitted = _gguf_raw_to_f32(vraw, _vqtype, tuple(int(x) for x in vref.shape))
|
| 813 |
+
q_head, out_qtype = _quantize_to_Q(np.ascontiguousarray(fitted), Q[_qo])
|
| 814 |
+
else:
|
| 815 |
+
_need = 1
|
| 816 |
+
for _d in quant_shape_to_byte_shape(tuple(int(x) for x in vref.shape), _vqtype):
|
| 817 |
+
_need *= _d
|
| 818 |
+
_raw = np.frombuffer(vraw, dtype=np.uint8)
|
| 819 |
+
if _raw.size < _need:
|
| 820 |
+
_fail(f" β Voice data too short for {vqtype} {tuple(vref.shape)}.")
|
| 821 |
+
q_head = np.ascontiguousarray(_raw[:_need].reshape(
|
| 822 |
+
quant_shape_to_byte_shape(tuple(int(x) for x in vref.shape), _vqtype)))
|
| 823 |
+
out_qtype = _vqtype
|
| 824 |
+
else:
|
| 825 |
+
fitted = _orient(vf32, vref.shape, olog)
|
| 826 |
+
if fitted is None:
|
| 827 |
+
_fail(f" β Voice shape {vref.shape} doesn't fit {tname} {olog}.")
|
| 828 |
+
fitted = np.ascontiguousarray(fitted)
|
| 829 |
+
_qo = _parse_quant_override(getattr(args, "quant", None) or "keep")
|
| 830 |
+
if _qo is None:
|
| 831 |
+
_fail(f" β Invalid --quant '{getattr(args, 'quant', None)}'.")
|
| 832 |
+
if _qo not in ("keep",):
|
| 833 |
+
q_head, out_qtype = _quantize_to_Q(fitted, Q[_qo])
|
| 834 |
+
elif fitted.shape[-1] % 32 == 0:
|
| 835 |
+
q_head, out_qtype = quantize(fitted, Q.Q8_0), Q.Q8_0
|
| 836 |
+
else:
|
| 837 |
+
_note(" last dim not x32 - added output kept F16")
|
| 838 |
+
q_head, out_qtype = fitted.astype(np.float16), Q.F16
|
| 839 |
+
if invented:
|
| 840 |
+
_note("speak: voice -> invented output.weight (file untied)")
|
| 841 |
+
else:
|
| 842 |
+
_note("speak: voice -> output.weight")
|
| 843 |
+
_gguf_write_repl(target, reader, arch, {tname: (q_head, out_qtype)}, out_path, args, op,
|
| 844 |
+
detail=(tname, olog, vref, vraw, vqtype, meta),
|
| 845 |
+
_extra_names=(tname,) if invented else ())
|
| 846 |
+
|
| 847 |
+
def _cast_gguf(target, out_path, vf32, vref, meta, args, op, vraw=None, vqtype=None, strict_target=False):
|
| 848 |
+
import numpy as np
|
| 849 |
+
from gguf import GGUFReader
|
| 850 |
+
|
| 851 |
+
reader = GGUFReader(target)
|
| 852 |
+
arch = _read_gguf_arch(reader)
|
| 853 |
+
|
| 854 |
+
if getattr(args, "speak", False):
|
| 855 |
+
_cast_gguf_speak(target, reader, arch, out_path, vf32, vref, meta, args, op, vraw=vraw, vqtype=vqtype)
|
| 856 |
+
return
|
| 857 |
+
target_tensor = None
|
| 858 |
+
if vref.name:
|
| 859 |
+
for t in reader.tensors:
|
| 860 |
+
if t.name == vref.name:
|
| 861 |
+
target_tensor = t
|
| 862 |
+
break
|
| 863 |
+
if target_tensor is None and strict_target:
|
| 864 |
+
_fail(f" β Tensor '{vref.name}' not found in target {target}. Voicepack requires exact match (not mapping to another tensor).")
|
| 865 |
+
if target_tensor is None:
|
| 866 |
+
for preferred in ("output.weight", "token_embd.weight"):
|
| 867 |
+
for t in reader.tensors:
|
| 868 |
+
if t.name == preferred:
|
| 869 |
+
target_tensor = t
|
| 870 |
+
break
|
| 871 |
+
if target_tensor is not None:
|
| 872 |
+
break
|
| 873 |
+
if target_tensor is None:
|
| 874 |
+
_fail(" β Target has no output.weight or token_embd.weight to receive the voice.")
|
| 875 |
+
|
| 876 |
+
tname = target_tensor.name
|
| 877 |
+
logical = tuple(reversed(tuple(int(x) for x in target_tensor.shape)))
|
| 878 |
+
|
| 879 |
+
# arch mismatch warning
|
| 880 |
+
varch = (meta or {}).get("architecture")
|
| 881 |
+
_confirm_arch_mismatch(varch, arch, args)
|
| 882 |
+
|
| 883 |
+
q_head, out_qtype = _gguf_fuse_one(target, target_tensor, tname, logical, vf32, vref, meta, args, op, vraw=vraw, vqtype=vqtype)
|
| 884 |
+
_gguf_write_repl(target, reader, arch, {tname: (q_head, out_qtype)}, out_path, args, op,
|
| 885 |
+
detail=(tname, logical, vref, vraw, vqtype, meta))
|
| 886 |
+
|
| 887 |
+
|
| 888 |
+
# GGUF (llama.cpp) <-> safetensors (HF) tensor-name aliases, matched by
|
| 889 |
+
# (block index, role). Exact name match always wins; aliases only fire on a
|
| 890 |
+
# miss, and only when exactly one candidate matches. Anything else fails loud
|
| 891 |
+
# at the call site β never a silent mapping to another tensor.
|
| 892 |
+
_ROLE_ALIASES = (
|
| 893 |
+
("embed_tokens.weight", "token_embd.weight"),
|
| 894 |
+
("word_embeddings.weight", "token_embd.weight"),
|
| 895 |
+
("wte.weight", "token_embd.weight"),
|
| 896 |
+
("output_layer.weight", "output.weight"),
|
| 897 |
+
("lm_head.weight", "output.weight"),
|
| 898 |
+
("final_layernorm.weight", "output_norm.weight"),
|
| 899 |
+
("self_attention.dense.weight", "attn_output.weight"),
|
| 900 |
+
("mlp.dense_4h_to_h.weight", "ffn_down.weight"),
|
| 901 |
+
("model.norm.weight", "output_norm.weight"),
|
| 902 |
+
("ln_f.weight", "output_norm.weight"),
|
| 903 |
+
("input_layernorm.weight", "attn_norm.weight"),
|
| 904 |
+
("self_attn.q_proj.weight", "attn_q.weight"),
|
| 905 |
+
("self_attn.k_proj.weight", "attn_k.weight"),
|
| 906 |
+
("self_attn.v_proj.weight", "attn_v.weight"),
|
| 907 |
+
("self_attn.o_proj.weight", "attn_o.weight"),
|
| 908 |
+
("self_attn.o_proj.weight", "attn_output.weight"),
|
| 909 |
+
("post_attention_layernorm.weight", "post_attention_norm.weight"),
|
| 910 |
+
("mlp.down_proj.weight", "ffn_down.weight"),
|
| 911 |
+
("experts.down_proj", "ffn_down_exps.weight"),
|
| 912 |
+
("mlp.up_proj.weight", "ffn_up.weight"),
|
| 913 |
+
)
|
| 914 |
+
|
| 915 |
+
|
| 916 |
+
def _block_index(name):
|
| 917 |
+
import re
|
| 918 |
+
m = re.search(r"(?<![A-Za-z0-9_])(?:layers|layer|blk|blks|blocks|h)\.(\d+)\b", name)
|
| 919 |
+
if m:
|
| 920 |
+
return int(m.group(1))
|
| 921 |
+
m = re.search(r"\.(\d+)\.", name)
|
| 922 |
+
return int(m.group(1)) if m else None
|
| 923 |
+
|
| 924 |
+
|
| 925 |
+
def _pack_aliases(vname, target_names):
|
| 926 |
+
"""All alias matches for vname in target_names (either naming direction).
|
| 927 |
+
Exact match is handled by callers first; this only fires on a miss."""
|
| 928 |
+
hits = []
|
| 929 |
+
for st_suf, gg_suf in _ROLE_ALIASES:
|
| 930 |
+
for t in target_names:
|
| 931 |
+
st_v = vname.endswith(st_suf)
|
| 932 |
+
gg_v = vname.endswith(gg_suf)
|
| 933 |
+
st_t = t.endswith(st_suf)
|
| 934 |
+
gg_t = t.endswith(gg_suf)
|
| 935 |
+
if (st_v and gg_t) or (gg_v and st_t):
|
| 936 |
+
if _block_index(vname) == _block_index(t) and t not in hits:
|
| 937 |
+
hits.append(t)
|
| 938 |
+
return hits
|
| 939 |
+
|
| 940 |
+
|
| 941 |
+
def _role_key(name):
|
| 942 |
+
"""(block_index, trailing_role) canonical key for same-format matching.
|
| 943 |
+
Trailing role is everything after the block number (e.g.
|
| 944 |
+
'self_attn.o_proj.weight'); unblocked names fall back to their last two
|
| 945 |
+
components (e.g. 'embed_tokens.weight'). The .weight/.bias suffix stays
|
| 946 |
+
in the key so a bias never matches a weight. The block pattern is
|
| 947 |
+
left-guarded so it never fires inside a word (e.g. 'torch.0')."""
|
| 948 |
+
import re
|
| 949 |
+
m = re.search(r"(?<![A-Za-z0-9_])(?:layers|layer|blk|blks|blocks|h)\.(\d+)\b", name)
|
| 950 |
+
if m:
|
| 951 |
+
return (int(m.group(1)), name[m.end():].strip("."))
|
| 952 |
+
m = re.search(r"\.(\d+)\.", name)
|
| 953 |
+
if m:
|
| 954 |
+
return (int(m.group(1)), name[m.end():].strip("."))
|
| 955 |
+
parts = name.split(".")
|
| 956 |
+
return (None, ".".join(parts[-2:]))
|
| 957 |
+
|
| 958 |
+
|
| 959 |
+
def _same_role(vname, target_names):
|
| 960 |
+
"""Targets sharing vname's (block, trailing role). Cross-format pairs
|
| 961 |
+
(ST suffix vs GGUF suffix) never collide here β _ROLE_ALIASES owns those.
|
| 962 |
+
Only fires on a miss; callers gate on elem-count and uniqueness."""
|
| 963 |
+
want = _role_key(vname)
|
| 964 |
+
return [t for t in target_names if _role_key(t) == want]
|
| 965 |
+
|
| 966 |
+
|
| 967 |
+
def _cast_gguf_pack(target, out_path, items, args, op):
|
| 968 |
+
"""Single-pass pack cast onto GGUF. items: [(vf32, vref, meta, vraw, vqtype)]."""
|
| 969 |
+
import numpy as np
|
| 970 |
+
from gguf import GGUFReader
|
| 971 |
+
reader = GGUFReader(target)
|
| 972 |
+
arch = _read_gguf_arch(reader)
|
| 973 |
+
varch = (items[0][2] or {}).get("architecture") if items else None
|
| 974 |
+
_confirm_arch_mismatch(varch, arch, args)
|
| 975 |
+
_names = [t.name for t in reader.tensors]
|
| 976 |
+
_counts = {}
|
| 977 |
+
for t in reader.tensors:
|
| 978 |
+
try:
|
| 979 |
+
n = 1
|
| 980 |
+
for d in t.shape:
|
| 981 |
+
n *= int(d)
|
| 982 |
+
_counts[t.name] = n
|
| 983 |
+
except Exception:
|
| 984 |
+
pass
|
| 985 |
+
_map = _resolve_pack_targets([(ref.name, ref.shape) for (_, ref, _, _, _) in items],
|
| 986 |
+
_names, target, _counts)
|
| 987 |
+
repl = {}
|
| 988 |
+
for vf32, vref, meta, vraw, vqtype in items:
|
| 989 |
+
target_tensor = next(t for t in reader.tensors if t.name == _map[vref.name])
|
| 990 |
+
tname = target_tensor.name
|
| 991 |
+
logical = tuple(reversed(tuple(int(x) for x in target_tensor.shape)))
|
| 992 |
+
q_head, out_qtype = _gguf_fuse_one(target, target_tensor, tname, logical, vf32, vref, meta, args, op, vraw=vraw, vqtype=vqtype)
|
| 993 |
+
repl[tname] = (q_head, out_qtype)
|
| 994 |
+
_gguf_write_repl(target, reader, arch, repl, out_path, args, op)
|
| 995 |
+
|
| 996 |
+
|
| 997 |
+
def _resolve_pack_targets(pairs, target_names, target, counts=None):
|
| 998 |
+
"""Map every voice tensor to a target tensor (exact, else one alias,
|
| 999 |
+
else one same-role same-format match).
|
| 1000 |
+
Resolves ALL up front so a pack fails once with every miss β never
|
| 1001 |
+
fix-forward one at a time. Same-size targets are offered as hints only.
|
| 1002 |
+
pairs: [(voice_name, voice_shape)]; counts: {target_name: elem_count}.
|
| 1003 |
+
Voice shapes are logical-as-is (GgufSource.ref already reverses);
|
| 1004 |
+
only elem-counts are compared, so transposes pass and nothing double-flips."""
|
| 1005 |
+
def _want(vshape):
|
| 1006 |
+
if vshape is None:
|
| 1007 |
+
return None
|
| 1008 |
+
n = 1
|
| 1009 |
+
for d in vshape:
|
| 1010 |
+
n *= int(d)
|
| 1011 |
+
return n
|
| 1012 |
+
mapping, misses = {}, []
|
| 1013 |
+
for vname, vshape in pairs:
|
| 1014 |
+
if vname in target_names:
|
| 1015 |
+
mapping[vname] = vname
|
| 1016 |
+
continue
|
| 1017 |
+
cands = _pack_aliases(vname, target_names)
|
| 1018 |
+
if len(cands) == 1:
|
| 1019 |
+
mapping[vname] = cands[0]
|
| 1020 |
+
_note(f" Aliased '{vname}' β '{cands[0]}' ({target} naming).")
|
| 1021 |
+
elif len(cands) > 1:
|
| 1022 |
+
misses.append(f" '{vname}' is ambiguous: {', '.join(cands)}")
|
| 1023 |
+
else:
|
| 1024 |
+
roles = [t for t in _same_role(vname, target_names)
|
| 1025 |
+
if _want(vshape) is None or counts is None
|
| 1026 |
+
or counts.get(t, _want(vshape)) == _want(vshape)]
|
| 1027 |
+
if len(roles) == 1:
|
| 1028 |
+
mapping[vname] = roles[0]
|
| 1029 |
+
_note(f" Aliased '{vname}' β '{roles[0]}' (same role).")
|
| 1030 |
+
continue
|
| 1031 |
+
if len(roles) > 1:
|
| 1032 |
+
misses.append(f" '{vname}' is ambiguous: {', '.join(roles)}")
|
| 1033 |
+
continue
|
| 1034 |
+
hint = ""
|
| 1035 |
+
if counts is not None and vshape is not None:
|
| 1036 |
+
same = [t for t in target_names if counts.get(t) == _want(vshape)][:3]
|
| 1037 |
+
if same:
|
| 1038 |
+
hint = f" (same size: {', '.join(same)})"
|
| 1039 |
+
misses.append(f" '{vname}' not found{hint}")
|
| 1040 |
+
if misses:
|
| 1041 |
+
_fail(f" β {len(misses)} tensor(s) have no match in target {target}:\n"
|
| 1042 |
+
+ "\n".join(misses)
|
| 1043 |
+
+ "\n Voicepack requires exact match (not mapping to another tensor).")
|
| 1044 |
+
return mapping
|
| 1045 |
+
|
| 1046 |
+
|
| 1047 |
+
def _cast_pack(target, src_path, out_path, args, op, excludes=(), meta=None):
|
| 1048 |
+
"""Cast every tensor in a pack file onto target (minus excludes).
|
| 1049 |
+
GGUF and safetensors targets each get one write pass (repl-dict fused first).
|
| 1050 |
+
Delta marker on the pack is detected once and threaded into every tensor."""
|
| 1051 |
+
src = open_source(str(src_path))
|
| 1052 |
+
all_names = [n for n in src.names() if n != "delta.voice.marker"]
|
| 1053 |
+
for e in excludes:
|
| 1054 |
+
if e not in all_names and not any(e in n for n in all_names):
|
| 1055 |
+
_warn(f" --exclude '{e}' not in pack (has {len(all_names)} tensors). Ignoring.")
|
| 1056 |
+
|
| 1057 |
+
resolved_ex = set()
|
| 1058 |
+
for e in excludes:
|
| 1059 |
+
if e in all_names:
|
| 1060 |
+
resolved_ex.add(e)
|
| 1061 |
+
else:
|
| 1062 |
+
_m = [n for n in all_names if e in n]
|
| 1063 |
+
if len(_m) == 1:
|
| 1064 |
+
resolved_ex.add(_m[0])
|
| 1065 |
+
to_cast = [n for n in all_names if n not in resolved_ex]
|
| 1066 |
+
if not to_cast:
|
| 1067 |
+
_fail(" β --exclude removes all tensors. Nothing to cast.")
|
| 1068 |
+
_step(f"Casting {len(to_cast)} tensors from {Path(src_path).name} onto {Path(target).name}β¦")
|
| 1069 |
+
if resolved_ex:
|
| 1070 |
+
_note(f"Excluding {len(resolved_ex)}: {', '.join(sorted(resolved_ex))}")
|
| 1071 |
+
is_delta_file = str(src_path).endswith(".safetensors") and _is_delta_safetensors(str(src_path))
|
| 1072 |
+
if is_delta_file:
|
| 1073 |
+
_note("Detected delta voice β fusing style (delta + head)β¦")
|
| 1074 |
+
if meta is None and is_delta_file:
|
| 1075 |
+
meta = {"type": "delta", "source": str(src_path)}
|
| 1076 |
+
items = []
|
| 1077 |
+
for i, tname in enumerate(to_cast):
|
| 1078 |
+
f32, ref, raw, qtype = _load_tensor(src_path, tname, _src=src)
|
| 1079 |
+
_step(f"[{i+1}/{len(to_cast)}] {tname} ({ref.dtype} {tuple(ref.shape)})β¦")
|
| 1080 |
+
items.append((f32, ref, meta, raw, qtype))
|
| 1081 |
+
if _is_gguf_file(target):
|
| 1082 |
+
_cast_gguf_pack(target, out_path, items, args, op)
|
| 1083 |
+
return
|
| 1084 |
+
_header, _tensors = read_safetensors(target)
|
| 1085 |
+
_st_names = list(_tensors.keys())
|
| 1086 |
+
_st_counts = {}
|
| 1087 |
+
for _n, _info in _tensors.items():
|
| 1088 |
+
try:
|
| 1089 |
+
_c = 1
|
| 1090 |
+
for _d in _info["shape"]:
|
| 1091 |
+
_c *= int(_d)
|
| 1092 |
+
_st_counts[_n] = _c
|
| 1093 |
+
except Exception:
|
| 1094 |
+
pass
|
| 1095 |
+
_st_map = _resolve_pack_targets([(ref.name, ref.shape) for (_, ref, _, _, _) in items],
|
| 1096 |
+
_st_names, target, _st_counts)
|
| 1097 |
+
repl = {}
|
| 1098 |
+
for f32, ref, m, raw, qtype in items:
|
| 1099 |
+
tname, data, tdtype, tshape = _cast_st_prepare(target, _tensors, f32, ref, m, args,
|
| 1100 |
+
vraw=raw, vqtype=qtype, strict_target=True,
|
| 1101 |
+
target_name=_st_map[ref.name])
|
| 1102 |
+
repl[tname] = (data, tdtype, tshape)
|
| 1103 |
+
_step(f"Replacing {len(repl)} tensors in {Path(target).name}β¦")
|
| 1104 |
+
_safetensors_write_repl(target, repl, out_path)
|
| 1105 |
+
file_size = os.path.getsize(out_path)
|
| 1106 |
+
log_operation({"op": op, "target": target, "format": "safetensors",
|
| 1107 |
+
"tensors": [(n, d) for n, (_b, d, _s) in repl.items()],
|
| 1108 |
+
"output": out_path, "status": "ok"})
|
| 1109 |
+
_ok(f"Done β {out_path}")
|
| 1110 |
+
for n, (_b, d, _s) in repl.items():
|
| 1111 |
+
_say(f" Tensor '{n}' replaced at {d} ({file_size/1e6:.1f} MB total).")
|
| 1112 |
+
|
| 1113 |
+
|
| 1114 |
+
def _parse_tensor_list(args):
|
| 1115 |
+
# Tensor positionals (get/cast tensors...), with comma-split for shell convenience.
|
| 1116 |
+
raw = getattr(args, "tensors", None) or []
|
| 1117 |
+
if isinstance(raw, str):
|
| 1118 |
+
raw = [raw]
|
| 1119 |
+
# Split comma-separated entries, strip, drop empties.
|
| 1120 |
+
out = []
|
| 1121 |
+
for r in raw:
|
| 1122 |
+
if r is None:
|
| 1123 |
+
continue
|
| 1124 |
+
for part in str(r).split(","):
|
| 1125 |
+
part = part.strip()
|
| 1126 |
+
if part:
|
| 1127 |
+
out.append(part)
|
| 1128 |
+
return out
|
| 1129 |
+
|
| 1130 |
+
|
| 1131 |
+
def _parse_exclude_list(args):
|
| 1132 |
+
raw = getattr(args, "excludes", None) or getattr(args, "exclude", None) or []
|
| 1133 |
+
if isinstance(raw, str):
|
| 1134 |
+
raw = [raw]
|
| 1135 |
+
out = []
|
| 1136 |
+
for r in (raw or []):
|
| 1137 |
+
for part in str(r).split(","):
|
| 1138 |
+
part = part.strip()
|
| 1139 |
+
if part:
|
| 1140 |
+
out.append(part)
|
| 1141 |
+
return out
|
| 1142 |
+
|
| 1143 |
+
|
| 1144 |
+
def _resolve_one_tensor(req, names):
|
| 1145 |
+
# Exact match first, then single substring match, else fail. Returns resolved name.
|
| 1146 |
+
if req in names:
|
| 1147 |
+
return req
|
| 1148 |
+
matches = [n for n in names if req and req in n]
|
| 1149 |
+
if len(matches) == 1:
|
| 1150 |
+
return matches[0]
|
| 1151 |
+
if len(matches) > 1:
|
| 1152 |
+
_fail(f" β '{req}' is ambiguous: {', '.join(matches[:8])}β¦")
|
| 1153 |
+
_fail(f" β Tensor '{req}' not found. Try one of: {', '.join(names[:10])}")
|
| 1154 |
+
|
| 1155 |
+
|
| 1156 |
+
def _tensor_src_meta(src_path):
|
| 1157 |
+
"""File-level delta detection for the tensor path (the marker lives in the file; no voice.json)."""
|
| 1158 |
+
if str(src_path).endswith(".safetensors"):
|
| 1159 |
+
try:
|
| 1160 |
+
if _is_delta_safetensors(str(src_path)):
|
| 1161 |
+
return {"type": "delta", "source": str(src_path)}
|
| 1162 |
+
except Exception:
|
| 1163 |
+
pass
|
| 1164 |
+
return None
|
| 1165 |
+
|
| 1166 |
+
|
| 1167 |
+
def _load_tensor(path, name=None, _src=None):
|
| 1168 |
+
src = _src if _src is not None else open_source(str(path))
|
| 1169 |
+
names = src.names()
|
| 1170 |
+
if not names:
|
| 1171 |
+
_fail(f" β No tensors found in {path}.")
|
| 1172 |
+
if name is None:
|
| 1173 |
+
name = "output.weight" if "output.weight" in names else names[0]
|
| 1174 |
+
|
| 1175 |
+
if name not in names:
|
| 1176 |
+
matches = [n for n in names if name in n]
|
| 1177 |
+
if len(matches) == 1:
|
| 1178 |
+
name = matches[0]
|
| 1179 |
+
else:
|
| 1180 |
+
_fail(f" β Tensor '{name}' not found in {path}.")
|
| 1181 |
+
ref = src.ref(name)
|
| 1182 |
+
if src.kind == "gguf" and _is_ggml_quant(ref.dtype):
|
| 1183 |
+
return None, ref, src.read_raw(name), ref.dtype
|
| 1184 |
+
return src.read_f32(name), ref, None, None
|
| 1185 |
+
|
| 1186 |
+
|
vlib/ctx.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared constants and globals: single definition for paths, version, verbosity."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
VOICES_DIR = Path.home() / ".voices"
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
HF_TOKEN_PATH = VOICES_DIR / ".hf_token"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
OPERATIONS_LOG = VOICES_DIR / "operations.log"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
REPORTS_JSONL = VOICES_DIR / "reports.jsonl"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
REPORTS_OUTBOX = VOICES_DIR / "outbox"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
REPORTS_DATASET_ID = "Wiself/voice-reports"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
REPORTS_REMOTE_PREFIX = "data/reports-"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
VERSION = "3.0"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
DTYPE_BYTES = {"F32": 4, "F16": 2, "BF16": 2}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
VERBOSE = False
|
vlib/delta.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Delta computation (voice minus base) and base resolution."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import struct
|
| 7 |
+
import sys
|
| 8 |
+
import time
|
| 9 |
+
from vlib.ui import _fail, _noninteractive, _progress_pref, _say, _transform, _warn
|
| 10 |
+
from vlib.net import _fmt_eta
|
| 11 |
+
from vlib.tensors import _is_delta_safetensors, _is_ggml_quant, decode_to_f32, dtype_bytes, read_safetensors
|
| 12 |
+
from vlib.sources import _detect_base, _normalize_hf_repo, _public_source, open_source
|
| 13 |
+
from vlib.fetch import _resolve_output_name, _source_arch
|
| 14 |
+
from vlib.registry import _assert_not_symlink, _iter_voices, _read_meta
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _choose_voice(args):
|
| 18 |
+
voices = []
|
| 19 |
+
for d in _iter_voices():
|
| 20 |
+
meta = _read_meta(d / "voice.json")
|
| 21 |
+
if meta:
|
| 22 |
+
voices.append((d.name, meta))
|
| 23 |
+
if not voices:
|
| 24 |
+
_fail(" β No voices stored yet. Use 'voice get <model>' to save one.")
|
| 25 |
+
_say(" Available voices:")
|
| 26 |
+
for i, (dirname, meta) in enumerate(voices, 1):
|
| 27 |
+
_say(f" {i}. {meta.get('name', dirname)} β {meta.get('source_hf_model', meta.get('source', 'β'))}")
|
| 28 |
+
try:
|
| 29 |
+
choice = input(f" Choose voice [1-{len(voices)}]: ").strip()
|
| 30 |
+
except (EOFError, KeyboardInterrupt):
|
| 31 |
+
_say(" β Cancelled.")
|
| 32 |
+
sys.exit(0)
|
| 33 |
+
if not choice.isdigit() or not (1 <= int(choice) <= len(voices)):
|
| 34 |
+
_fail(" β Invalid choice.")
|
| 35 |
+
return voices[int(choice) - 1][1].get("name", voices[int(choice) - 1][0])
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _resolve_base(args, source_hf):
|
| 39 |
+
base_arg = getattr(args, "base", None)
|
| 40 |
+
if base_arg:
|
| 41 |
+
# allow local file/dir path as base (for manual delta)
|
| 42 |
+
p = Path(base_arg).expanduser()
|
| 43 |
+
if p.exists():
|
| 44 |
+
return str(p.resolve())
|
| 45 |
+
norm = _normalize_hf_repo(base_arg)
|
| 46 |
+
if norm is None:
|
| 47 |
+
_fail(f" β Invalid HF repo '{base_arg}'. Use org/model or a local path.")
|
| 48 |
+
return norm
|
| 49 |
+
if source_hf not in ("β", "", None) and "/" in source_hf:
|
| 50 |
+
suggested, is_multi, count, _raw = _detect_base(source_hf)
|
| 51 |
+
else:
|
| 52 |
+
suggested, is_multi, count, _raw = None, False, 0, []
|
| 53 |
+
if suggested is None:
|
| 54 |
+
if _noninteractive(args):
|
| 55 |
+
_fail(" β Could not detect the base model. Use --base <org/model> --yes")
|
| 56 |
+
try:
|
| 57 |
+
entered = input(" Enter base HF repo (org/model) or local path: ").strip()
|
| 58 |
+
except (EOFError, KeyboardInterrupt):
|
| 59 |
+
_say(" β Cancelled.")
|
| 60 |
+
sys.exit(0)
|
| 61 |
+
if not entered:
|
| 62 |
+
_fail(" β Invalid base repo.")
|
| 63 |
+
p = Path(entered).expanduser()
|
| 64 |
+
if p.exists():
|
| 65 |
+
return str(p.resolve())
|
| 66 |
+
norm = _normalize_hf_repo(entered)
|
| 67 |
+
if norm is None:
|
| 68 |
+
_fail(" β Invalid base repo.")
|
| 69 |
+
return norm
|
| 70 |
+
if is_multi:
|
| 71 |
+
_say(f" Base: {suggested} ({count} candidates)")
|
| 72 |
+
else:
|
| 73 |
+
_say(f" Base detected: {suggested}")
|
| 74 |
+
if _noninteractive(args):
|
| 75 |
+
return suggested
|
| 76 |
+
try:
|
| 77 |
+
entered = input(f" Press enter to use {suggested}, or type another repo/path: ").strip()
|
| 78 |
+
except (EOFError, KeyboardInterrupt):
|
| 79 |
+
_say(" β Cancelled.")
|
| 80 |
+
sys.exit(0)
|
| 81 |
+
if not entered:
|
| 82 |
+
return suggested
|
| 83 |
+
p = Path(entered).expanduser()
|
| 84 |
+
if p.exists():
|
| 85 |
+
return str(p.resolve())
|
| 86 |
+
norm = _normalize_hf_repo(entered)
|
| 87 |
+
return norm if norm else suggested
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _delta_gate(args, warn_lines, phrase, fail_hint):
|
| 91 |
+
"""Confirm niche delta-on-delta cases. Fails fast when non-interactive."""
|
| 92 |
+
for i, line in enumerate(warn_lines):
|
| 93 |
+
(_warn if i == 0 else _say)(line)
|
| 94 |
+
if _noninteractive(args):
|
| 95 |
+
_fail(f" β {fail_hint} (re-run interactively and type {phrase} to proceed).")
|
| 96 |
+
try:
|
| 97 |
+
ans = input(f" Type {phrase} to continue, Enter to cancel: ").strip()
|
| 98 |
+
except (EOFError, KeyboardInterrupt):
|
| 99 |
+
_say(" Cancelled."); sys.exit(0)
|
| 100 |
+
if ans.strip().lower() != phrase.lower():
|
| 101 |
+
_say(" Cancelled."); sys.exit(0)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _check_delta_gate(args, voice_path, base_path):
|
| 105 |
+
"""Single gate-checker for delta-on-delta niche cases. Computes the
|
| 106 |
+
is_voice/is_base flags internally (GGUF has no marker, so non-safetensors
|
| 107 |
+
sides stay False β matches both the guarded GGUF branch and the
|
| 108 |
+
safetensors branch, where both sides are .safetensors)."""
|
| 109 |
+
is_voice_delta = _is_delta_safetensors(str(voice_path)) if str(voice_path).endswith(".safetensors") else False
|
| 110 |
+
is_base_delta = _is_delta_safetensors(str(base_path)) if str(base_path).endswith(".safetensors") else False
|
| 111 |
+
if not is_voice_delta and is_base_delta:
|
| 112 |
+
_delta_gate(args, [" The base you gave is a delta-voice (has delta.voice.marker).",
|
| 113 |
+
" Deltas are meant to be applied, not used as a base.",
|
| 114 |
+
" normal β delta = 2*normal β style β nonsense, not a style."],
|
| 115 |
+
"UNDO THE UNDO", "delta-on-delta needs explicit confirmation")
|
| 116 |
+
elif is_voice_delta and not is_base_delta:
|
| 117 |
+
_delta_gate(args, [" The voice you gave is already a delta-voice.",
|
| 118 |
+
" Delta-on-normal is usually just style β base. You are doing delta β normal = style β 2*normal."],
|
| 119 |
+
"DOUBLE DIP", "delta-on-delta needs explicit confirmation")
|
| 120 |
+
elif is_voice_delta and is_base_delta:
|
| 121 |
+
_delta_gate(args, [" β¨ Congratulations β you have discovered a hidden feature",
|
| 122 |
+
" The voice and base are both delta-voices.",
|
| 123 |
+
" delta β delta = style difference of style differences."],
|
| 124 |
+
"BIG BRAIN", "delta-on-delta needs explicit confirmation")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _compute_delta(voice_path, base_path, delta_folder, delta_name, args=None):
|
| 128 |
+
# Fast path: both safetensors (no dequant, chunked, efficient for 1.4GB).
|
| 129 |
+
# Slow path: either side GGUF quant -> dequant once to F32 (unavoidable), direct numpy (efficient for <50MB tests).
|
| 130 |
+
_v_gguf = _b_gguf = False
|
| 131 |
+
# Chunk seam resolves through the voice facade (not vlib.tensors): the suite
|
| 132 |
+
# patches voice._DELTA_CHUNK_ELEMS / voice._gguf_raw_slice_to_f32, so the
|
| 133 |
+
# slow path must honor the facade namespace. Local import: voice imports us.
|
| 134 |
+
import voice as _facade
|
| 135 |
+
try:
|
| 136 |
+
v_hdr, v_tensors = read_safetensors(str(voice_path))
|
| 137 |
+
except Exception:
|
| 138 |
+
_v_gguf = True
|
| 139 |
+
v_hdr = v_tensors = None
|
| 140 |
+
try:
|
| 141 |
+
b_hdr, b_tensors = read_safetensors(str(base_path))
|
| 142 |
+
except Exception:
|
| 143 |
+
_b_gguf = True
|
| 144 |
+
b_hdr = b_tensors = None
|
| 145 |
+
if _v_gguf or _b_gguf:
|
| 146 |
+
import numpy as np
|
| 147 |
+
# Resolve tensor names via open_source (handles GGUF + safetensors uniformly).
|
| 148 |
+
v_src = open_source(str(voice_path))
|
| 149 |
+
b_src = open_source(str(base_path))
|
| 150 |
+
v_names = [n for n in v_src.names() if n != "delta.voice.marker"]
|
| 151 |
+
b_names = [n for n in b_src.names() if n != "delta.voice.marker"]
|
| 152 |
+
if not v_names or not b_names:
|
| 153 |
+
_fail(" β No tensors found for delta.")
|
| 154 |
+
# Match by exact name: every voice tensor needs a same-named base tensor.
|
| 155 |
+
# Output head first for deterministic order, then voice order.
|
| 156 |
+
try:
|
| 157 |
+
arch, cfg = _source_arch(v_src)
|
| 158 |
+
_cand = _resolve_output_name(v_src, cfg, v_names)
|
| 159 |
+
except Exception:
|
| 160 |
+
_cand = None
|
| 161 |
+
ordered = ([_cand] if _cand in v_names else []) + [n for n in v_names if n != _cand]
|
| 162 |
+
missing = [n for n in ordered if n not in b_names]
|
| 163 |
+
if missing:
|
| 164 |
+
shown = ", ".join(missing[:5]) + ("β¦" if len(missing) > 5 else "")
|
| 165 |
+
_fail(f" β {len(missing)} voice tensor(s) have no match in base: {shown}. Delta needs same names on both sides.")
|
| 166 |
+
pairs = [(n, n) for n in ordered]
|
| 167 |
+
_check_delta_gate(args, voice_path, base_path)
|
| 168 |
+
_assert_not_symlink(delta_folder)
|
| 169 |
+
delta_folder.mkdir(parents=True, exist_ok=True)
|
| 170 |
+
try:
|
| 171 |
+
os.chmod(delta_folder, 0o700)
|
| 172 |
+
except Exception:
|
| 173 |
+
pass
|
| 174 |
+
out_path = delta_folder / "voice.safetensors"
|
| 175 |
+
tmp_out = delta_folder / "voice.safetensors.tmp"
|
| 176 |
+
# Header first (offsets need shapes only), then stream one pair at a
|
| 177 |
+
# time so packs never hold every tensor in RAM at once.
|
| 178 |
+
header, off = {}, 0
|
| 179 |
+
for vname, bname in pairs:
|
| 180 |
+
vref, bref = v_src.ref(vname), b_src.ref(bname)
|
| 181 |
+
if tuple(vref.shape) != tuple(bref.shape):
|
| 182 |
+
_fail(f" β Voice tensor '{vname}' shape {tuple(vref.shape)} doesn't fit base {tuple(bref.shape)}.")
|
| 183 |
+
nbytes = 2
|
| 184 |
+
for d in vref.shape:
|
| 185 |
+
nbytes *= int(d)
|
| 186 |
+
header[vname] = {"dtype": "F16", "shape": [int(x) for x in vref.shape], "data_offsets": [off, off + nbytes]}
|
| 187 |
+
off += nbytes
|
| 188 |
+
header["delta.voice.marker"] = {"dtype": "U8", "shape": [1], "data_offsets": [off, off + 1]}
|
| 189 |
+
header["__metadata__"] = {"type": "delta", "base": _public_source(str(base_path)), "voice": _public_source(str(voice_path))}
|
| 190 |
+
hj = json.dumps(header).encode("utf-8")
|
| 191 |
+
with open(tmp_out, "wb") as out:
|
| 192 |
+
out.write(struct.pack("<Q", len(hj)))
|
| 193 |
+
out.write(hj)
|
| 194 |
+
for vname, bname in pairs:
|
| 195 |
+
vref, bref = v_src.ref(vname), b_src.ref(bname)
|
| 196 |
+
_warn(f" Dequanting {vname} ({vref.dtype}) and base ({bref.dtype}) once to F32 for delta math (unavoidable for GGUF quant).")
|
| 197 |
+
elems = 1
|
| 198 |
+
for d in vref.shape:
|
| 199 |
+
elems *= int(d)
|
| 200 |
+
# Within a pair, decode/dequant in _DELTA_CHUNK_ELEMS slices so peak
|
| 201 |
+
# stays O(chunk): plain sides stream raw slices; quant sides stream
|
| 202 |
+
# block-aligned raw slices (GGML_QUANT_SIZES units decode independently).
|
| 203 |
+
from gguf.constants import GGMLQuantizationType as _Q
|
| 204 |
+
from gguf.constants import GGML_QUANT_SIZES as _QS
|
| 205 |
+
v_q = _is_ggml_quant(vref.dtype)
|
| 206 |
+
b_q = _is_ggml_quant(bref.dtype)
|
| 207 |
+
v_qt = _Q[vref.dtype] if v_q else None
|
| 208 |
+
b_qt = _Q[bref.dtype] if b_q else None
|
| 209 |
+
v_bpe = 1 if v_q else dtype_bytes(vref.dtype)
|
| 210 |
+
b_bpe = 1 if b_q else dtype_bytes(bref.dtype)
|
| 211 |
+
|
| 212 |
+
def _quant_slice(src, name, qt, at, n):
|
| 213 |
+
bs, ts = _QS[qt]
|
| 214 |
+
b0 = at // bs
|
| 215 |
+
b1 = (at + n + bs - 1) // bs
|
| 216 |
+
seg = src.read_raw_slice(name, b0 * ts, (b1 - b0) * ts)
|
| 217 |
+
return _facade._gguf_raw_slice_to_f32(seg, qt, at - b0 * bs, n)
|
| 218 |
+
|
| 219 |
+
done = 0
|
| 220 |
+
while done < elems:
|
| 221 |
+
cur = min(_facade._DELTA_CHUNK_ELEMS, elems - done)
|
| 222 |
+
if v_q:
|
| 223 |
+
v_f32 = _quant_slice(v_src, vname, v_qt, done, cur)
|
| 224 |
+
else:
|
| 225 |
+
v_f32 = decode_to_f32(v_src.read_raw_slice(vname, done * v_bpe, cur * v_bpe),
|
| 226 |
+
vref.dtype, (cur,))
|
| 227 |
+
if b_q:
|
| 228 |
+
b_f32 = _quant_slice(b_src, bname, b_qt, done, cur)
|
| 229 |
+
else:
|
| 230 |
+
b_f32 = decode_to_f32(b_src.read_raw_slice(bname, done * b_bpe, cur * b_bpe),
|
| 231 |
+
bref.dtype, (cur,))
|
| 232 |
+
out.write((v_f32.astype(np.float32) - b_f32.astype(np.float32)).astype("float16").tobytes())
|
| 233 |
+
done += cur
|
| 234 |
+
out.write(b"\x01")
|
| 235 |
+
out.flush()
|
| 236 |
+
try:
|
| 237 |
+
os.fsync(out.fileno())
|
| 238 |
+
except Exception:
|
| 239 |
+
pass
|
| 240 |
+
tmp_out.replace(out_path)
|
| 241 |
+
return out_path
|
| 242 |
+
# Original chunked safetensors-safetensors path below (no dequant beyond F32 math per chunk).
|
| 243 |
+
# Match by exact name: every voice tensor needs a same-named base tensor.
|
| 244 |
+
v_keys = [k for k in v_tensors.keys() if k != "delta.voice.marker"]
|
| 245 |
+
if not v_keys:
|
| 246 |
+
_fail(" β No tensors found for delta.")
|
| 247 |
+
missing = [k for k in v_keys if k not in b_tensors]
|
| 248 |
+
if missing:
|
| 249 |
+
shown = ", ".join(missing[:5]) + ("β¦" if len(missing) > 5 else "")
|
| 250 |
+
_fail(f" β {len(missing)} voice tensor(s) have no match in base: {shown}. Delta needs same names on both sides.")
|
| 251 |
+
pairs = [(k, k) for k in v_keys]
|
| 252 |
+
_check_delta_gate(args, voice_path, base_path)
|
| 253 |
+
with open(voice_path, "rb") as f:
|
| 254 |
+
v_hs = struct.unpack("<Q", f.read(8))[0]
|
| 255 |
+
with open(base_path, "rb") as f:
|
| 256 |
+
b_hs = struct.unpack("<Q", f.read(8))[0]
|
| 257 |
+
specs = []
|
| 258 |
+
header, off = {}, 0
|
| 259 |
+
for vname, bname in pairs:
|
| 260 |
+
vinfo, binfo = v_tensors[vname], b_tensors[bname]
|
| 261 |
+
vshape, bshape = tuple(vinfo["shape"]), tuple(binfo["shape"])
|
| 262 |
+
if vshape != bshape:
|
| 263 |
+
_fail(f" β Voice tensor '{vname}' shape {vshape} doesn't fit base {bshape}.")
|
| 264 |
+
elems = 1
|
| 265 |
+
for d in vshape:
|
| 266 |
+
elems *= int(d)
|
| 267 |
+
nbytes = elems * 2
|
| 268 |
+
header[vname] = {"dtype": "F16", "shape": list(vshape), "data_offsets": [off, off + nbytes]}
|
| 269 |
+
off += nbytes
|
| 270 |
+
specs.append({"name": vname, "vinfo": vinfo, "binfo": binfo,
|
| 271 |
+
"v_start": 8 + v_hs + vinfo["data_offsets"][0],
|
| 272 |
+
"b_start": 8 + b_hs + binfo["data_offsets"][0],
|
| 273 |
+
"v_bpe": dtype_bytes(vinfo["dtype"]), "b_bpe": dtype_bytes(binfo["dtype"]),
|
| 274 |
+
"elems": elems})
|
| 275 |
+
total_elems = off // 2
|
| 276 |
+
marker_name = "delta.voice.marker"
|
| 277 |
+
header[marker_name] = {"dtype": "U8", "shape": [1], "data_offsets": [off, off + 1]}
|
| 278 |
+
header["__metadata__"] = {"type": "delta", "base": _public_source(str(base_path)), "voice": _public_source(str(voice_path))}
|
| 279 |
+
hj = json.dumps(header).encode("utf-8")
|
| 280 |
+
_assert_not_symlink(delta_folder)
|
| 281 |
+
delta_folder.mkdir(parents=True, exist_ok=True)
|
| 282 |
+
try:
|
| 283 |
+
os.chmod(delta_folder, 0o700)
|
| 284 |
+
except Exception:
|
| 285 |
+
pass
|
| 286 |
+
out_path = delta_folder / "voice.safetensors"
|
| 287 |
+
tmp_out = delta_folder / "voice.safetensors.tmp"
|
| 288 |
+
chunk = 1 << 20
|
| 289 |
+
total_bytes = total_elems * 2
|
| 290 |
+
t0 = time.time()
|
| 291 |
+
last = [0.0]
|
| 292 |
+
def _delta_progress(force=False):
|
| 293 |
+
now = time.time()
|
| 294 |
+
if not force and now - last[0] < 0.25 and processed < total_elems:
|
| 295 |
+
return
|
| 296 |
+
last[0] = now
|
| 297 |
+
elapsed = now - t0 or 1e-6
|
| 298 |
+
mbps = (processed * 2) / elapsed / 1e6
|
| 299 |
+
pct = processed / total_elems * 100 if total_elems else 100
|
| 300 |
+
remain = total_bytes - processed * 2
|
| 301 |
+
eta = _fmt_eta(remain, mbps)
|
| 302 |
+
msg = f" β crafting delta {pct:3.0f}% Β· {processed*2/1e6:.0f}/{total_bytes/1e6:.0f} MB Β· {mbps:.1f} MB/s Β· {eta} "
|
| 303 |
+
sys.stdout.write(_transform(msg) + ("\n" if _progress_pref() else "\r"))
|
| 304 |
+
sys.stdout.flush()
|
| 305 |
+
with open(voice_path, "rb") as fv, open(base_path, "rb") as fb, open(tmp_out, "wb") as out:
|
| 306 |
+
out.write(struct.pack("<Q", len(hj)))
|
| 307 |
+
out.write(hj)
|
| 308 |
+
processed = 0
|
| 309 |
+
_delta_progress(force=True)
|
| 310 |
+
for spec in specs:
|
| 311 |
+
fv.seek(spec["v_start"])
|
| 312 |
+
fb.seek(spec["b_start"])
|
| 313 |
+
vinfo, binfo = spec["vinfo"], spec["binfo"]
|
| 314 |
+
v_bpe, b_bpe = spec["v_bpe"], spec["b_bpe"]
|
| 315 |
+
done = 0
|
| 316 |
+
while done < spec["elems"]:
|
| 317 |
+
cur = min(chunk, spec["elems"] - done)
|
| 318 |
+
v_raw = fv.read(cur * v_bpe)
|
| 319 |
+
b_raw = fb.read(cur * b_bpe)
|
| 320 |
+
if len(v_raw) != cur * v_bpe or len(b_raw) != cur * b_bpe:
|
| 321 |
+
sys.stdout.write("\n")
|
| 322 |
+
_fail(f" β Short read during delta compute ({spec['name']}).")
|
| 323 |
+
v_f32 = decode_to_f32(v_raw, vinfo["dtype"], (cur,))
|
| 324 |
+
b_f32 = decode_to_f32(b_raw, binfo["dtype"], (cur,))
|
| 325 |
+
delta_f16 = (v_f32 - b_f32).astype("float16")
|
| 326 |
+
out.write(delta_f16.tobytes())
|
| 327 |
+
done += cur
|
| 328 |
+
processed += cur
|
| 329 |
+
_delta_progress()
|
| 330 |
+
_delta_progress(force=True)
|
| 331 |
+
sys.stdout.write("\n")
|
| 332 |
+
# marker byte for auto-detect (delta.voice.marker)
|
| 333 |
+
out.write(b"\x01")
|
| 334 |
+
out.flush()
|
| 335 |
+
try:
|
| 336 |
+
os.fsync(out.fileno())
|
| 337 |
+
except Exception:
|
| 338 |
+
pass
|
| 339 |
+
tmp_out.replace(out_path)
|
| 340 |
+
return out_path
|
vlib/fetch.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fetching tensors from sources into local bytes."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
from vlib import ctx
|
| 7 |
+
from vlib.ui import _fail, _now_iso, _step, _warn
|
| 8 |
+
from vlib.net import _cleanup_tmp, _safe_id, download_range
|
| 9 |
+
from vlib.tensors import _is_ggml_quant, _is_quant_fetched, _write_safetensors_streaming, _write_single_tensor_gguf, encode_from_f32, write_safetensors
|
| 10 |
+
from vlib.sources import _hf_config, _public_source, detect_output_tensor, largest_2d
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _source_arch(source):
|
| 14 |
+
if source.kind == "safetensors":
|
| 15 |
+
cfg = _hf_config(source.repo) if source.repo else None
|
| 16 |
+
if cfg:
|
| 17 |
+
arch = (cfg.get("architectures") or [None])[0] or cfg.get("model_type")
|
| 18 |
+
else:
|
| 19 |
+
arch = None
|
| 20 |
+
return arch, cfg
|
| 21 |
+
source._load()
|
| 22 |
+
if getattr(source, "_reader", None) is not None:
|
| 23 |
+
for f in source._reader.fields.values():
|
| 24 |
+
if f.name == "general.architecture":
|
| 25 |
+
try:
|
| 26 |
+
v = f.parts[f.data[0]]
|
| 27 |
+
return (v.decode() if isinstance(v, bytes) else str(v)), None
|
| 28 |
+
except Exception:
|
| 29 |
+
return None, None
|
| 30 |
+
return (source._remote[0] if source._remote else None), None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _resolve_output_name(source, config, names):
|
| 34 |
+
if source.kind == "gguf":
|
| 35 |
+
for n in ("output.weight", "token_embd.weight"):
|
| 36 |
+
if n in names:
|
| 37 |
+
return n
|
| 38 |
+
n = detect_output_tensor(names, config)
|
| 39 |
+
if n:
|
| 40 |
+
return n
|
| 41 |
+
else:
|
| 42 |
+
n = detect_output_tensor(names, config)
|
| 43 |
+
if n:
|
| 44 |
+
return n
|
| 45 |
+
shapes = {n: source.ref(n).shape for n in names}
|
| 46 |
+
return largest_2d(names, shapes)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _stream_safetensors_tensor(source, name, dest_path):
|
| 50 |
+
"""Stream a safetensors tensor's raw bytes to dest_path. Returns byte count."""
|
| 51 |
+
if source.path:
|
| 52 |
+
hs, tensors = source._local()
|
| 53 |
+
begin, end = tensors[name]["data_offsets"]
|
| 54 |
+
with open(source.path, "rb") as src, open(dest_path, "wb") as out:
|
| 55 |
+
src.seek(8 + hs + begin)
|
| 56 |
+
remaining = end - begin
|
| 57 |
+
while remaining:
|
| 58 |
+
b = src.read(min(8 << 20, remaining))
|
| 59 |
+
if not b:
|
| 60 |
+
break
|
| 61 |
+
out.write(b)
|
| 62 |
+
remaining -= len(b)
|
| 63 |
+
return end - begin
|
| 64 |
+
url, hs, tensors = source._shard_info(name)
|
| 65 |
+
begin, end = tensors[name]["data_offsets"]
|
| 66 |
+
import shutil
|
| 67 |
+
combined = download_range(url, 8 + hs + begin, 8 + hs + end - 1, dest_path.parent, None, label=name)
|
| 68 |
+
shutil.move(str(combined), str(dest_path))
|
| 69 |
+
return end - begin
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _fetch_tensor(source, name, tmp_dir):
|
| 73 |
+
"""Return (kind, raw_path, data, dtype, shape) for one tensor.
|
| 74 |
+
kind == 'file' β raw bytes at raw_path; kind == 'bytes' β data holds bytes.
|
| 75 |
+
GGML-quantized tensors keep their exact raw blocks (no requant)."""
|
| 76 |
+
ref = source.ref(name)
|
| 77 |
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
| 78 |
+
if source.kind == "safetensors":
|
| 79 |
+
raw_path = tmp_dir / "raw.bin"
|
| 80 |
+
n = _stream_safetensors_tensor(source, name, raw_path)
|
| 81 |
+
return ("file", raw_path, n, ref.dtype, ref.shape)
|
| 82 |
+
if _is_ggml_quant(ref.dtype):
|
| 83 |
+
# passthrough: exact quant blocks, stored in a single-tensor GGUF artifact
|
| 84 |
+
raw_path = tmp_dir / "raw.bin"
|
| 85 |
+
n = _stream_gguf_tensor(source, name, raw_path)
|
| 86 |
+
return ("file", raw_path, n, ref.dtype, ref.shape)
|
| 87 |
+
f32 = source.read_f32(name)
|
| 88 |
+
# Preserve original precision family: BF16 stays BF16 (not F16 downconvert),
|
| 89 |
+
# F32 stays F32, F16 stays F16. BF16->F32->BF16 round-trip is exact.
|
| 90 |
+
_rd = ref.dtype.upper()
|
| 91 |
+
if _rd in ("F32", "FP32"):
|
| 92 |
+
dtype = "F32"
|
| 93 |
+
elif _rd in ("BF16", "BF16E8M"):
|
| 94 |
+
dtype = "BF16"
|
| 95 |
+
else:
|
| 96 |
+
dtype = "F16"
|
| 97 |
+
return ("bytes", None, encode_from_f32(f32, dtype), dtype, ref.shape)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _stream_gguf_tensor(source, name, dest_path):
|
| 101 |
+
"""Stream a GGUF tensor's exact raw blocks to dest_path. Returns byte count."""
|
| 102 |
+
import shutil
|
| 103 |
+
import numpy as np
|
| 104 |
+
if source.path:
|
| 105 |
+
source._load()
|
| 106 |
+
for t in source._reader.tensors:
|
| 107 |
+
if t.name == name:
|
| 108 |
+
with open(dest_path, "wb") as out:
|
| 109 |
+
raw = t.data.tobytes() if t.data.dtype == np.uint8 else memoryview(t.data).cast("B").tobytes()
|
| 110 |
+
out.write(raw)
|
| 111 |
+
return t.n_bytes
|
| 112 |
+
raise KeyError(name)
|
| 113 |
+
dims, gtype, data_offset, n_bytes = source._remote[2][name]
|
| 114 |
+
start = source._remote[1] + data_offset
|
| 115 |
+
combined = download_range(source.url, start, start + n_bytes - 1, dest_path.parent, None, label=name)
|
| 116 |
+
shutil.move(str(combined), str(dest_path))
|
| 117 |
+
return n_bytes
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _write_fetched(fetched, tensor_name, out_path):
|
| 121 |
+
"""Write a fetched tensor to a standalone artifact (gguf when quantized)."""
|
| 122 |
+
kind, raw_path, data, dtype, shape = fetched
|
| 123 |
+
if _is_quant_fetched(fetched):
|
| 124 |
+
if not str(out_path).endswith(".gguf"):
|
| 125 |
+
out_path = str(out_path)[:-len(".safetensors")] + ".gguf" if str(out_path).endswith(".safetensors") else str(out_path) + ".gguf"
|
| 126 |
+
_write_single_tensor_gguf(tensor_name, raw_path, raw_path.stat().st_size, dtype, shape, str(out_path))
|
| 127 |
+
return Path(out_path)
|
| 128 |
+
tmp = str(out_path) + ".tmp"
|
| 129 |
+
if kind == "file":
|
| 130 |
+
_write_safetensors_streaming(tensor_name, str(raw_path), raw_path.stat().st_size, dtype, shape, tmp)
|
| 131 |
+
else:
|
| 132 |
+
write_safetensors({tensor_name: (data, dtype, shape)}, tmp)
|
| 133 |
+
os.replace(tmp, str(out_path))
|
| 134 |
+
return Path(out_path)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _write_voicepack_json(out_path, source_id, pack):
|
| 138 |
+
"""Provenance sidecar for a voicepack: source + per-tensor names/shapes/dtypes.
|
| 139 |
+
Written next to the pack file (same stem, .json suffix)."""
|
| 140 |
+
meta = {
|
| 141 |
+
"source": _public_source(source_id),
|
| 142 |
+
"tensors": [{"name": n, "shape": [int(x) for x in v[2]], "dtype": v[1]}
|
| 143 |
+
for n, v in pack.items()],
|
| 144 |
+
"downloaded_at": _now_iso(),
|
| 145 |
+
}
|
| 146 |
+
jpath = Path(str(out_path)).with_suffix(".json")
|
| 147 |
+
jtmp = jpath.parent / (jpath.name + ".tmp")
|
| 148 |
+
jtmp.write_text(json.dumps(meta, indent=2) + "\n")
|
| 149 |
+
jtmp.replace(jpath)
|
| 150 |
+
return jpath
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _fetch_pack(source, source_id, resolved):
|
| 154 |
+
"""Fetch each resolved tensor's raw bytes. Returns pack {name: (bytes, dtype, shape)}.
|
| 155 |
+
Shared by get --multi and pack: always safetensors (GGUF quants dequant once with a warning)."""
|
| 156 |
+
pack = {}
|
| 157 |
+
for tname in resolved:
|
| 158 |
+
ref = source.ref(tname)
|
| 159 |
+
_step(f"Extracting {tname} ({ref.dtype} {tuple(ref.shape)})β¦")
|
| 160 |
+
tmp_dir = ctx.VOICES_DIR / ".parts" / _safe_id(source_id, tname)
|
| 161 |
+
try:
|
| 162 |
+
fetched = _fetch_tensor(source, tname, tmp_dir)
|
| 163 |
+
except Exception as e:
|
| 164 |
+
_fail(f" β Could not extract tensor {tname}: {e}")
|
| 165 |
+
kind, raw_path, data, dtype, shape = fetched
|
| 166 |
+
try:
|
| 167 |
+
if kind == "file":
|
| 168 |
+
# safetensors raw file: read bytes directly (preserves BF16/F16/F32).
|
| 169 |
+
with open(str(raw_path), "rb") as f:
|
| 170 |
+
b = f.read()
|
| 171 |
+
pack[tname] = (b, dtype, tuple(shape))
|
| 172 |
+
else:
|
| 173 |
+
# bytes already encoded (F32/F16/BF16 preserved).
|
| 174 |
+
pack[tname] = (data, dtype, tuple(shape))
|
| 175 |
+
# If fetched was GGUF quant passthrough, _fetch_tensor returns
|
| 176 |
+
# ("file", raw, n, qtype, shape) with quant dtype β dequant once for safetensors pack.
|
| 177 |
+
if _is_ggml_quant(dtype):
|
| 178 |
+
_warn(f" {tname} is {dtype} quant β dequanting once to F32 for voicepack.safetensors (unavoidable, safetensors has no quant).")
|
| 179 |
+
try:
|
| 180 |
+
f32 = source.read_f32(tname)
|
| 181 |
+
pack[tname] = (f32.astype("float32").tobytes(), "F32", tuple(shape))
|
| 182 |
+
except Exception as e:
|
| 183 |
+
_fail(f" β Could not dequant {tname} for pack: {e}")
|
| 184 |
+
finally:
|
| 185 |
+
_cleanup_tmp(tmp_dir)
|
| 186 |
+
if not pack:
|
| 187 |
+
_fail(" β Nothing to pack.")
|
| 188 |
+
return pack
|
vlib/net.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP, auth, retries, and ranged downloads."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import sys
|
| 8 |
+
import time
|
| 9 |
+
import urllib.error
|
| 10 |
+
import urllib.request
|
| 11 |
+
from urllib.parse import urlparse
|
| 12 |
+
from vlib import ctx
|
| 13 |
+
from vlib.ui import _fail, _note, _progress_pref, _say, _transform
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
_HF_TOKEN_CACHE = {"tok": None, "ts": 0.0}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _get_hf_token():
|
| 20 |
+
# cache 60s to avoid file thrash on hot path
|
| 21 |
+
now = time.time()
|
| 22 |
+
if now - _HF_TOKEN_CACHE["ts"] < 60 and _HF_TOKEN_CACHE["tok"] is not None:
|
| 23 |
+
return _HF_TOKEN_CACHE["tok"]
|
| 24 |
+
tok = None
|
| 25 |
+
for k in ("HF_TOKEN", "HF_HUB_TOKEN", "HUGGING_FACE_HUB_TOKEN"):
|
| 26 |
+
v = os.environ.get(k)
|
| 27 |
+
if v and v.strip().startswith("hf_"):
|
| 28 |
+
tok = v.strip()
|
| 29 |
+
break
|
| 30 |
+
if tok is None:
|
| 31 |
+
for p in (ctx.HF_TOKEN_PATH, Path.home() / ".cache" / "huggingface" / "token", Path.home() / ".huggingface" / "token"):
|
| 32 |
+
try:
|
| 33 |
+
if p.exists():
|
| 34 |
+
t = p.read_text().strip()
|
| 35 |
+
if t.startswith("hf_"):
|
| 36 |
+
tok = t
|
| 37 |
+
break
|
| 38 |
+
except Exception:
|
| 39 |
+
continue
|
| 40 |
+
_HF_TOKEN_CACHE["tok"] = tok
|
| 41 |
+
_HF_TOKEN_CACHE["ts"] = now
|
| 42 |
+
return tok
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
_HF_TRUSTED_HOSTS = frozenset({"huggingface.co", "cdn-lfs.huggingface.co", "cdn-lfs-us-1.huggingface.co"})
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
from functools import lru_cache
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@lru_cache(maxsize=256)
|
| 52 |
+
def _is_hf_url(url):
|
| 53 |
+
try:
|
| 54 |
+
host = (urlparse(url).hostname or "").lower()
|
| 55 |
+
except Exception:
|
| 56 |
+
return False
|
| 57 |
+
if host in _HF_TRUSTED_HOSTS:
|
| 58 |
+
return True
|
| 59 |
+
return host == "huggingface.co" or host.endswith(".huggingface.co")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _hf_auth_headers(url, extra=None):
|
| 63 |
+
"""Auth + UA. Never sends Authorization to non-HF hosts; never overwrites caller header for HF either."""
|
| 64 |
+
h = {"User-Agent": "voice/2.0"}
|
| 65 |
+
if extra:
|
| 66 |
+
# Strip caller Authorization if target is not HF - prevents token leak to arbitrary URL
|
| 67 |
+
if not _is_hf_url(url) and "Authorization" in extra:
|
| 68 |
+
extra = {k: v for k, v in extra.items() if k != "Authorization"}
|
| 69 |
+
h.update(extra)
|
| 70 |
+
if "Authorization" not in h:
|
| 71 |
+
tok = _get_hf_token()
|
| 72 |
+
if tok and _is_hf_url(url):
|
| 73 |
+
h["Authorization"] = f"Bearer {tok}"
|
| 74 |
+
# If caller supplied Authorization for non-HF, drop it
|
| 75 |
+
if "Authorization" in h and not _is_hf_url(url):
|
| 76 |
+
del h["Authorization"]
|
| 77 |
+
return h
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class _PreserveAuthRedirectHandler(urllib.request.HTTPRedirectHandler):
|
| 81 |
+
def redirect_request(self, req, fp, code, msg, hdrs, newurl):
|
| 82 |
+
result = super().redirect_request(req, fp, code, msg, hdrs, newurl)
|
| 83 |
+
if result is None:
|
| 84 |
+
return None
|
| 85 |
+
if _is_hf_url(newurl):
|
| 86 |
+
if "Authorization" not in result.headers:
|
| 87 |
+
tok = _get_hf_token()
|
| 88 |
+
if tok:
|
| 89 |
+
result.add_header("Authorization", f"Bearer {tok}")
|
| 90 |
+
else:
|
| 91 |
+
# stdlib forwards the original headers across hosts: strip the token
|
| 92 |
+
# (bridge/CDN URLs are pre-signed, so it is never needed there either)
|
| 93 |
+
try:
|
| 94 |
+
result.remove_header("Authorization")
|
| 95 |
+
except Exception:
|
| 96 |
+
pass
|
| 97 |
+
return result
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
urllib.request.install_opener(urllib.request.build_opener(_PreserveAuthRedirectHandler()))
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _parse_retry_after(headers):
|
| 104 |
+
try:
|
| 105 |
+
v = headers.get("Retry-After") or headers.get("retry-after")
|
| 106 |
+
return int(str(v).split(",")[0].strip()) if v is not None else None
|
| 107 |
+
except Exception:
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _bounded_retry_after(headers, fallback, cap=30):
|
| 112 |
+
"""Retry-After with a cap: a 429 must never look like a hang (e.g. Retry-After: 3600)."""
|
| 113 |
+
try:
|
| 114 |
+
ra = _parse_retry_after(headers)
|
| 115 |
+
wait = ra if ra is not None else fallback
|
| 116 |
+
except Exception:
|
| 117 |
+
wait = fallback
|
| 118 |
+
try:
|
| 119 |
+
wait = float(wait)
|
| 120 |
+
except Exception:
|
| 121 |
+
wait = float(fallback)
|
| 122 |
+
if wait < 0:
|
| 123 |
+
wait = float(fallback)
|
| 124 |
+
return min(wait, cap)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _timeout(base):
|
| 128 |
+
try:
|
| 129 |
+
env = int(os.environ.get("HF_HUB_DOWNLOAD_TIMEOUT", "0"))
|
| 130 |
+
return max(base, env) if env > 0 else base
|
| 131 |
+
except Exception:
|
| 132 |
+
return base
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def http_range(url, start, end, timeout=60):
|
| 136 |
+
for attempt in range(3):
|
| 137 |
+
hdrs = _hf_auth_headers(url, {"Range": f"bytes={start}-{end}"})
|
| 138 |
+
try:
|
| 139 |
+
with urllib.request.urlopen(urllib.request.Request(url, headers=hdrs), timeout=_timeout(timeout)) as resp:
|
| 140 |
+
if resp.status == 429 and attempt < 2:
|
| 141 |
+
wait = _bounded_retry_after(resp.headers, 1.5 * (2 ** attempt))
|
| 142 |
+
if wait >= 5:
|
| 143 |
+
_note(f" Rate limited (429) β waiting {wait:.0f}s before retryβ¦")
|
| 144 |
+
time.sleep(wait)
|
| 145 |
+
continue
|
| 146 |
+
if resp.status not in (200, 206):
|
| 147 |
+
raise urllib.error.HTTPError(url, resp.status, f"status {resp.status}", resp.headers, None)
|
| 148 |
+
return resp.read(), resp.status, resp.headers.get("ETag")
|
| 149 |
+
except urllib.error.HTTPError as e:
|
| 150 |
+
if e.code == 429 and attempt < 2:
|
| 151 |
+
wait = _bounded_retry_after(getattr(e, "headers", None) or {}, 1.5 * (2 ** attempt))
|
| 152 |
+
if wait >= 5:
|
| 153 |
+
_note(f" Rate limited (429) β waiting {wait:.0f}s before retryβ¦")
|
| 154 |
+
time.sleep(wait)
|
| 155 |
+
continue
|
| 156 |
+
raise
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def http_get_json(url, timeout=30):
|
| 160 |
+
req = urllib.request.Request(url, headers=_hf_auth_headers(url))
|
| 161 |
+
try:
|
| 162 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 163 |
+
return json.loads(resp.read())
|
| 164 |
+
except urllib.error.HTTPError as e:
|
| 165 |
+
try:
|
| 166 |
+
e.close() # else the response fp dangles until GC (ResourceWarning)
|
| 167 |
+
except Exception:
|
| 168 |
+
pass
|
| 169 |
+
raise
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _safe_id(model_id, shard_url=""):
|
| 173 |
+
mid = re.sub(r"[^a-z0-9._-]", "_", model_id.strip().lower().replace("..", "_").replace("/", "__").replace("\\", "_").replace(":", "_"))
|
| 174 |
+
mid = re.sub(r"_+", "_", mid).strip("_")[:120]
|
| 175 |
+
if shard_url:
|
| 176 |
+
shard = re.sub(r"[^a-z0-9._-]", "_", shard_url.split("/")[-1].replace(".safetensors", "").replace(".gguf", "")).lower()[:40]
|
| 177 |
+
return f"{mid}__{shard}"
|
| 178 |
+
return mid
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _acquire_parts_lock(part_dir):
|
| 182 |
+
"""Best-effort cross-process lock for .parts. Returns lock path or None."""
|
| 183 |
+
lock = part_dir / ".lock"
|
| 184 |
+
try:
|
| 185 |
+
# O_EXCL to avoid double acquisition
|
| 186 |
+
fd = os.open(str(lock), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
| 187 |
+
try:
|
| 188 |
+
os.write(fd, str(os.getpid()).encode())
|
| 189 |
+
finally:
|
| 190 |
+
os.close(fd)
|
| 191 |
+
return lock
|
| 192 |
+
except FileExistsError:
|
| 193 |
+
try:
|
| 194 |
+
# stale lock (>10 min) - reclaim
|
| 195 |
+
if time.time() - lock.stat().st_mtime > 600:
|
| 196 |
+
lock.unlink(missing_ok=True)
|
| 197 |
+
fd = os.open(str(lock), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
| 198 |
+
os.close(fd)
|
| 199 |
+
return lock
|
| 200 |
+
except Exception:
|
| 201 |
+
pass
|
| 202 |
+
return None
|
| 203 |
+
except Exception:
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def download_range(url, abs_start, abs_end, part_dir, etag, label="", n_parts=None):
|
| 208 |
+
import threading
|
| 209 |
+
|
| 210 |
+
total = abs_end - abs_start + 1
|
| 211 |
+
part_dir.mkdir(parents=True, exist_ok=True)
|
| 212 |
+
try:
|
| 213 |
+
os.chmod(part_dir.parent, 0o700)
|
| 214 |
+
except Exception:
|
| 215 |
+
pass
|
| 216 |
+
meta_path = part_dir / "meta.json"
|
| 217 |
+
|
| 218 |
+
old_etag = None
|
| 219 |
+
if meta_path.exists():
|
| 220 |
+
try:
|
| 221 |
+
old_etag = json.loads(meta_path.read_text()).get("etag")
|
| 222 |
+
except Exception:
|
| 223 |
+
old_etag = None
|
| 224 |
+
# acquire lock BEFORE writing meta so racers can't interleave etag/url
|
| 225 |
+
_parts_lock = _acquire_parts_lock(part_dir)
|
| 226 |
+
try:
|
| 227 |
+
meta_path.write_text(json.dumps({"url": url, "abs_start": abs_start, "total": total, "etag": etag}, indent=2))
|
| 228 |
+
except Exception:
|
| 229 |
+
pass
|
| 230 |
+
|
| 231 |
+
try:
|
| 232 |
+
import shutil
|
| 233 |
+
free = shutil.disk_usage(part_dir).free
|
| 234 |
+
if free < int(total * 1.25):
|
| 235 |
+
_fail(f" β Not enough disk space: need {total*1.25/1e9:.1f} GB, free {free/1e9:.1f} GB.")
|
| 236 |
+
except SystemExit:
|
| 237 |
+
raise
|
| 238 |
+
except Exception:
|
| 239 |
+
pass
|
| 240 |
+
|
| 241 |
+
if n_parts is None:
|
| 242 |
+
n_parts = 8 if total > 512 * 1024 * 1024 else (4 if total > 128 * 1024 * 1024 else 1)
|
| 243 |
+
chunk_total = total // n_parts
|
| 244 |
+
ranges = []
|
| 245 |
+
for i in range(n_parts):
|
| 246 |
+
s = i * chunk_total
|
| 247 |
+
e = (s + chunk_total - 1) if i < n_parts - 1 else total - 1
|
| 248 |
+
ranges.append((abs_start + s, abs_start + e, part_dir / f"chunk_{i:03d}.part", e - s + 1))
|
| 249 |
+
|
| 250 |
+
downloaded = 0
|
| 251 |
+
for _rs, _re, p, csz in ranges:
|
| 252 |
+
if p.exists():
|
| 253 |
+
try:
|
| 254 |
+
sz = p.stat().st_size
|
| 255 |
+
# If ETag changed (or one side unknown), invalidate stale chunk
|
| 256 |
+
if old_etag != etag and (old_etag is not None or etag is not None):
|
| 257 |
+
p.unlink(missing_ok=True)
|
| 258 |
+
continue
|
| 259 |
+
if sz > csz:
|
| 260 |
+
p.unlink(missing_ok=True)
|
| 261 |
+
continue
|
| 262 |
+
downloaded += sz
|
| 263 |
+
except Exception:
|
| 264 |
+
pass
|
| 265 |
+
|
| 266 |
+
t0 = time.time()
|
| 267 |
+
lock = threading.RLock() # reentrant: progress() also takes it, and runs inside locked regions
|
| 268 |
+
stop = threading.Event() # set on fatal error / interrupt: workers wind down, partials kept
|
| 269 |
+
errors = []
|
| 270 |
+
last = [0.0]
|
| 271 |
+
|
| 272 |
+
def progress(force=False):
|
| 273 |
+
now = time.time()
|
| 274 |
+
with lock:
|
| 275 |
+
cur_downloaded = downloaded
|
| 276 |
+
if not force and now - last[0] < 0.25 and cur_downloaded < total:
|
| 277 |
+
return
|
| 278 |
+
last[0] = now
|
| 279 |
+
elapsed = now - t0 or 1e-6
|
| 280 |
+
mbps = cur_downloaded / elapsed / 1e6
|
| 281 |
+
pct = cur_downloaded / total * 100 if total else 100
|
| 282 |
+
remain = total - cur_downloaded
|
| 283 |
+
eta = _fmt_eta(remain, mbps)
|
| 284 |
+
msg = f" β {label} {pct:3.0f}% Β· {cur_downloaded/1e6:.0f}/{total/1e6:.0f} MB Β· {mbps:.1f} MB/s Β· {eta} "
|
| 285 |
+
sys.stdout.write(_transform(msg) + ("\n" if _progress_pref() else "\r"))
|
| 286 |
+
sys.stdout.flush()
|
| 287 |
+
|
| 288 |
+
# show 0% immediately so user sees waiting + speed
|
| 289 |
+
progress(force=True)
|
| 290 |
+
|
| 291 |
+
def chunk(r_start, r_end, p, csz):
|
| 292 |
+
nonlocal downloaded
|
| 293 |
+
existing = p.stat().st_size if p.exists() else 0
|
| 294 |
+
if existing == csz:
|
| 295 |
+
return # already complete (counted in pre-scan); re-requesting would 416
|
| 296 |
+
eff = r_start + existing
|
| 297 |
+
for attempt in range(3):
|
| 298 |
+
if stop.is_set():
|
| 299 |
+
return
|
| 300 |
+
try:
|
| 301 |
+
hdrs = _hf_auth_headers(url, {"Range": f"bytes={eff}-{r_end}"})
|
| 302 |
+
if etag:
|
| 303 |
+
hdrs["If-Match"] = etag
|
| 304 |
+
with urllib.request.urlopen(urllib.request.Request(url, headers=hdrs), timeout=_timeout(120)) as resp:
|
| 305 |
+
if resp.status not in (200, 206):
|
| 306 |
+
raise urllib.error.HTTPError(url, resp.status, "bad status", None, None)
|
| 307 |
+
if resp.status == 200 and (r_end - r_start + 1) != total:
|
| 308 |
+
# multi-part chunk but server ignored Range: body is wrong data β fail fast
|
| 309 |
+
_fail(f" β Server ignored the Range request for {label or url} (got 200, need 206). Resume unsupported β try again later.")
|
| 310 |
+
if existing and resp.status == 200:
|
| 311 |
+
# server ignored Range - we already counted existing in downloaded, correct it
|
| 312 |
+
with lock:
|
| 313 |
+
downloaded -= existing
|
| 314 |
+
p.unlink(missing_ok=True)
|
| 315 |
+
existing = 0
|
| 316 |
+
eff = r_start
|
| 317 |
+
mode = "ab" if existing else "wb"
|
| 318 |
+
with open(p, mode) as f:
|
| 319 |
+
while not stop.is_set():
|
| 320 |
+
b = resp.read(8 << 20)
|
| 321 |
+
if not b:
|
| 322 |
+
break
|
| 323 |
+
f.write(b)
|
| 324 |
+
with lock:
|
| 325 |
+
downloaded += len(b)
|
| 326 |
+
progress()
|
| 327 |
+
f.flush()
|
| 328 |
+
try:
|
| 329 |
+
os.fsync(f.fileno())
|
| 330 |
+
except Exception:
|
| 331 |
+
pass
|
| 332 |
+
final = p.stat().st_size if p.exists() else 0
|
| 333 |
+
if final != csz and attempt < 2:
|
| 334 |
+
existing = final
|
| 335 |
+
eff = r_start + final
|
| 336 |
+
time.sleep(0.5 * (attempt + 1))
|
| 337 |
+
continue
|
| 338 |
+
if final != csz:
|
| 339 |
+
raise OSError(f"{p.name} incomplete {final}/{csz}")
|
| 340 |
+
return
|
| 341 |
+
except urllib.error.HTTPError as e:
|
| 342 |
+
if attempt < 2:
|
| 343 |
+
wait = _bounded_retry_after(getattr(e, "headers", None) or {}, 0.7 * (2 ** attempt))
|
| 344 |
+
if wait >= 5:
|
| 345 |
+
_note(f" Rate limited ({e.code}) β waiting {wait:.0f}s before retryβ¦")
|
| 346 |
+
time.sleep(wait)
|
| 347 |
+
if p.exists():
|
| 348 |
+
existing = p.stat().st_size
|
| 349 |
+
eff = r_start + existing
|
| 350 |
+
continue
|
| 351 |
+
raise
|
| 352 |
+
except (urllib.error.URLError, TimeoutError, OSError):
|
| 353 |
+
if attempt < 2:
|
| 354 |
+
time.sleep(0.7 * (2 ** attempt))
|
| 355 |
+
if p.exists():
|
| 356 |
+
existing = p.stat().st_size
|
| 357 |
+
eff = r_start + existing
|
| 358 |
+
continue
|
| 359 |
+
raise
|
| 360 |
+
raise OSError(f"{p.name} failed after 3 retries")
|
| 361 |
+
|
| 362 |
+
def worker(rs, re_, p, csz):
|
| 363 |
+
try:
|
| 364 |
+
chunk(rs, re_, p, csz)
|
| 365 |
+
except SystemExit as e:
|
| 366 |
+
errors.append(e) # _fail() already printed the reason
|
| 367 |
+
stop.set()
|
| 368 |
+
except Exception as e: # noqa: BLE001 β transported to main thread below
|
| 369 |
+
errors.append(e)
|
| 370 |
+
stop.set()
|
| 371 |
+
|
| 372 |
+
try:
|
| 373 |
+
if n_parts == 1:
|
| 374 |
+
rs, re_, p, csz = ranges[0]
|
| 375 |
+
chunk(rs, re_, p, csz)
|
| 376 |
+
else:
|
| 377 |
+
# daemon threads: Ctrl-C returns instantly, partial chunks kept for resume
|
| 378 |
+
threads = [threading.Thread(target=worker, args=(rs, re_, p, csz), daemon=True)
|
| 379 |
+
for rs, re_, p, csz in ranges]
|
| 380 |
+
for t in threads:
|
| 381 |
+
t.start()
|
| 382 |
+
while any(t.is_alive() for t in threads):
|
| 383 |
+
for t in threads:
|
| 384 |
+
t.join(timeout=0.25)
|
| 385 |
+
if stop.is_set():
|
| 386 |
+
break
|
| 387 |
+
if stop.is_set():
|
| 388 |
+
break
|
| 389 |
+
if errors:
|
| 390 |
+
raise errors[0]
|
| 391 |
+
except KeyboardInterrupt:
|
| 392 |
+
stop.set()
|
| 393 |
+
sys.stdout.write("\n")
|
| 394 |
+
_fail(f" β Download cancelled ({downloaded/1e6:.0f}/{total/1e6:.0f} MB kept). Run again to resume.")
|
| 395 |
+
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError):
|
| 396 |
+
sys.stdout.write("\n")
|
| 397 |
+
_fail(f" β Download interrupted ({downloaded/1e6:.0f}/{total/1e6:.0f} MB kept). Run again to resume.")
|
| 398 |
+
except Exception as e:
|
| 399 |
+
sys.stdout.write("\n")
|
| 400 |
+
if ctx.VERBOSE:
|
| 401 |
+
import traceback
|
| 402 |
+
_say(traceback.format_exc())
|
| 403 |
+
_fail(f" β Download failed: {e}")
|
| 404 |
+
|
| 405 |
+
# ensure 100% with speed is shown before combining
|
| 406 |
+
progress(force=True)
|
| 407 |
+
for _rs, _re, p, csz in ranges:
|
| 408 |
+
try:
|
| 409 |
+
if p.stat().st_size != csz:
|
| 410 |
+
_fail(f" β Incomplete download ({p.name}). Run again to resume.")
|
| 411 |
+
except Exception:
|
| 412 |
+
_fail(f" β Missing chunk {p.name}. Run again to resume.")
|
| 413 |
+
combined = part_dir / "combined.part"
|
| 414 |
+
with open(combined, "wb") as out:
|
| 415 |
+
for _rs, _re, p, _csz in ranges:
|
| 416 |
+
with open(p, "rb") as inp:
|
| 417 |
+
while True:
|
| 418 |
+
b = inp.read(8 << 20)
|
| 419 |
+
if not b:
|
| 420 |
+
break
|
| 421 |
+
out.write(b)
|
| 422 |
+
out.flush()
|
| 423 |
+
try:
|
| 424 |
+
os.fsync(out.fileno())
|
| 425 |
+
except Exception:
|
| 426 |
+
pass
|
| 427 |
+
# clean up lock
|
| 428 |
+
try:
|
| 429 |
+
if '_parts_lock' in locals() and _parts_lock and _parts_lock.exists():
|
| 430 |
+
_parts_lock.unlink(missing_ok=True)
|
| 431 |
+
except Exception:
|
| 432 |
+
pass
|
| 433 |
+
sys.stdout.write("\n")
|
| 434 |
+
sys.stdout.flush()
|
| 435 |
+
return combined
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def _fmt_eta(remaining, mbps):
|
| 439 |
+
if mbps < 1e-6:
|
| 440 |
+
return "--:--"
|
| 441 |
+
sec = int(remaining / (mbps * 1e6))
|
| 442 |
+
return f"{sec}s" if sec < 60 else f"{sec//60}m {sec%60}s"
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def _cleanup_tmp(tmp_dir):
|
| 446 |
+
try:
|
| 447 |
+
if tmp_dir.exists():
|
| 448 |
+
import shutil
|
| 449 |
+
shutil.rmtree(tmp_dir)
|
| 450 |
+
except Exception:
|
| 451 |
+
pass
|
vlib/pack.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Head-only voice-pack preset (unused by the CLI)."""
|
| 2 |
+
import argparse
|
| 3 |
+
import json
|
| 4 |
+
import urllib.error
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from vlib import ctx
|
| 8 |
+
from vlib.fetch import _fetch_pack, _resolve_output_name, _source_arch
|
| 9 |
+
from vlib.sources import (_family, _hf_config, _public_source,
|
| 10 |
+
_slugify_voice_name, open_source)
|
| 11 |
+
from vlib.tensors import write_safetensors
|
| 12 |
+
from vlib.ui import _fail, _note, _now_iso, _say, _step
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _resolve_pack_tensors(source, repo, config, names):
|
| 16 |
+
"""Head-only voice-pack preset. Embed/norm/blocks stay manual
|
| 17 |
+
(voice get <repo> <tensors...>).
|
| 18 |
+
Config is the source config with _hf_config fallback. Miss fails loud."""
|
| 19 |
+
if config is None and getattr(source, "repo", None):
|
| 20 |
+
config = _hf_config(source.repo)
|
| 21 |
+
head = _resolve_output_name(source, config, names)
|
| 22 |
+
if head is None:
|
| 23 |
+
_fail(f" β pack: no output head found in '{repo}'.")
|
| 24 |
+
return [head]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def cmd_pack(args):
|
| 28 |
+
repo = args.repo.strip().rstrip("/")
|
| 29 |
+
if not repo:
|
| 30 |
+
_fail(" β Enter a model name β for example: voice pack google/gemma-3-1b")
|
| 31 |
+
_step(f"Finding the voice pack in {repo}β¦")
|
| 32 |
+
try:
|
| 33 |
+
source = open_source(repo)
|
| 34 |
+
names = source.names()
|
| 35 |
+
if not names:
|
| 36 |
+
_fail(f" β No tensors found in '{repo}'.")
|
| 37 |
+
arch, config = _source_arch(source)
|
| 38 |
+
except urllib.error.HTTPError as e:
|
| 39 |
+
_fail(f" β Model '{repo}' was not found or is not accessible ({e.code}).")
|
| 40 |
+
except Exception as e:
|
| 41 |
+
if ctx.VERBOSE:
|
| 42 |
+
import traceback
|
| 43 |
+
_say(traceback.format_exc())
|
| 44 |
+
_fail(f" β Could not reach the model. Check your connection ({type(e).__name__}).")
|
| 45 |
+
|
| 46 |
+
_step("Resolving voice-pack tensorsβ¦")
|
| 47 |
+
resolved = _resolve_pack_tensors(source, repo, config, names)
|
| 48 |
+
for tname in resolved:
|
| 49 |
+
_note(f" {tname}")
|
| 50 |
+
# Fetch via the same multi path as get --multi, then save flat in the
|
| 51 |
+
# registry through the existing add machinery (no new storage format).
|
| 52 |
+
pack = _fetch_pack(source, repo, resolved)
|
| 53 |
+
slug = _slugify_voice_name(repo.split("/")[-1].split("@")[0].split(":")[0].replace(".gguf", "").replace(".safetensors", ""))
|
| 54 |
+
name = f"pack-{slug}"
|
| 55 |
+
import tempfile, shutil
|
| 56 |
+
stage = Path(tempfile.mkdtemp(prefix="voicepack-stage-"))
|
| 57 |
+
try:
|
| 58 |
+
st_path = stage / "voice.safetensors"
|
| 59 |
+
write_safetensors(pack, str(st_path))
|
| 60 |
+
js_path = stage / "voice.json"
|
| 61 |
+
js_path.write_text(json.dumps({
|
| 62 |
+
"name": name,
|
| 63 |
+
"source": _public_source(repo),
|
| 64 |
+
"source_format": source.kind,
|
| 65 |
+
"tensors": [{"name": n, "shape": [int(x) for x in pack[n][2]], "dtype": pack[n][1]}
|
| 66 |
+
for n in resolved if n in pack],
|
| 67 |
+
"architecture": arch,
|
| 68 |
+
"family": _family(arch),
|
| 69 |
+
"downloaded_at": _now_iso(),
|
| 70 |
+
}, indent=2) + "\n")
|
| 71 |
+
from vlib.cmds_voices import cmd_add
|
| 72 |
+
cmd_add(argparse.Namespace(safetensors=str(st_path), json=str(js_path), name=name,
|
| 73 |
+
yes=getattr(args, "yes", False),
|
| 74 |
+
non_interactive=getattr(args, "non_interactive", False)))
|
| 75 |
+
finally:
|
| 76 |
+
shutil.rmtree(stage, ignore_errors=True)
|
vlib/registry.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Voice registry, ops log, and write guards."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
import time
|
| 8 |
+
from vlib import ctx
|
| 9 |
+
from vlib.ui import _confirm, _fail, _noninteractive, _say, _warn
|
| 10 |
+
from vlib.tensors import _is_gguf_file, _is_quant_fetched, _write_safetensors_streaming, _write_single_tensor_gguf, write_safetensors
|
| 11 |
+
from vlib.sources import _NAME_RULES, _sanitize_voice_name, _slugify_voice_name
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def log_operation(record):
|
| 15 |
+
record = {"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), **record}
|
| 16 |
+
try:
|
| 17 |
+
ctx.VOICES_DIR.mkdir(parents=True, exist_ok=True)
|
| 18 |
+
try:
|
| 19 |
+
os.chmod(ctx.VOICES_DIR, 0o700)
|
| 20 |
+
except Exception:
|
| 21 |
+
pass
|
| 22 |
+
_assert_not_symlink(ctx.OPERATIONS_LOG)
|
| 23 |
+
with open(ctx.OPERATIONS_LOG, "a") as f:
|
| 24 |
+
f.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
|
| 25 |
+
try:
|
| 26 |
+
f.flush()
|
| 27 |
+
os.fsync(f.fileno())
|
| 28 |
+
except Exception:
|
| 29 |
+
pass
|
| 30 |
+
try:
|
| 31 |
+
os.chmod(ctx.OPERATIONS_LOG, 0o600)
|
| 32 |
+
except Exception:
|
| 33 |
+
pass
|
| 34 |
+
try:
|
| 35 |
+
dirfd = os.open(str(ctx.VOICES_DIR), os.O_DIRECTORY)
|
| 36 |
+
try:
|
| 37 |
+
os.fsync(dirfd)
|
| 38 |
+
finally:
|
| 39 |
+
os.close(dirfd)
|
| 40 |
+
except Exception:
|
| 41 |
+
pass
|
| 42 |
+
except Exception:
|
| 43 |
+
if ctx.VERBOSE:
|
| 44 |
+
_warn("could not write operation log")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _atomic_jsonl_append(path, record):
|
| 48 |
+
"""Append one JSON line atomically with flock + fsync. Optimised: single chmod, cached JSON."""
|
| 49 |
+
path = Path(path)
|
| 50 |
+
# fast path: parent exists, avoid mkdir/chmod churn
|
| 51 |
+
if not path.parent.exists():
|
| 52 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 53 |
+
try:
|
| 54 |
+
os.chmod(path.parent, 0o700)
|
| 55 |
+
except Exception:
|
| 56 |
+
pass
|
| 57 |
+
if not path.exists():
|
| 58 |
+
try:
|
| 59 |
+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT, 0o600)
|
| 60 |
+
os.close(fd)
|
| 61 |
+
except Exception:
|
| 62 |
+
pass
|
| 63 |
+
else:
|
| 64 |
+
# occasional chmod drift check, not every append
|
| 65 |
+
try:
|
| 66 |
+
if oct(path.stat().st_mode & 0o777) != "0o600":
|
| 67 |
+
os.chmod(path, 0o600)
|
| 68 |
+
except Exception:
|
| 69 |
+
pass
|
| 70 |
+
_assert_not_symlink(path)
|
| 71 |
+
line = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
|
| 72 |
+
with open(path, "a") as f:
|
| 73 |
+
try:
|
| 74 |
+
import fcntl
|
| 75 |
+
fcntl.flock(f, fcntl.LOCK_EX)
|
| 76 |
+
locked = True
|
| 77 |
+
except Exception:
|
| 78 |
+
locked = False
|
| 79 |
+
try:
|
| 80 |
+
f.write(line)
|
| 81 |
+
f.flush()
|
| 82 |
+
try:
|
| 83 |
+
os.fsync(f.fileno())
|
| 84 |
+
except Exception:
|
| 85 |
+
pass
|
| 86 |
+
finally:
|
| 87 |
+
if locked:
|
| 88 |
+
try:
|
| 89 |
+
import fcntl
|
| 90 |
+
fcntl.flock(f, fcntl.LOCK_UN)
|
| 91 |
+
except Exception:
|
| 92 |
+
pass
|
| 93 |
+
try:
|
| 94 |
+
dirfd = os.open(str(path.parent), os.O_DIRECTORY)
|
| 95 |
+
try:
|
| 96 |
+
os.fsync(dirfd)
|
| 97 |
+
finally:
|
| 98 |
+
os.close(dirfd)
|
| 99 |
+
except Exception:
|
| 100 |
+
pass
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _write_outbox(report_id, record):
|
| 104 |
+
ctx.REPORTS_OUTBOX.mkdir(parents=True, exist_ok=True)
|
| 105 |
+
try:
|
| 106 |
+
os.chmod(ctx.REPORTS_OUTBOX, 0o700)
|
| 107 |
+
except Exception:
|
| 108 |
+
pass
|
| 109 |
+
_assert_not_symlink(ctx.REPORTS_OUTBOX)
|
| 110 |
+
out_path = ctx.REPORTS_OUTBOX / f"{report_id}.json"
|
| 111 |
+
_assert_not_symlink(out_path)
|
| 112 |
+
tmp = out_path.with_suffix(".tmp")
|
| 113 |
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
| 114 |
+
try:
|
| 115 |
+
os.write(fd, (json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8"))
|
| 116 |
+
try:
|
| 117 |
+
os.fsync(fd)
|
| 118 |
+
except Exception:
|
| 119 |
+
pass
|
| 120 |
+
finally:
|
| 121 |
+
os.close(fd)
|
| 122 |
+
tmp.replace(out_path)
|
| 123 |
+
try:
|
| 124 |
+
dirfd = os.open(str(ctx.REPORTS_OUTBOX), os.O_DIRECTORY)
|
| 125 |
+
try:
|
| 126 |
+
os.fsync(dirfd)
|
| 127 |
+
finally:
|
| 128 |
+
os.close(dirfd)
|
| 129 |
+
except Exception:
|
| 130 |
+
pass
|
| 131 |
+
return out_path
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _append_report(record):
|
| 135 |
+
_atomic_jsonl_append(ctx.REPORTS_JSONL, record)
|
| 136 |
+
_write_outbox(record["report_id"], record)
|
| 137 |
+
log_operation({"op": "report", "report_id": record["report_id"], "status": "ok", "voice": record.get("voice"), "target": record.get("target")})
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _iter_voices():
|
| 141 |
+
if not ctx.VOICES_DIR.exists():
|
| 142 |
+
return []
|
| 143 |
+
return [d for d in sorted(ctx.VOICES_DIR.iterdir(), key=lambda p: p.name.lower())
|
| 144 |
+
if d.is_dir() and not d.name.startswith(".") and d.name != "base"]
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _registry_resolve(name_or_path):
|
| 148 |
+
p = Path(name_or_path).expanduser()
|
| 149 |
+
if p.is_file():
|
| 150 |
+
return p, None
|
| 151 |
+
if p.is_dir():
|
| 152 |
+
for fname in ("voice.safetensors", "voice.gguf"):
|
| 153 |
+
if (p / fname).exists():
|
| 154 |
+
return p / fname, _read_meta(p / "voice.json")
|
| 155 |
+
target = name_or_path.strip().lower()
|
| 156 |
+
for d in _iter_voices():
|
| 157 |
+
if d.name.lower() == target:
|
| 158 |
+
for fname in ("voice.safetensors", "voice.gguf"):
|
| 159 |
+
if (d / fname).exists():
|
| 160 |
+
return d / fname, _read_meta(d / "voice.json")
|
| 161 |
+
return None, None
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _read_meta(json_path):
|
| 165 |
+
try:
|
| 166 |
+
return json.loads(json_path.read_text()) if json_path.exists() else None
|
| 167 |
+
except Exception:
|
| 168 |
+
return None
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _registry_name_exists(name):
|
| 172 |
+
target = name.strip().lower()
|
| 173 |
+
return any(d.name.lower() == target for d in _iter_voices())
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _registry_folder(name):
|
| 177 |
+
return ctx.VOICES_DIR / name.strip().lower()
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _assert_not_symlink(path):
|
| 181 |
+
# Resolve-first, fail-closed. The old code compared raw strings
|
| 182 |
+
# (str.startswith): a sibling such as ".voices2" counted as "inside"
|
| 183 |
+
# ".voices", uninspected parents were skipped by the same prefix test,
|
| 184 |
+
# and any resolve failure was swallowed (fail-open). Now: resolve before
|
| 185 |
+
# any comparison, test membership with real path relations, walk every
|
| 186 |
+
# component below VOICES_DIR, and refuse when resolution itself fails.
|
| 187 |
+
# Scope: registry paths (under VOICES_DIR). Unrelated user paths (e.g. an
|
| 188 |
+
# explicit --out in /tmp) keep the old behavior: direct-symlink check only,
|
| 189 |
+
# since the user chose them and system roots above them may legitimately
|
| 190 |
+
# be links. TOCTOU (link swapped between check and write) is out of scope:
|
| 191 |
+
# local CLI, self-owned dir.
|
| 192 |
+
try:
|
| 193 |
+
p = Path(path)
|
| 194 |
+
abs_p = p.absolute()
|
| 195 |
+
norm = Path(os.path.normpath(abs_p))
|
| 196 |
+
base_abs = ctx.VOICES_DIR.absolute()
|
| 197 |
+
base_res = ctx.VOICES_DIR.resolve()
|
| 198 |
+
rp = p.resolve()
|
| 199 |
+
if p.is_symlink():
|
| 200 |
+
_fail(f" β Refusing to write through symlink: {path}")
|
| 201 |
+
prefix_hit = str(abs_p).startswith(str(base_abs))
|
| 202 |
+
inside = norm == base_abs or base_abs in norm.parents
|
| 203 |
+
if inside or prefix_hit:
|
| 204 |
+
# every component from the base down must be symlink-free; a
|
| 205 |
+
# prefix-sibling (".voices2") has no relative leg to walk, but it
|
| 206 |
+
# still faces the containment check below.
|
| 207 |
+
if inside:
|
| 208 |
+
cur = base_abs
|
| 209 |
+
for part in norm.relative_to(base_abs).parts:
|
| 210 |
+
cur = cur / part
|
| 211 |
+
if cur.is_symlink():
|
| 212 |
+
_fail(f" β Refusing to write through symlink: {cur}")
|
| 213 |
+
if rp != base_res and base_res not in rp.parents:
|
| 214 |
+
_fail(f" β Refusing to write outside VOICES_DIR: {path}")
|
| 215 |
+
except SystemExit:
|
| 216 |
+
raise
|
| 217 |
+
except Exception:
|
| 218 |
+
_fail(f" β Refusing to write through unresolvable path: {path}")
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _write_voice(folder, tensor_name, fetched, meta):
|
| 222 |
+
_assert_not_symlink(folder)
|
| 223 |
+
folder.mkdir(parents=True, exist_ok=True)
|
| 224 |
+
try:
|
| 225 |
+
os.chmod(folder, 0o700)
|
| 226 |
+
except Exception:
|
| 227 |
+
pass
|
| 228 |
+
_assert_not_symlink(folder)
|
| 229 |
+
kind, raw_path, data, dtype, shape = fetched
|
| 230 |
+
if _is_quant_fetched(fetched):
|
| 231 |
+
fpath = folder / "voice.gguf"
|
| 232 |
+
tmp = folder / "voice.gguf.tmp"
|
| 233 |
+
_write_single_tensor_gguf(tensor_name, raw_path, raw_path.stat().st_size, dtype, shape, str(tmp))
|
| 234 |
+
else:
|
| 235 |
+
fpath = folder / "voice.safetensors"
|
| 236 |
+
tmp = folder / "voice.safetensors.tmp"
|
| 237 |
+
if kind == "file":
|
| 238 |
+
_write_safetensors_streaming(tensor_name, str(raw_path), raw_path.stat().st_size, dtype, shape, str(tmp))
|
| 239 |
+
else:
|
| 240 |
+
write_safetensors({tensor_name: (data, dtype, shape)}, str(tmp))
|
| 241 |
+
tmp.replace(fpath)
|
| 242 |
+
try:
|
| 243 |
+
os.chmod(fpath, 0o600)
|
| 244 |
+
except Exception:
|
| 245 |
+
pass
|
| 246 |
+
meta = dict(meta)
|
| 247 |
+
meta["artifact_format"] = "gguf" if _is_quant_fetched(fetched) else "safetensors"
|
| 248 |
+
meta["ggml_type"] = dtype if _is_quant_fetched(fetched) else None
|
| 249 |
+
meta["bytes"] = fpath.stat().st_size
|
| 250 |
+
jtmp = folder / "voice.json.tmp"
|
| 251 |
+
# atomic 0600 json write
|
| 252 |
+
fd = os.open(str(jtmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
| 253 |
+
try:
|
| 254 |
+
os.write(fd, (json.dumps(meta, indent=2) + "\n").encode("utf-8"))
|
| 255 |
+
try:
|
| 256 |
+
os.fsync(fd)
|
| 257 |
+
except Exception:
|
| 258 |
+
pass
|
| 259 |
+
finally:
|
| 260 |
+
os.close(fd)
|
| 261 |
+
jtmp.replace(folder / "voice.json")
|
| 262 |
+
return fpath
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _resolve_name(args, default_name):
|
| 266 |
+
provided = getattr(args, "name", None)
|
| 267 |
+
if provided and str(provided).strip():
|
| 268 |
+
name = str(provided).strip()
|
| 269 |
+
if _sanitize_voice_name(name) is None:
|
| 270 |
+
_fail(f" β Invalid name '{name}'. {_NAME_RULES}")
|
| 271 |
+
return name
|
| 272 |
+
default_slug = _slugify_voice_name(default_name)
|
| 273 |
+
if _noninteractive(args):
|
| 274 |
+
return default_slug
|
| 275 |
+
try:
|
| 276 |
+
entered = input(f" β Name the voice or press enter to use [{default_slug}]: ").strip()
|
| 277 |
+
name = entered or default_slug
|
| 278 |
+
except (EOFError, KeyboardInterrupt):
|
| 279 |
+
_say(" β Cancelled.")
|
| 280 |
+
sys.exit(0)
|
| 281 |
+
if _sanitize_voice_name(name) is None:
|
| 282 |
+
_fail(f" β Invalid name '{name}'. {_NAME_RULES}")
|
| 283 |
+
return name
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _out_path(args, target, voice_arg):
|
| 287 |
+
if getattr(args, "out", None):
|
| 288 |
+
out = args.out
|
| 289 |
+
# jail: resolve but allow any absolute path user explicitly requested; block symlink traversal
|
| 290 |
+
if out.endswith(("/", os.sep)) or os.path.isdir(out) or not os.path.splitext(out)[1]:
|
| 291 |
+
out = os.path.join(out, f"{_stem(voice_arg)}-{_stem(target)}{'.gguf' if _is_gguf_file(target) else '.safetensors'}")
|
| 292 |
+
# normalize
|
| 293 |
+
out = os.path.abspath(out)
|
| 294 |
+
return out
|
| 295 |
+
# sane default: ~/Voiced/<voice>-<model>/<voice>-<model>.gguf (not alongside target)
|
| 296 |
+
# avoids overwriting and keeps Voiced folder tidy
|
| 297 |
+
voiced_base = Path.home() / "Voiced"
|
| 298 |
+
sub = f"{_stem(voice_arg)}-{_stem(target)}"
|
| 299 |
+
ext = ".gguf" if _is_gguf_file(target) else ".safetensors"
|
| 300 |
+
return str(voiced_base / sub / f"{sub}{ext}")
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def _stem(p):
|
| 304 |
+
return os.path.splitext(os.path.basename(p))[0]
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _check_overwrite(out_path, args):
|
| 308 |
+
if os.path.islink(out_path):
|
| 309 |
+
_fail(f" β Refusing to write through symlink: {out_path}")
|
| 310 |
+
if os.path.exists(out_path):
|
| 311 |
+
if _noninteractive(args) and not getattr(args, "yes", False):
|
| 312 |
+
_fail(f" β Output '{out_path}' exists. Use --yes to overwrite in non-interactive mode.")
|
| 313 |
+
if not _confirm(f" Output '{out_path}' already exists. Overwrite? [y/N]: ", args):
|
| 314 |
+
_say(" Cancelled.")
|
| 315 |
+
sys.exit(0)
|
| 316 |
+
out_dir = os.path.dirname(os.path.abspath(out_path))
|
| 317 |
+
if out_dir and os.path.islink(out_dir):
|
| 318 |
+
_fail(f" β Refusing to write through symlink: {out_dir}")
|
| 319 |
+
if out_dir and not os.path.isdir(out_dir):
|
| 320 |
+
try:
|
| 321 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 322 |
+
except Exception:
|
| 323 |
+
_fail(f" β Could not create folder \"{out_dir}\".")
|
vlib/reports.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local reports and community sharing."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import sys
|
| 8 |
+
import urllib.error
|
| 9 |
+
import urllib.request
|
| 10 |
+
from vlib import ctx
|
| 11 |
+
from vlib.ui import _noninteractive, _now_iso, _ok, _say, _step, _warn
|
| 12 |
+
from vlib.net import _get_hf_token, _parse_retry_after
|
| 13 |
+
from vlib.tensors import _is_gguf_file, read_safetensors
|
| 14 |
+
from vlib.sources import _family, _public_source, detect_output_tensor, largest_2d
|
| 15 |
+
from vlib.registry import _append_report, _iter_voices, _read_meta, _registry_resolve
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _hash_file(path):
|
| 19 |
+
import hashlib
|
| 20 |
+
h = hashlib.sha256()
|
| 21 |
+
with open(path, "rb") as f:
|
| 22 |
+
while True:
|
| 23 |
+
b = f.read(8 << 20)
|
| 24 |
+
if not b:
|
| 25 |
+
break
|
| 26 |
+
h.update(b)
|
| 27 |
+
return h.hexdigest()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _sanitize_note(s):
|
| 31 |
+
if s is None:
|
| 32 |
+
return None
|
| 33 |
+
s = str(s).replace("\r", " ").replace("\n", " ").replace("\x00", " ")
|
| 34 |
+
s = re.sub(r"\s+", " ", s).strip()
|
| 35 |
+
if not s:
|
| 36 |
+
return None
|
| 37 |
+
# strip CSV injection prefixes
|
| 38 |
+
if s and s[0] in "=+@-":
|
| 39 |
+
s = "'" + s
|
| 40 |
+
if len(s) > 240:
|
| 41 |
+
s = s[:239] + "β¦"
|
| 42 |
+
return s
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _sanitize_reporter(s):
|
| 46 |
+
if not s:
|
| 47 |
+
return None
|
| 48 |
+
s = re.sub(r"[^A-Za-z0-9._-]", "_", str(s).strip())[:64].strip("_")
|
| 49 |
+
return s or None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _prompt_share(args, preview=None):
|
| 53 |
+
if getattr(args, "no_push", False):
|
| 54 |
+
return False
|
| 55 |
+
if getattr(args, "yes", False):
|
| 56 |
+
return True
|
| 57 |
+
if _noninteractive(args):
|
| 58 |
+
return False
|
| 59 |
+
if preview:
|
| 60 |
+
_say(f" Will share: {preview}")
|
| 61 |
+
try:
|
| 62 |
+
ans = input(" Leave a breadcrumb to help the next person? [y/N] (N): ").strip().lower()
|
| 63 |
+
except (EOFError, KeyboardInterrupt):
|
| 64 |
+
_say(" β Cancelled.")
|
| 65 |
+
sys.exit(0)
|
| 66 |
+
return ans in ("y", "yes")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _push_reports_batch(records):
|
| 70 |
+
"""Batch push: group by shard, single fetch+upload per shard. Returns pushed count.
|
| 71 |
+
Non-collaborators lack write access to Wiself/voice-reports β fallback to PR (create_pr=True).
|
| 72 |
+
Fixes tempfile.mktemp racy API β mkstemp 0600; 3 retries on 429/409/412.
|
| 73 |
+
Note: download-modify-upload still racy for direct pushes (two owners racing may lose);
|
| 74 |
+
dedup on report_id softens it, and PR path eliminates race for crowd (each PR is isolated)."""
|
| 75 |
+
import time, tempfile
|
| 76 |
+
if not records:
|
| 77 |
+
return 0
|
| 78 |
+
tok = _get_hf_token()
|
| 79 |
+
if not tok:
|
| 80 |
+
_warn(" No HF token β saved to outbox only. Run: voice auth")
|
| 81 |
+
return 0
|
| 82 |
+
try:
|
| 83 |
+
from huggingface_hub import HfApi
|
| 84 |
+
from huggingface_hub import CommitOperationAdd
|
| 85 |
+
api = HfApi(token=tok)
|
| 86 |
+
except Exception as e:
|
| 87 |
+
_warn(f" huggingface_hub not available ({e}) β kept in outbox.")
|
| 88 |
+
return 0
|
| 89 |
+
try:
|
| 90 |
+
api.create_repo(repo_id=ctx.REPORTS_DATASET_ID, repo_type="dataset", exist_ok=True)
|
| 91 |
+
except Exception:
|
| 92 |
+
pass
|
| 93 |
+
from collections import defaultdict
|
| 94 |
+
by_shard = defaultdict(list)
|
| 95 |
+
for r in records:
|
| 96 |
+
shard = f"{ctx.REPORTS_REMOTE_PREFIX}{r['ts'][:7]}.jsonl"
|
| 97 |
+
by_shard[shard].append(r)
|
| 98 |
+
pushed = 0
|
| 99 |
+
for shard, recs in by_shard.items():
|
| 100 |
+
for attempt in range(3):
|
| 101 |
+
try:
|
| 102 |
+
existing = ""
|
| 103 |
+
try:
|
| 104 |
+
from huggingface_hub import hf_hub_download
|
| 105 |
+
tmp = hf_hub_download(repo_id=ctx.REPORTS_DATASET_ID, repo_type="dataset", filename=shard, token=tok)
|
| 106 |
+
existing = Path(tmp).read_text(encoding="utf-8", errors="replace")
|
| 107 |
+
except Exception:
|
| 108 |
+
existing = ""
|
| 109 |
+
# parse existing_ids properly (not substring) for correct dedup
|
| 110 |
+
existing_ids = set()
|
| 111 |
+
if existing:
|
| 112 |
+
for line in existing.splitlines():
|
| 113 |
+
if not line.strip():
|
| 114 |
+
continue
|
| 115 |
+
try:
|
| 116 |
+
existing_ids.add(json.loads(line).get("report_id"))
|
| 117 |
+
except Exception:
|
| 118 |
+
continue
|
| 119 |
+
# headroom check per Tim Cook: warn if shard near 10k
|
| 120 |
+
if len(existing_ids) > 10000:
|
| 121 |
+
_warn(f" Shard {shard} has {len(existing_ids)} reports β consider rotation (reports-YYYY-MM-2).")
|
| 122 |
+
todo = [r for r in recs if r["report_id"] not in existing_ids]
|
| 123 |
+
if not todo:
|
| 124 |
+
for r in recs:
|
| 125 |
+
try:
|
| 126 |
+
(ctx.REPORTS_OUTBOX / f"{r['report_id']}.json").unlink(missing_ok=True)
|
| 127 |
+
except Exception:
|
| 128 |
+
pass
|
| 129 |
+
pushed += len(recs)
|
| 130 |
+
break
|
| 131 |
+
if not existing:
|
| 132 |
+
new_content = "\n".join(json.dumps(r, ensure_ascii=False, separators=(",", ":")) for r in todo) + "\n"
|
| 133 |
+
else:
|
| 134 |
+
if not existing.endswith("\n"):
|
| 135 |
+
existing += "\n"
|
| 136 |
+
new_content = existing + "\n".join(json.dumps(r, ensure_ascii=False, separators=(",", ":")) for r in todo) + "\n"
|
| 137 |
+
# mkstemp 0600, not deprecated mktemp
|
| 138 |
+
fd_tmp, tmp_path = tempfile.mkstemp(prefix="voice-report-")
|
| 139 |
+
tmpf = Path(tmp_path)
|
| 140 |
+
try:
|
| 141 |
+
os.write(fd_tmp, new_content.encode("utf-8"))
|
| 142 |
+
try:
|
| 143 |
+
os.fsync(fd_tmp)
|
| 144 |
+
except Exception:
|
| 145 |
+
pass
|
| 146 |
+
finally:
|
| 147 |
+
try:
|
| 148 |
+
os.close(fd_tmp)
|
| 149 |
+
except Exception:
|
| 150 |
+
pass
|
| 151 |
+
try:
|
| 152 |
+
os.chmod(tmpf, 0o600)
|
| 153 |
+
except Exception:
|
| 154 |
+
pass
|
| 155 |
+
# try direct push first; on 403 (no write access) fallback to PR
|
| 156 |
+
try:
|
| 157 |
+
api.upload_file(path_or_fileobj=str(tmpf), path_in_repo=shard, repo_id=ctx.REPORTS_DATASET_ID, repo_type="dataset", commit_message=f"report batch {len(todo)} -> {shard}")
|
| 158 |
+
except Exception as e_direct:
|
| 159 |
+
is_403 = False
|
| 160 |
+
try:
|
| 161 |
+
from huggingface_hub.utils import HfHubHTTPError as _HE
|
| 162 |
+
if isinstance(e_direct, _HE) and getattr(e_direct.response, "status_code", None) == 403:
|
| 163 |
+
is_403 = True
|
| 164 |
+
elif "403" in str(e_direct) or "Forbidden" in str(e_direct):
|
| 165 |
+
is_403 = True
|
| 166 |
+
except Exception:
|
| 167 |
+
pass
|
| 168 |
+
if is_403:
|
| 169 |
+
_step(" No write access β creating pull requestβ¦")
|
| 170 |
+
api.create_commit(
|
| 171 |
+
repo_id=ctx.REPORTS_DATASET_ID,
|
| 172 |
+
repo_type="dataset",
|
| 173 |
+
operations=[CommitOperationAdd(path_in_repo=shard, path_or_fileobj=str(tmpf))],
|
| 174 |
+
commit_message=f"report batch {len(todo)} -> {shard} ({todo[0]['report_id'][:8]}β¦)",
|
| 175 |
+
create_pr=True,
|
| 176 |
+
)
|
| 177 |
+
_ok(" PR opened β thank you! Maintainer will merge shortly.")
|
| 178 |
+
else:
|
| 179 |
+
raise
|
| 180 |
+
try:
|
| 181 |
+
tmpf.unlink(missing_ok=True)
|
| 182 |
+
except Exception:
|
| 183 |
+
pass
|
| 184 |
+
for r in todo:
|
| 185 |
+
try:
|
| 186 |
+
(ctx.REPORTS_OUTBOX / f"{r['report_id']}.json").unlink(missing_ok=True)
|
| 187 |
+
except Exception:
|
| 188 |
+
pass
|
| 189 |
+
pushed += len(todo)
|
| 190 |
+
break
|
| 191 |
+
except Exception as e:
|
| 192 |
+
try:
|
| 193 |
+
from huggingface_hub.utils import HfHubHTTPError as _HE
|
| 194 |
+
code = getattr(getattr(e, "response", None), "status_code", None)
|
| 195 |
+
if isinstance(e, _HE) and code in (429, 409, 412):
|
| 196 |
+
ra = _parse_retry_after(getattr(e.response, "headers", {}) or {})
|
| 197 |
+
time.sleep(ra or (0.7 * (2 ** attempt)))
|
| 198 |
+
continue
|
| 199 |
+
# 403 already handled via PR above; other 403 should not retry as direct
|
| 200 |
+
if isinstance(e, _HE) and code == 403:
|
| 201 |
+
# if PR also failed, keep in outbox
|
| 202 |
+
_warn(f" Push failed for {shard} (403 Forbidden) β kept in outbox. PR fallback also failed.")
|
| 203 |
+
break
|
| 204 |
+
except Exception:
|
| 205 |
+
pass
|
| 206 |
+
if attempt < 2 and isinstance(e, (urllib.error.URLError, TimeoutError, OSError)):
|
| 207 |
+
time.sleep(0.7 * (2 ** attempt))
|
| 208 |
+
continue
|
| 209 |
+
if attempt >= 2:
|
| 210 |
+
if ctx.VERBOSE:
|
| 211 |
+
import traceback
|
| 212 |
+
_say(traceback.format_exc())
|
| 213 |
+
_warn(f" Push failed for {shard} ({type(e).__name__}: {e}) β kept in outbox.")
|
| 214 |
+
break
|
| 215 |
+
time.sleep(0.7 * (2 ** attempt))
|
| 216 |
+
return pushed
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _push_report(record):
|
| 220 |
+
return _push_reports_batch([record]) == 1
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _last_operation():
|
| 224 |
+
try:
|
| 225 |
+
if not ctx.OPERATIONS_LOG.exists():
|
| 226 |
+
return None
|
| 227 |
+
# read last non-empty line
|
| 228 |
+
with open(ctx.OPERATIONS_LOG, "rb") as f:
|
| 229 |
+
f.seek(0, 2)
|
| 230 |
+
size = f.tell()
|
| 231 |
+
if size == 0:
|
| 232 |
+
return None
|
| 233 |
+
# read tail 8KB
|
| 234 |
+
read = min(size, 8192)
|
| 235 |
+
f.seek(size - read)
|
| 236 |
+
tail = f.read().decode("utf-8", "replace").strip().splitlines()
|
| 237 |
+
for line in reversed(tail):
|
| 238 |
+
if not line.strip():
|
| 239 |
+
continue
|
| 240 |
+
try:
|
| 241 |
+
rec = json.loads(line)
|
| 242 |
+
op = rec.get("op")
|
| 243 |
+
if op:
|
| 244 |
+
return op
|
| 245 |
+
except Exception:
|
| 246 |
+
continue
|
| 247 |
+
except Exception:
|
| 248 |
+
pass
|
| 249 |
+
return None
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def cmd_report(args):
|
| 253 |
+
msg = _sanitize_note(getattr(args, "message", None))
|
| 254 |
+
voice_arg = getattr(args, "voice", None)
|
| 255 |
+
import uuid, platform
|
| 256 |
+
report_id = uuid.uuid4().hex
|
| 257 |
+
ts = _now_iso()
|
| 258 |
+
# sanitize voice/target early for CSV safety
|
| 259 |
+
voice_name = _sanitize_note(voice_arg.strip()) if voice_arg and voice_arg.strip() else None
|
| 260 |
+
target_raw = getattr(args, "target", None)
|
| 261 |
+
target_basename = _sanitize_note(Path(target_raw).name) if target_raw else None
|
| 262 |
+
# auto-pull voice provenance
|
| 263 |
+
voice_meta = None
|
| 264 |
+
try:
|
| 265 |
+
if voice_arg:
|
| 266 |
+
vp, vm = _registry_resolve(voice_arg)
|
| 267 |
+
if vp and vp.exists():
|
| 268 |
+
if vm is None:
|
| 269 |
+
vm = _read_meta(Path(vp).parent / "voice.json")
|
| 270 |
+
voice_meta = dict(vm or {})
|
| 271 |
+
if "tensor_sha256" not in voice_meta and vp.exists():
|
| 272 |
+
try:
|
| 273 |
+
voice_meta["tensor_sha256"] = _hash_file(str(vp))
|
| 274 |
+
except Exception:
|
| 275 |
+
pass
|
| 276 |
+
else:
|
| 277 |
+
# fallback: most recent voice if requested name not found
|
| 278 |
+
voices = sorted([d for d in _iter_voices() if (d / "voice.json").exists()], key=lambda p: p.stat().st_mtime, reverse=True)
|
| 279 |
+
if voices:
|
| 280 |
+
vm = _read_meta(voices[0] / "voice.json")
|
| 281 |
+
if vm:
|
| 282 |
+
voice_meta = dict(vm)
|
| 283 |
+
if not voice_name:
|
| 284 |
+
voice_name = _sanitize_note(vm.get("name") or voices[0].name)
|
| 285 |
+
else:
|
| 286 |
+
# zero flags: auto-pull most recent voice for full traceability
|
| 287 |
+
voices = sorted([d for d in _iter_voices() if (d / "voice.json").exists()], key=lambda p: p.stat().st_mtime, reverse=True)
|
| 288 |
+
if voices:
|
| 289 |
+
vm = _read_meta(voices[0] / "voice.json")
|
| 290 |
+
if vm:
|
| 291 |
+
voice_meta = dict(vm)
|
| 292 |
+
if not voice_name:
|
| 293 |
+
voice_name = _sanitize_note(vm.get("name") or voices[0].name)
|
| 294 |
+
except Exception:
|
| 295 |
+
pass
|
| 296 |
+
# auto-probe target file if exists
|
| 297 |
+
target_info = None
|
| 298 |
+
if target_raw and Path(target_raw).expanduser().exists():
|
| 299 |
+
try:
|
| 300 |
+
p = Path(target_raw).expanduser()
|
| 301 |
+
if _is_gguf_file(str(p)):
|
| 302 |
+
from gguf import GGUFReader
|
| 303 |
+
r = GGUFReader(str(p))
|
| 304 |
+
arch = None
|
| 305 |
+
for fld in r.fields.values():
|
| 306 |
+
if fld.name == "general.architecture":
|
| 307 |
+
try:
|
| 308 |
+
v = fld.parts[fld.data[0]]
|
| 309 |
+
if isinstance(v, bytes):
|
| 310 |
+
arch = v.decode()
|
| 311 |
+
elif hasattr(v, "tobytes"):
|
| 312 |
+
try:
|
| 313 |
+
arch = v.tobytes().decode()
|
| 314 |
+
except Exception:
|
| 315 |
+
arch = str(v)
|
| 316 |
+
else:
|
| 317 |
+
arch = str(v)
|
| 318 |
+
arch = arch.strip("\x00").strip()
|
| 319 |
+
except Exception:
|
| 320 |
+
pass
|
| 321 |
+
break
|
| 322 |
+
# find output tensor
|
| 323 |
+
tname = None
|
| 324 |
+
for pref in ("output.weight", "token_embd.weight"):
|
| 325 |
+
for t in r.tensors:
|
| 326 |
+
if t.name == pref:
|
| 327 |
+
tname = t.name
|
| 328 |
+
break
|
| 329 |
+
if tname:
|
| 330 |
+
break
|
| 331 |
+
shape = None
|
| 332 |
+
dtype = None
|
| 333 |
+
if tname:
|
| 334 |
+
for t in r.tensors:
|
| 335 |
+
if t.name == tname:
|
| 336 |
+
shape = list(reversed([int(x) for x in t.shape]))
|
| 337 |
+
dtype = t.tensor_type.name
|
| 338 |
+
break
|
| 339 |
+
target_info = {"basename": target_basename, "format": "gguf", "tensor_name": tname, "architecture": arch, "family": _family(arch), "shape": shape, "dtype": dtype}
|
| 340 |
+
else:
|
| 341 |
+
# try safetensors probe (header only)
|
| 342 |
+
try:
|
| 343 |
+
hdr, tens = read_safetensors(str(p))
|
| 344 |
+
# pick output tensor
|
| 345 |
+
tname = detect_output_tensor(list(tens.keys()), None) or largest_2d(list(tens.keys()), {k: v["shape"] for k, v in tens.items()})
|
| 346 |
+
info = tens.get(tname) if tname else None
|
| 347 |
+
target_info = {"basename": target_basename, "format": "safetensors", "tensor_name": tname, "shape": list(info["shape"]) if info else None, "dtype": info["dtype"] if info else None}
|
| 348 |
+
except Exception:
|
| 349 |
+
target_info = {"basename": target_basename, "format": "safetensors"}
|
| 350 |
+
except Exception:
|
| 351 |
+
pass
|
| 352 |
+
if target_info is None and target_basename:
|
| 353 |
+
target_info = {"basename": target_basename}
|
| 354 |
+
# env enrichment
|
| 355 |
+
try:
|
| 356 |
+
import numpy as _np
|
| 357 |
+
np_ver = getattr(_np, "__version__", None)
|
| 358 |
+
except Exception:
|
| 359 |
+
np_ver = None
|
| 360 |
+
try:
|
| 361 |
+
import gguf as _gg
|
| 362 |
+
gg_ver = getattr(_gg, "__version__", "installed")
|
| 363 |
+
except Exception:
|
| 364 |
+
gg_ver = None
|
| 365 |
+
env_info = {"voice_ver": ctx.VERSION, "python": platform.python_version(), "platform": platform.platform(), "numpy": np_ver, "gguf": gg_ver}
|
| 366 |
+
operation = getattr(args, "operation", None) or _last_operation() or "report"
|
| 367 |
+
record = {
|
| 368 |
+
"v": 2,
|
| 369 |
+
"report_id": report_id,
|
| 370 |
+
"ts": ts,
|
| 371 |
+
"voice": voice_name,
|
| 372 |
+
"voice_meta": voice_meta,
|
| 373 |
+
"target": target_basename,
|
| 374 |
+
"target_meta": target_info,
|
| 375 |
+
"operation": operation,
|
| 376 |
+
"status": "ok",
|
| 377 |
+
"message": msg,
|
| 378 |
+
"env": env_info,
|
| 379 |
+
}
|
| 380 |
+
# keep backward compat flat fields for old dataset readers
|
| 381 |
+
if voice_meta:
|
| 382 |
+
record["voice_source"] = _public_source(voice_meta.get("source_hf_model") or voice_meta.get("source"))
|
| 383 |
+
record["voice_tensor"] = voice_meta.get("tensor_name")
|
| 384 |
+
record["voice_dtype"] = voice_meta.get("dtype")
|
| 385 |
+
record["voice_shape"] = voice_meta.get("shape")
|
| 386 |
+
reporter = _sanitize_reporter(getattr(args, "reporter", None))
|
| 387 |
+
if reporter:
|
| 388 |
+
record["reporter"] = reporter
|
| 389 |
+
# auto-reporter from HF token if available (opt-in via --reporter not set)
|
| 390 |
+
if "reporter" not in record:
|
| 391 |
+
# do not auto-fill PII; keep null unless user opted
|
| 392 |
+
pass
|
| 393 |
+
# validate size
|
| 394 |
+
try:
|
| 395 |
+
rec_json = json.dumps(record, ensure_ascii=False)
|
| 396 |
+
if len(rec_json) > 4096:
|
| 397 |
+
_warn(" Note too large β truncating.")
|
| 398 |
+
record["message"] = _sanitize_note((msg or "")[:240])
|
| 399 |
+
except Exception:
|
| 400 |
+
pass
|
| 401 |
+
_append_report(record)
|
| 402 |
+
_ok(f"Thanks for noting β saved locally ({report_id[:8]}β¦) β {ctx.REPORTS_JSONL}")
|
| 403 |
+
_say(f" Outbox: {ctx.REPORTS_OUTBOX / (report_id + '.json')} (private, stays until you choose to share)")
|
| 404 |
+
# also handle --sync of pending
|
| 405 |
+
if getattr(args, "sync", False):
|
| 406 |
+
pending = sorted(ctx.REPORTS_OUTBOX.glob("*.json"))
|
| 407 |
+
if not pending:
|
| 408 |
+
_say(" No pending reports to sync.")
|
| 409 |
+
return
|
| 410 |
+
_step(f"Syncing {len(pending)} pending report(s)β¦")
|
| 411 |
+
recs = []
|
| 412 |
+
for p in pending:
|
| 413 |
+
try:
|
| 414 |
+
recs.append(json.loads(p.read_text()))
|
| 415 |
+
except Exception as e:
|
| 416 |
+
_warn(f" Could not read {p.name}: {e}")
|
| 417 |
+
if recs:
|
| 418 |
+
n = _push_reports_batch(recs)
|
| 419 |
+
_ok(f"Synced {n}/{len(recs)} report(s).")
|
| 420 |
+
return
|
| 421 |
+
# warm preview β what will be shared
|
| 422 |
+
preview = None
|
| 423 |
+
try:
|
| 424 |
+
pv = voice_name or (voice_meta.get("name") if voice_meta else None) or "β"
|
| 425 |
+
pt = (voice_meta.get("tensor_name") if voice_meta else None) or (target_info.get("tensor_name") if target_info else None) or "β"
|
| 426 |
+
ps = voice_meta.get("shape") if voice_meta and voice_meta.get("shape") else (target_info.get("shape") if target_info else None)
|
| 427 |
+
pd = voice_meta.get("dtype") if voice_meta and voice_meta.get("dtype") else (target_info.get("dtype") if target_info else None)
|
| 428 |
+
pv_str = f"voice: {pv}, tensor: {pt}"
|
| 429 |
+
if ps:
|
| 430 |
+
pv_str += f" {ps}"
|
| 431 |
+
if pd:
|
| 432 |
+
pv_str += f" {pd}"
|
| 433 |
+
if target_basename:
|
| 434 |
+
pv_str += f", target: {target_basename}"
|
| 435 |
+
if target_info and target_info.get("family"):
|
| 436 |
+
pv_str += f" {target_info.get('family')}"
|
| 437 |
+
preview = pv_str
|
| 438 |
+
except Exception:
|
| 439 |
+
preview = None
|
| 440 |
+
should_push = _prompt_share(args, preview=preview)
|
| 441 |
+
if should_push:
|
| 442 |
+
_step("Sharing to community to help othersβ¦")
|
| 443 |
+
ok = _push_report(record)
|
| 444 |
+
if ok:
|
| 445 |
+
_ok("Shared to community β thank you! Helps the next person.")
|
| 446 |
+
pending = sorted(ctx.REPORTS_OUTBOX.glob("*.json"))
|
| 447 |
+
if pending:
|
| 448 |
+
recs = []
|
| 449 |
+
for p in pending:
|
| 450 |
+
try:
|
| 451 |
+
recs.append(json.loads(p.read_text()))
|
| 452 |
+
except Exception:
|
| 453 |
+
continue
|
| 454 |
+
if recs:
|
| 455 |
+
_step(f"Syncing {len(recs)} pending report(s)β¦")
|
| 456 |
+
_push_reports_batch(recs)
|
| 457 |
+
# headroom warning
|
| 458 |
+
try:
|
| 459 |
+
sz = ctx.REPORTS_JSONL.stat().st_size if ctx.REPORTS_JSONL.exists() else 0
|
| 460 |
+
if sz > 50 * 1024 * 1024:
|
| 461 |
+
_warn(f" reports.jsonl {sz/1e6:.1f} MB β consider archiving (rotation to reports-YYYY-MM.jsonl).")
|
| 462 |
+
if len(list(ctx.REPORTS_OUTBOX.glob("*.json"))) > 5000:
|
| 463 |
+
_warn(" Outbox >5000 pending β run voice report --sync --yes to flush.")
|
| 464 |
+
except Exception:
|
| 465 |
+
pass
|
| 466 |
+
else:
|
| 467 |
+
_warn(" Share pending β will retry on next report or voice doctor.")
|
| 468 |
+
else:
|
| 469 |
+
_say(" Saved locally. Outbox retained for later push (rerun with --yes to share).")
|
vlib/sources.py
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model sources: naming heuristics, HF metadata, source objects."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
+
import struct
|
| 7 |
+
import urllib.error
|
| 8 |
+
import urllib.request
|
| 9 |
+
from urllib.parse import urlparse
|
| 10 |
+
from vlib import ctx
|
| 11 |
+
from vlib.ui import _fail, _note
|
| 12 |
+
from vlib.net import _hf_auth_headers, http_get_json, http_range
|
| 13 |
+
from vlib.tensors import _gguf_raw_to_f32, _gguf_tensor_f32, _is_gguf_file, _parse_gguf_header, decode_to_f32
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
OUTPUT_ALIASES = (
|
| 17 |
+
"lm_head.weight", "output.weight", "transformer.output_layer.weight",
|
| 18 |
+
"output_layer.weight", "embed_out.weight",
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
EMBEDDING_ALIASES = (
|
| 23 |
+
"model.embed_tokens.weight", "embed_tokens.weight",
|
| 24 |
+
"transformer.embedding.word_embeddings.weight", "wte.weight", "word_embeddings.weight",
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _family(arch):
|
| 29 |
+
a = (arch or "").lower()
|
| 30 |
+
if "glm" in a or "chatglm" in a:
|
| 31 |
+
return "GLM"
|
| 32 |
+
if "gemma4" in a:
|
| 33 |
+
return "Gemma 4"
|
| 34 |
+
if "gemma" in a:
|
| 35 |
+
return "Gemma"
|
| 36 |
+
if "qwen" in a:
|
| 37 |
+
return "Qwen"
|
| 38 |
+
if "llama" in a:
|
| 39 |
+
return "Llama"
|
| 40 |
+
if "gpt2" in a or "gptneo" in a:
|
| 41 |
+
return "GPT"
|
| 42 |
+
if "opt" in a:
|
| 43 |
+
return "OPT"
|
| 44 |
+
return arch or "unknown"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _nested(cfg, *keys):
|
| 48 |
+
for k in keys:
|
| 49 |
+
if isinstance(cfg.get(k), (int, str, list)):
|
| 50 |
+
return cfg[k]
|
| 51 |
+
tc = cfg.get("text_config") or {}
|
| 52 |
+
for k in keys:
|
| 53 |
+
if isinstance(tc.get(k), (int, str, list)):
|
| 54 |
+
return tc[k]
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def detect_output_tensor(names, config):
|
| 59 |
+
if not names:
|
| 60 |
+
return None
|
| 61 |
+
for alias in OUTPUT_ALIASES:
|
| 62 |
+
if alias in names:
|
| 63 |
+
return alias
|
| 64 |
+
for n in names:
|
| 65 |
+
if any(a in n for a in ("lm_head", "output_layer", "embed_out")) and n.endswith("weight"):
|
| 66 |
+
return n
|
| 67 |
+
tie = config.get("tie_word_embeddings") if config else None
|
| 68 |
+
if tie is True:
|
| 69 |
+
for alias in EMBEDDING_ALIASES:
|
| 70 |
+
if alias in names:
|
| 71 |
+
return alias
|
| 72 |
+
# any embedding-like tensor (tied layouts, incl. transformer.wte.weight)
|
| 73 |
+
for n in names:
|
| 74 |
+
if n.endswith(("embed_tokens.weight", "word_embeddings.weight", "wte.weight")):
|
| 75 |
+
return n
|
| 76 |
+
return None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def largest_2d(names, shapes):
|
| 80 |
+
best, best_elems = None, 0
|
| 81 |
+
for n in names:
|
| 82 |
+
s = shapes.get(n)
|
| 83 |
+
if s is not None and len(s) == 2:
|
| 84 |
+
elems = int(s[0]) * int(s[1])
|
| 85 |
+
if elems > best_elems:
|
| 86 |
+
best, best_elems = n, elems
|
| 87 |
+
return best
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _hf_config(model_id):
|
| 91 |
+
try:
|
| 92 |
+
return http_get_json(f"https://huggingface.co/{model_id}/resolve/main/config.json", timeout=30)
|
| 93 |
+
except Exception:
|
| 94 |
+
return None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _hf_license(model_id):
|
| 98 |
+
for url in (f"https://huggingface.co/{model_id}/resolve/main/LICENSE", f"https://huggingface.co/{model_id}/raw/main/LICENSE"):
|
| 99 |
+
try:
|
| 100 |
+
req = urllib.request.Request(url, headers=_hf_auth_headers(url))
|
| 101 |
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
| 102 |
+
text = resp.read(2000).decode("utf-8", "replace").strip()
|
| 103 |
+
if len(text) > 10:
|
| 104 |
+
return text[:200]
|
| 105 |
+
except Exception:
|
| 106 |
+
continue
|
| 107 |
+
return None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _hf_model_index(model_id, rev="main"):
|
| 111 |
+
try:
|
| 112 |
+
return http_get_json(f"https://huggingface.co/{model_id}/resolve/{rev}/model.safetensors.index.json", timeout=20)
|
| 113 |
+
except Exception:
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _normalize_hf_repo(s):
|
| 118 |
+
if not s or not s.strip():
|
| 119 |
+
return None
|
| 120 |
+
s = s.strip().rstrip("/")
|
| 121 |
+
if s.startswith(("http://", "https://")):
|
| 122 |
+
try:
|
| 123 |
+
parts = urlparse(s).path.strip("/").split("/")
|
| 124 |
+
if len(parts) >= 2 and parts[0] and parts[1]:
|
| 125 |
+
return f"{parts[0]}/{parts[1]}"
|
| 126 |
+
except Exception:
|
| 127 |
+
return None
|
| 128 |
+
return None
|
| 129 |
+
s = s.split("@")[0].split(":")[0].strip()
|
| 130 |
+
parts = s.split("/")
|
| 131 |
+
if len(parts) >= 2 and parts[0] and parts[1]:
|
| 132 |
+
return f"{parts[0]}/{parts[1]}"
|
| 133 |
+
return None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _base_paths(base_id):
|
| 137 |
+
norm = _normalize_hf_repo(base_id)
|
| 138 |
+
if norm is None:
|
| 139 |
+
return None
|
| 140 |
+
org, model = (x.lower() for x in norm.split("/"))
|
| 141 |
+
file_path = ctx.VOICES_DIR / "base" / org / f"{model}.safetensors"
|
| 142 |
+
return file_path, ctx.VOICES_DIR / "base" / org / f"{model}.json"
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _hf_api_base_model(source_id):
|
| 146 |
+
try:
|
| 147 |
+
data = http_get_json(f"https://huggingface.co/api/models/{source_id}", timeout=15)
|
| 148 |
+
card = data.get("cardData") or {}
|
| 149 |
+
bm = card.get("base_model") or data.get("base_model")
|
| 150 |
+
if bm is None:
|
| 151 |
+
return None
|
| 152 |
+
if isinstance(bm, str):
|
| 153 |
+
return [bm]
|
| 154 |
+
if isinstance(bm, list):
|
| 155 |
+
return [x for x in bm if isinstance(x, str) and x.strip()]
|
| 156 |
+
except Exception:
|
| 157 |
+
return None
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _hf_mergekit_base(source_id):
|
| 162 |
+
for tmpl in (
|
| 163 |
+
f"https://huggingface.co/{source_id}/raw/main/mergekit_config.yml",
|
| 164 |
+
f"https://huggingface.co/{source_id}/raw/main/mergekit_config.yaml",
|
| 165 |
+
):
|
| 166 |
+
try:
|
| 167 |
+
req = urllib.request.Request(tmpl, headers=_hf_auth_headers(tmpl))
|
| 168 |
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
| 169 |
+
text = resp.read().decode("utf-8", "replace")
|
| 170 |
+
for line in text.splitlines():
|
| 171 |
+
line = line.strip()
|
| 172 |
+
if line.startswith("#"):
|
| 173 |
+
continue
|
| 174 |
+
m = re.search(r'base_model\s*:\s*["\']?\s*([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)', line)
|
| 175 |
+
if m:
|
| 176 |
+
return m.group(1).strip()
|
| 177 |
+
except Exception:
|
| 178 |
+
continue
|
| 179 |
+
return None
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _detect_base(source_id):
|
| 183 |
+
raw = _hf_api_base_model(source_id)
|
| 184 |
+
if not raw:
|
| 185 |
+
return None, False, 0, []
|
| 186 |
+
if len(raw) == 1:
|
| 187 |
+
return raw[0], False, 1, raw
|
| 188 |
+
tried = _hf_mergekit_base(source_id)
|
| 189 |
+
if tried:
|
| 190 |
+
return tried, True, len(raw), raw
|
| 191 |
+
return raw[0], True, len(raw), raw
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class TensorRef:
|
| 195 |
+
__slots__ = ("name", "dtype", "shape")
|
| 196 |
+
|
| 197 |
+
def __init__(self, name, dtype, shape):
|
| 198 |
+
self.name = name
|
| 199 |
+
self.dtype = dtype
|
| 200 |
+
self.shape = tuple(int(x) for x in shape)
|
| 201 |
+
|
| 202 |
+
def __repr__(self):
|
| 203 |
+
return f"TensorRef({self.name!r}, {self.dtype}, {self.shape})"
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
class SafetensorsSource:
|
| 207 |
+
kind = "safetensors"
|
| 208 |
+
|
| 209 |
+
def __init__(self, path=None, repo=None, url=None, rev=None):
|
| 210 |
+
self.path = str(path) if path else None
|
| 211 |
+
self.repo = repo
|
| 212 |
+
self.url = url
|
| 213 |
+
self.rev = rev or "main"
|
| 214 |
+
self._header = None
|
| 215 |
+
self._header_size = None
|
| 216 |
+
self._index = None # weight_map for sharded repos
|
| 217 |
+
self._shard_headers = {} # shard file -> (header_size, tensors)
|
| 218 |
+
|
| 219 |
+
def _local(self):
|
| 220 |
+
"""(header_size, tensors) for a local file β read once, then cached."""
|
| 221 |
+
if self.path and self._header_size is None:
|
| 222 |
+
with open(self.path, "rb") as f:
|
| 223 |
+
self._header_size = struct.unpack("<Q", f.read(8))[0]
|
| 224 |
+
self._header = json.loads(f.read(self._header_size))
|
| 225 |
+
return self._header_size, {k: v for k, v in self._header.items() if k != "__metadata__"}
|
| 226 |
+
|
| 227 |
+
def _single_url(self):
|
| 228 |
+
if self.url:
|
| 229 |
+
return self.url
|
| 230 |
+
return f"https://huggingface.co/{self.repo}/resolve/{self.rev}/model.safetensors"
|
| 231 |
+
|
| 232 |
+
def _load_index(self):
|
| 233 |
+
if self._index is not None or self.path or self.url:
|
| 234 |
+
return
|
| 235 |
+
idx = _hf_model_index(self.repo, self.rev)
|
| 236 |
+
self._index = idx.get("weight_map") if idx else None
|
| 237 |
+
|
| 238 |
+
def names(self):
|
| 239 |
+
if self.path:
|
| 240 |
+
return list(self._local()[1].keys())
|
| 241 |
+
if self.repo and self.url is None:
|
| 242 |
+
self._load_index()
|
| 243 |
+
if self._index is not None:
|
| 244 |
+
return list(self._index.keys())
|
| 245 |
+
# single remote file
|
| 246 |
+
self._load_single_header()
|
| 247 |
+
return list(self._tensors().keys())
|
| 248 |
+
|
| 249 |
+
def _load_single_header(self):
|
| 250 |
+
if self._header is not None:
|
| 251 |
+
return
|
| 252 |
+
raw, _st, _e = http_range(self._single_url(), 0, 7)
|
| 253 |
+
self._header_size = struct.unpack("<Q", raw)[0]
|
| 254 |
+
raw, _st, _e = http_range(self._single_url(), 8, 8 + self._header_size - 1)
|
| 255 |
+
self._header = json.loads(raw)
|
| 256 |
+
|
| 257 |
+
def _tensors(self):
|
| 258 |
+
return {k: v for k, v in self._header.items() if k != "__metadata__"}
|
| 259 |
+
|
| 260 |
+
def _shard_info(self, name):
|
| 261 |
+
"""Return (url, header_size, tensors) for the shard containing `name`."""
|
| 262 |
+
if self.path:
|
| 263 |
+
hs, tensors = self._local()
|
| 264 |
+
return None, hs, tensors
|
| 265 |
+
if self.url:
|
| 266 |
+
self._load_single_header()
|
| 267 |
+
return self._single_url(), self._header_size, self._tensors()
|
| 268 |
+
# repo
|
| 269 |
+
self._load_index()
|
| 270 |
+
if self._index is None:
|
| 271 |
+
self._load_single_header()
|
| 272 |
+
return self._single_url(), self._header_size, self._tensors()
|
| 273 |
+
shard = self._index.get(name)
|
| 274 |
+
if shard is None:
|
| 275 |
+
raise KeyError(name)
|
| 276 |
+
if shard not in self._shard_headers:
|
| 277 |
+
url = f"https://huggingface.co/{self.repo}/resolve/{self.rev}/{shard}"
|
| 278 |
+
raw, _st, _e = http_range(url, 0, 7)
|
| 279 |
+
hs = struct.unpack("<Q", raw)[0]
|
| 280 |
+
raw, _st, _e = http_range(url, 8, 8 + hs - 1)
|
| 281 |
+
tensors = {k: v for k, v in json.loads(raw).items() if k != "__metadata__"}
|
| 282 |
+
self._shard_headers[shard] = (url, hs, tensors)
|
| 283 |
+
return self._shard_headers[shard]
|
| 284 |
+
|
| 285 |
+
def ref(self, name):
|
| 286 |
+
if self.path:
|
| 287 |
+
info = self._local()[1][name]
|
| 288 |
+
return TensorRef(name, info["dtype"], info["shape"])
|
| 289 |
+
url, hs, tensors = self._shard_info(name)
|
| 290 |
+
info = tensors[name]
|
| 291 |
+
return TensorRef(name, info["dtype"], info["shape"])
|
| 292 |
+
|
| 293 |
+
def read_raw(self, name):
|
| 294 |
+
if self.path:
|
| 295 |
+
hs, tensors = self._local()
|
| 296 |
+
info = tensors[name]
|
| 297 |
+
begin, end = info["data_offsets"]
|
| 298 |
+
with open(self.path, "rb") as f:
|
| 299 |
+
f.seek(8 + hs + begin)
|
| 300 |
+
return f.read(end - begin)
|
| 301 |
+
url, hs, tensors = self._shard_info(name)
|
| 302 |
+
info = tensors[name]
|
| 303 |
+
begin, end = info["data_offsets"]
|
| 304 |
+
data, _st, _e = http_range(url, 8 + hs + begin, 8 + hs + end - 1, timeout=300)
|
| 305 |
+
return data
|
| 306 |
+
|
| 307 |
+
def read_raw_slice(self, name, byte_off, byte_len):
|
| 308 |
+
"""Minimal slice read: byte_off/byte_len relative to the tensor's data."""
|
| 309 |
+
if self.path:
|
| 310 |
+
hs, tensors = self._local()
|
| 311 |
+
info = tensors[name]
|
| 312 |
+
begin = info["data_offsets"][0] + byte_off
|
| 313 |
+
with open(self.path, "rb") as f:
|
| 314 |
+
f.seek(8 + hs + begin)
|
| 315 |
+
return f.read(byte_len)
|
| 316 |
+
url, hs, tensors = self._shard_info(name)
|
| 317 |
+
info = tensors[name]
|
| 318 |
+
begin = info["data_offsets"][0] + byte_off
|
| 319 |
+
data, _st, _e = http_range(url, 8 + hs + begin, 8 + hs + begin + byte_len - 1, timeout=300)
|
| 320 |
+
return data
|
| 321 |
+
|
| 322 |
+
def read_f32(self, name):
|
| 323 |
+
info = self.ref(name)
|
| 324 |
+
return decode_to_f32(self.read_raw(name), info.dtype, info.shape)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
class GgufSource:
|
| 328 |
+
kind = "gguf"
|
| 329 |
+
|
| 330 |
+
def __init__(self, path=None, url=None, rev=None):
|
| 331 |
+
self.path = str(path) if path else None
|
| 332 |
+
self.url = url
|
| 333 |
+
self.rev = rev or "main"
|
| 334 |
+
self._reader = None
|
| 335 |
+
self._remote = None
|
| 336 |
+
|
| 337 |
+
def _load(self):
|
| 338 |
+
if self.path:
|
| 339 |
+
if self._reader is None:
|
| 340 |
+
from gguf import GGUFReader
|
| 341 |
+
self._reader = GGUFReader(self.path)
|
| 342 |
+
elif self._remote is None:
|
| 343 |
+
self._remote = _parse_gguf_header(self.url)
|
| 344 |
+
|
| 345 |
+
def names(self):
|
| 346 |
+
self._load()
|
| 347 |
+
if self._reader is not None:
|
| 348 |
+
return [t.name for t in self._reader.tensors]
|
| 349 |
+
return list(self._remote[2].keys())
|
| 350 |
+
|
| 351 |
+
def ref(self, name):
|
| 352 |
+
from gguf.constants import GGMLQuantizationType as Q
|
| 353 |
+
self._load()
|
| 354 |
+
if self._reader is not None:
|
| 355 |
+
for t in self._reader.tensors:
|
| 356 |
+
if t.name == name:
|
| 357 |
+
return TensorRef(name, t.tensor_type.name, tuple(reversed(t.shape)))
|
| 358 |
+
raise KeyError(name)
|
| 359 |
+
dims, gtype, _off, _nbytes = self._remote[2][name]
|
| 360 |
+
return TensorRef(name, Q(gtype).name, tuple(reversed(dims)))
|
| 361 |
+
|
| 362 |
+
def read_f32(self, name):
|
| 363 |
+
from gguf.constants import GGMLQuantizationType as Q
|
| 364 |
+
self._load()
|
| 365 |
+
if self._reader is not None:
|
| 366 |
+
for t in self._reader.tensors:
|
| 367 |
+
if t.name == name:
|
| 368 |
+
return _gguf_tensor_f32(t)
|
| 369 |
+
raise KeyError(name)
|
| 370 |
+
dims, gtype, data_offset, n_bytes = self._remote[2][name]
|
| 371 |
+
start = self._remote[1] + data_offset
|
| 372 |
+
raw, _st, _e = http_range(self.url, start, start + n_bytes - 1, timeout=300)
|
| 373 |
+
return _gguf_raw_to_f32(raw, Q(gtype), dims)
|
| 374 |
+
|
| 375 |
+
def read_raw(self, name):
|
| 376 |
+
"""Exact tensor bytes in file order (any quant, incl. K/IQ blocks)."""
|
| 377 |
+
import numpy as np
|
| 378 |
+
if self.path:
|
| 379 |
+
self._load()
|
| 380 |
+
for t in self._reader.tensors:
|
| 381 |
+
if t.name == name:
|
| 382 |
+
return t.data.tobytes() if t.data.dtype == np.uint8 else memoryview(t.data).cast("B").tobytes()
|
| 383 |
+
raise KeyError(name)
|
| 384 |
+
dims, gtype, data_offset, n_bytes = self._remote[2][name]
|
| 385 |
+
start = self._remote[1] + data_offset
|
| 386 |
+
raw, _st, _e = http_range(self.url, start, start + n_bytes - 1, timeout=300)
|
| 387 |
+
return raw
|
| 388 |
+
|
| 389 |
+
def read_raw_slice(self, name, byte_off, byte_len):
|
| 390 |
+
"""Minimal slice read: byte_off/byte_len relative to the tensor's raw bytes."""
|
| 391 |
+
import numpy as np
|
| 392 |
+
if self.path:
|
| 393 |
+
self._load()
|
| 394 |
+
for t in self._reader.tensors:
|
| 395 |
+
if t.name == name:
|
| 396 |
+
try:
|
| 397 |
+
# flatten to bytes FIRST: bare mv[i:j] slices dim-0 rows, not bytes
|
| 398 |
+
mv = memoryview(t.data).cast("B")
|
| 399 |
+
return mv[byte_off:byte_off + byte_len].tobytes()
|
| 400 |
+
except Exception:
|
| 401 |
+
return self.read_raw(name)[byte_off:byte_off + byte_len]
|
| 402 |
+
raise KeyError(name)
|
| 403 |
+
dims, gtype, data_offset, n_bytes = self._remote[2][name]
|
| 404 |
+
start = self._remote[1] + data_offset + byte_off
|
| 405 |
+
raw, _st, _e = http_range(self.url, start, start + byte_len - 1, timeout=300)
|
| 406 |
+
return raw
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def open_source(arg, rev=None):
|
| 410 |
+
p = Path(arg).expanduser()
|
| 411 |
+
if p.exists():
|
| 412 |
+
if p.is_dir():
|
| 413 |
+
ggufs = sorted(p.glob("*.gguf"))
|
| 414 |
+
if ggufs:
|
| 415 |
+
return GgufSource(path=str(ggufs[0]))
|
| 416 |
+
if (p / "model.safetensors").exists():
|
| 417 |
+
return SafetensorsSource(path=str(p / "model.safetensors"))
|
| 418 |
+
_fail(f" β No model.safetensors or .gguf found in {arg}.")
|
| 419 |
+
if p.is_file():
|
| 420 |
+
return GgufSource(path=str(p)) if _is_gguf_file(str(p)) else SafetensorsSource(path=str(p))
|
| 421 |
+
if arg.startswith(("http://", "https://")):
|
| 422 |
+
if arg.rstrip("/").split("/")[-1].endswith(".gguf"):
|
| 423 |
+
return GgufSource(url=arg)
|
| 424 |
+
return SafetensorsSource(url=arg)
|
| 425 |
+
return _source_from_repo(arg.strip().rstrip("/"), rev=rev)
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def _source_from_repo(repo, rev=None):
|
| 429 |
+
rev = rev or "main"
|
| 430 |
+
try:
|
| 431 |
+
tree = http_get_json(f"https://huggingface.co/api/models/{repo}/tree/{rev}", timeout=20)
|
| 432 |
+
files = [f.get("path", "") for f in tree if f.get("path")]
|
| 433 |
+
ggufs = [f for f in files if f.endswith(".gguf")]
|
| 434 |
+
if ggufs:
|
| 435 |
+
chosen = _pick_gguf(ggufs)
|
| 436 |
+
if ctx.VERBOSE:
|
| 437 |
+
_note(f"GGUF repo β using {chosen}")
|
| 438 |
+
return GgufSource(url=f"https://huggingface.co/{repo}/resolve/{rev}/{chosen}", rev=rev)
|
| 439 |
+
if any(f.endswith(".safetensors") for f in files):
|
| 440 |
+
return SafetensorsSource(repo=repo, rev=rev)
|
| 441 |
+
if files:
|
| 442 |
+
_fail(f" β '{repo}' has no .safetensors or .gguf files (found: {', '.join(files[:6])}{'β¦' if len(files) > 6 else ''}).")
|
| 443 |
+
except SystemExit:
|
| 444 |
+
raise
|
| 445 |
+
except Exception:
|
| 446 |
+
pass
|
| 447 |
+
return SafetensorsSource(repo=repo, rev=rev)
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def _pick_gguf(files):
|
| 451 |
+
files = sorted(files)
|
| 452 |
+
for key in ("fp16", "f16", "q8_0"):
|
| 453 |
+
for f in files:
|
| 454 |
+
if key in f.lower():
|
| 455 |
+
return f
|
| 456 |
+
return files[0]
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def _sanitize_voice_name(name):
|
| 460 |
+
if not name or not name.strip():
|
| 461 |
+
return None
|
| 462 |
+
name = name.strip()
|
| 463 |
+
if name.lower().startswith("delta-") or name in (".", ".."):
|
| 464 |
+
return None
|
| 465 |
+
bad = set("/\\.: ")
|
| 466 |
+
for c in name:
|
| 467 |
+
if c in bad or ord(c) < 32:
|
| 468 |
+
return None
|
| 469 |
+
return name
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
_NAME_RULES = "Use letters, numbers, hyphens, underscores (no spaces, /, \\, dots, colons; not starting with delta-)."
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
def _public_source(ref):
|
| 476 |
+
"""Share-safe form of a model reference for voice.json/reports.
|
| 477 |
+
HF ids pass through; URLs lose userinfo/query/fragment; local paths
|
| 478 |
+
collapse to basename (directories leak usernames)."""
|
| 479 |
+
s = (ref or "").strip()
|
| 480 |
+
if not s or s == "β":
|
| 481 |
+
return s
|
| 482 |
+
try:
|
| 483 |
+
u = urlparse(s)
|
| 484 |
+
if u.scheme in ("http", "https"):
|
| 485 |
+
net = u.hostname or ""
|
| 486 |
+
if u.port:
|
| 487 |
+
net += f":{u.port}"
|
| 488 |
+
return f"{u.scheme}://{net}{u.path}"
|
| 489 |
+
if u.scheme == "file":
|
| 490 |
+
return Path(u.path).name or s
|
| 491 |
+
except Exception:
|
| 492 |
+
pass
|
| 493 |
+
if (s.startswith(("/", "~", ".")) or "\\" in s or len(s) > 2 and s[1] == ":"
|
| 494 |
+
or Path(s).expanduser().exists()):
|
| 495 |
+
return Path(s).name or s
|
| 496 |
+
return s
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
def _slugify_voice_name(raw, fallback="voice"):
|
| 500 |
+
"""Derive a valid voice name from arbitrary input (filenames, model IDs).
|
| 501 |
+
Lowercases, maps runs of invalid chars to '-', collapses, trims to 64 chars."""
|
| 502 |
+
s = str(raw or "").strip().lower()
|
| 503 |
+
if not s:
|
| 504 |
+
return fallback
|
| 505 |
+
s = re.sub(r"[^a-z0-9_-]+", "-", s)
|
| 506 |
+
s = re.sub(r"-+", "-", s).strip("-_")[:64]
|
| 507 |
+
return s or fallback
|
vlib/tensors.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tensor file-format primitives: safetensors, GGUF headers, dtype math, quant predicates."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import struct
|
| 7 |
+
import urllib.error
|
| 8 |
+
import urllib.request
|
| 9 |
+
from vlib.ui import _fail
|
| 10 |
+
from vlib.net import _hf_auth_headers
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _validate_safetensors_header(header, file_size, header_size):
|
| 14 |
+
"""Validate data_offsets are sane. Raises ValueError on corruption."""
|
| 15 |
+
tensors = {k: v for k, v in header.items() if k != "__metadata__"}
|
| 16 |
+
data_len = file_size - 8 - header_size
|
| 17 |
+
if data_len < 0:
|
| 18 |
+
raise ValueError(f"header {header_size} exceeds file {file_size}")
|
| 19 |
+
# collect and validate offsets
|
| 20 |
+
intervals = []
|
| 21 |
+
for name, info in tensors.items():
|
| 22 |
+
if not isinstance(info.get("data_offsets"), (list, tuple)) or len(info["data_offsets"]) != 2:
|
| 23 |
+
raise ValueError(f"tensor {name!r} missing data_offsets")
|
| 24 |
+
begin, end = info["data_offsets"]
|
| 25 |
+
if not isinstance(begin, int) or not isinstance(end, int) or begin < 0 or end < begin or end > data_len:
|
| 26 |
+
raise ValueError(f"tensor {name!r} offsets [{begin},{end}) out of bounds (data_len {data_len})")
|
| 27 |
+
shape = info.get("shape")
|
| 28 |
+
dtype = info.get("dtype")
|
| 29 |
+
if dtype is not None:
|
| 30 |
+
try:
|
| 31 |
+
bpe = dtype_bytes(dtype)
|
| 32 |
+
except ValueError:
|
| 33 |
+
raise ValueError(f"tensor {name!r} unknown dtype {dtype!r}")
|
| 34 |
+
if isinstance(shape, (list, tuple)):
|
| 35 |
+
prod = 1
|
| 36 |
+
for d in shape:
|
| 37 |
+
prod *= int(d)
|
| 38 |
+
expected = prod * bpe
|
| 39 |
+
if expected != (end - begin):
|
| 40 |
+
raise ValueError(f"tensor {name!r} shape {shape} dtype {dtype} expects {expected} bytes, got {end-begin}")
|
| 41 |
+
intervals.append((begin, end, name))
|
| 42 |
+
intervals.sort()
|
| 43 |
+
for i in range(1, len(intervals)):
|
| 44 |
+
if intervals[i][0] < intervals[i-1][1]:
|
| 45 |
+
raise ValueError(f"overlapping offsets: {intervals[i-1][2]!r} and {intervals[i][2]!r}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def read_safetensors(path):
|
| 49 |
+
try:
|
| 50 |
+
file_size = os.path.getsize(path)
|
| 51 |
+
with open(path, "rb") as f:
|
| 52 |
+
raw = f.read(8)
|
| 53 |
+
if len(raw) < 8:
|
| 54 |
+
raise ValueError("file too short")
|
| 55 |
+
header_size = struct.unpack("<Q", raw)[0]
|
| 56 |
+
if header_size <= 0 or header_size + 8 > file_size:
|
| 57 |
+
raise ValueError(f"header_size {header_size} not sane for {file_size} bytes")
|
| 58 |
+
header = json.loads(f.read(header_size))
|
| 59 |
+
_validate_safetensors_header(header, file_size, header_size)
|
| 60 |
+
except (json.JSONDecodeError, ValueError, OSError, struct.error) as exc:
|
| 61 |
+
raise ValueError(f"{path} is not a valid .safetensors file.") from exc
|
| 62 |
+
return header, {k: v for k, v in header.items() if k != "__metadata__"}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def write_safetensors(tensors, out_path):
|
| 66 |
+
header, offset = {}, 0
|
| 67 |
+
for name, (data, dtype, shape) in tensors.items():
|
| 68 |
+
n = len(data)
|
| 69 |
+
header[name] = {"dtype": dtype, "shape": list(shape), "data_offsets": [offset, offset + n]}
|
| 70 |
+
offset += n
|
| 71 |
+
hj = json.dumps(header).encode("utf-8")
|
| 72 |
+
# atomic write via tmp + fsync
|
| 73 |
+
tmp_path = str(out_path) + ".tmp"
|
| 74 |
+
with open(tmp_path, "wb") as f:
|
| 75 |
+
f.write(struct.pack("<Q", len(hj)))
|
| 76 |
+
f.write(hj)
|
| 77 |
+
for name in tensors:
|
| 78 |
+
f.write(tensors[name][0])
|
| 79 |
+
f.flush()
|
| 80 |
+
try:
|
| 81 |
+
os.fsync(f.fileno())
|
| 82 |
+
except Exception:
|
| 83 |
+
pass
|
| 84 |
+
os.replace(tmp_path, str(out_path))
|
| 85 |
+
try:
|
| 86 |
+
# fsync parent dir for durability
|
| 87 |
+
dirfd = os.open(os.path.dirname(os.path.abspath(str(out_path))) or ".", os.O_DIRECTORY)
|
| 88 |
+
try:
|
| 89 |
+
os.fsync(dirfd)
|
| 90 |
+
finally:
|
| 91 |
+
os.close(dirfd)
|
| 92 |
+
except Exception:
|
| 93 |
+
pass
|
| 94 |
+
return os.path.getsize(out_path)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _write_safetensors_streaming(tensor_name, src_path, src_total, dtype, shape, out_path):
|
| 98 |
+
header = {tensor_name: {"dtype": dtype, "shape": list(shape), "data_offsets": [0, src_total]}}
|
| 99 |
+
hj = json.dumps(header).encode("utf-8")
|
| 100 |
+
with open(out_path, "wb") as out, open(src_path, "rb") as inp:
|
| 101 |
+
out.write(struct.pack("<Q", len(hj)))
|
| 102 |
+
out.write(hj)
|
| 103 |
+
while True:
|
| 104 |
+
b = inp.read(8 << 20)
|
| 105 |
+
if not b:
|
| 106 |
+
break
|
| 107 |
+
out.write(b)
|
| 108 |
+
out.flush()
|
| 109 |
+
try:
|
| 110 |
+
os.fsync(out.fileno())
|
| 111 |
+
except Exception:
|
| 112 |
+
pass
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def rewrite_safetensors_tensor(target_path, out_path, replace_name, replace_bytes, replace_dtype, replace_shape):
|
| 116 |
+
"""Copy target_path -> out_path, replacing one tensor's bytes. The tensor's
|
| 117 |
+
dtype/shape may change but its byte size must stay identical (offsets fixed)."""
|
| 118 |
+
with open(target_path, "rb") as f:
|
| 119 |
+
header_size = struct.unpack("<Q", f.read(8))[0]
|
| 120 |
+
raw_hdr = f.read(header_size)
|
| 121 |
+
if len(raw_hdr) != header_size:
|
| 122 |
+
_fail(f" β Truncated header in {target_path}.")
|
| 123 |
+
header = json.loads(raw_hdr)
|
| 124 |
+
_validate_safetensors_header(header, os.path.getsize(target_path), header_size)
|
| 125 |
+
tensors = {k: v for k, v in header.items() if k != "__metadata__"}
|
| 126 |
+
if replace_name not in tensors:
|
| 127 |
+
_fail(f" β Tensor '{replace_name}' not found in {target_path}.")
|
| 128 |
+
old_n = tensors[replace_name]["data_offsets"][1] - tensors[replace_name]["data_offsets"][0]
|
| 129 |
+
if len(replace_bytes) != old_n:
|
| 130 |
+
_fail(f" β Byte-size mismatch replacing '{replace_name}' ({len(replace_bytes)} vs {old_n}).")
|
| 131 |
+
new_header = dict(header)
|
| 132 |
+
offset = 0
|
| 133 |
+
for name in header:
|
| 134 |
+
if name == "__metadata__":
|
| 135 |
+
continue
|
| 136 |
+
if name == replace_name:
|
| 137 |
+
new_header[name] = {"dtype": replace_dtype, "shape": list(replace_shape), "data_offsets": [offset, offset + len(replace_bytes)]}
|
| 138 |
+
else:
|
| 139 |
+
n = tensors[name]["data_offsets"][1] - tensors[name]["data_offsets"][0]
|
| 140 |
+
new_header[name] = {"dtype": tensors[name]["dtype"], "shape": tensors[name]["shape"], "data_offsets": [offset, offset + n]}
|
| 141 |
+
offset += new_header[name]["data_offsets"][1] - new_header[name]["data_offsets"][0]
|
| 142 |
+
hj = json.dumps(new_header).encode("utf-8")
|
| 143 |
+
data_start = 8 + header_size
|
| 144 |
+
with open(target_path, "rb") as src, open(out_path, "wb") as out:
|
| 145 |
+
out.write(struct.pack("<Q", len(hj)))
|
| 146 |
+
out.write(hj)
|
| 147 |
+
for name in header:
|
| 148 |
+
if name == "__metadata__":
|
| 149 |
+
continue
|
| 150 |
+
begin, end = tensors[name]["data_offsets"]
|
| 151 |
+
if name == replace_name:
|
| 152 |
+
out.write(replace_bytes)
|
| 153 |
+
src.seek(data_start + end)
|
| 154 |
+
else:
|
| 155 |
+
src.seek(data_start + begin)
|
| 156 |
+
remaining = end - begin
|
| 157 |
+
while remaining:
|
| 158 |
+
b = src.read(min(8 << 20, remaining))
|
| 159 |
+
if not b:
|
| 160 |
+
_fail(f" β Truncated read for tensor '{name}' in {target_path} (missing {remaining} bytes).")
|
| 161 |
+
out.write(b)
|
| 162 |
+
remaining -= len(b)
|
| 163 |
+
out.flush()
|
| 164 |
+
try:
|
| 165 |
+
os.fsync(out.fileno())
|
| 166 |
+
except Exception:
|
| 167 |
+
pass
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _safetensors_write_repl(target_path, repl, out_path):
|
| 171 |
+
"""Single-pass safetensors rewrite. repl maps tensor name -> (bytes, dtype, shape).
|
| 172 |
+
Streams the target once: replacement bytes spliced in, unchanged bytes pass through.
|
| 173 |
+
Atomic via tmp+fsync+replace. Same per-tensor guards as rewrite_safetensors_tensor."""
|
| 174 |
+
with open(target_path, "rb") as f:
|
| 175 |
+
header_size = struct.unpack("<Q", f.read(8))[0]
|
| 176 |
+
raw_hdr = f.read(header_size)
|
| 177 |
+
if len(raw_hdr) != header_size:
|
| 178 |
+
_fail(f" β Truncated header in {target_path}.")
|
| 179 |
+
header = json.loads(raw_hdr)
|
| 180 |
+
_validate_safetensors_header(header, os.path.getsize(target_path), header_size)
|
| 181 |
+
tensors = {k: v for k, v in header.items() if k != "__metadata__"}
|
| 182 |
+
for name in repl:
|
| 183 |
+
if name not in tensors:
|
| 184 |
+
_fail(f" β Tensor '{name}' not found in {target_path}.")
|
| 185 |
+
old_n = tensors[name]["data_offsets"][1] - tensors[name]["data_offsets"][0]
|
| 186 |
+
if len(repl[name][0]) != old_n:
|
| 187 |
+
_fail(f" β Byte-size mismatch replacing '{name}' ({len(repl[name][0])} vs {old_n}).")
|
| 188 |
+
new_header = dict(header)
|
| 189 |
+
offset = 0
|
| 190 |
+
for name in header:
|
| 191 |
+
if name == "__metadata__":
|
| 192 |
+
continue
|
| 193 |
+
if name in repl:
|
| 194 |
+
_rb, _rd, _rs = repl[name]
|
| 195 |
+
new_header[name] = {"dtype": _rd, "shape": list(_rs), "data_offsets": [offset, offset + len(_rb)]}
|
| 196 |
+
else:
|
| 197 |
+
n = tensors[name]["data_offsets"][1] - tensors[name]["data_offsets"][0]
|
| 198 |
+
new_header[name] = {"dtype": tensors[name]["dtype"], "shape": tensors[name]["shape"], "data_offsets": [offset, offset + n]}
|
| 199 |
+
offset += new_header[name]["data_offsets"][1] - new_header[name]["data_offsets"][0]
|
| 200 |
+
hj = json.dumps(new_header).encode("utf-8")
|
| 201 |
+
data_start = 8 + header_size
|
| 202 |
+
tmp_path = str(out_path) + ".tmp"
|
| 203 |
+
with open(target_path, "rb") as src, open(tmp_path, "wb") as out:
|
| 204 |
+
out.write(struct.pack("<Q", len(hj)))
|
| 205 |
+
out.write(hj)
|
| 206 |
+
for name in header:
|
| 207 |
+
if name == "__metadata__":
|
| 208 |
+
continue
|
| 209 |
+
begin, end = tensors[name]["data_offsets"]
|
| 210 |
+
if name in repl:
|
| 211 |
+
out.write(repl[name][0])
|
| 212 |
+
src.seek(data_start + end)
|
| 213 |
+
else:
|
| 214 |
+
src.seek(data_start + begin)
|
| 215 |
+
remaining = end - begin
|
| 216 |
+
while remaining:
|
| 217 |
+
b = src.read(min(8 << 20, remaining))
|
| 218 |
+
if not b:
|
| 219 |
+
_fail(f" β Truncated read for tensor '{name}' in {target_path} (missing {remaining} bytes).")
|
| 220 |
+
out.write(b)
|
| 221 |
+
remaining -= len(b)
|
| 222 |
+
out.flush()
|
| 223 |
+
try:
|
| 224 |
+
os.fsync(out.fileno())
|
| 225 |
+
except Exception:
|
| 226 |
+
pass
|
| 227 |
+
os.replace(tmp_path, str(out_path))
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
_DTYPE_BYTES_MAP = {
|
| 231 |
+
"F32": 4, "FP32": 4, "F64": 8,
|
| 232 |
+
"F16": 2, "FP16": 2, "BF16": 2, "BF16E8M": 2, "F16E5M6": 2,
|
| 233 |
+
"I8": 1, "U8": 1, "BOOL": 1, "I16": 2, "I32": 4, "I64": 8,
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def dtype_bytes(dtype):
|
| 238 |
+
try:
|
| 239 |
+
return _DTYPE_BYTES_MAP[dtype.upper()]
|
| 240 |
+
except KeyError:
|
| 241 |
+
raise ValueError(f"unsupported dtype {dtype}")
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def bf16_to_f32(u16):
|
| 245 |
+
import numpy as np
|
| 246 |
+
return (u16.astype(np.uint32) << 16).view(np.float32).copy()
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def decode_to_f32(raw, dtype, shape):
|
| 250 |
+
import numpy as np
|
| 251 |
+
d = dtype.upper()
|
| 252 |
+
# length sanity: prevent silent truncation or over-read
|
| 253 |
+
try:
|
| 254 |
+
bpe = dtype_bytes(d)
|
| 255 |
+
except ValueError:
|
| 256 |
+
raise
|
| 257 |
+
prod = 1
|
| 258 |
+
for dim in shape:
|
| 259 |
+
prod *= int(dim)
|
| 260 |
+
expected = prod * bpe
|
| 261 |
+
if len(raw) != expected:
|
| 262 |
+
raise ValueError(f"decode length mismatch for {dtype} {shape}: got {len(raw)} bytes, expected {expected}")
|
| 263 |
+
if d in ("BF16", "BF16E8M"):
|
| 264 |
+
return bf16_to_f32(np.frombuffer(raw, dtype=np.uint16)).reshape(shape)
|
| 265 |
+
if d in ("F16", "FP16", "F16E5M6"):
|
| 266 |
+
return np.frombuffer(raw, dtype=np.float16).reshape(shape).astype(np.float32)
|
| 267 |
+
if d in ("F32", "FP32"):
|
| 268 |
+
return np.frombuffer(raw, dtype=np.float32).reshape(shape).astype(np.float32)
|
| 269 |
+
raise ValueError(f"unsupported dtype {dtype}")
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def encode_from_f32(arr, dtype):
|
| 273 |
+
import numpy as np
|
| 274 |
+
d = dtype.upper()
|
| 275 |
+
if d in ("F32", "FP32"):
|
| 276 |
+
return arr.astype(np.float32).tobytes()
|
| 277 |
+
if d in ("F16", "FP16", "F16E5M6"):
|
| 278 |
+
return arr.astype(np.float16).tobytes()
|
| 279 |
+
if d in ("BF16", "BF16E8M"):
|
| 280 |
+
u32 = arr.astype(np.float32).view(np.uint32)
|
| 281 |
+
# round-to-nearest-even (not truncation): matches standard BF16 cast
|
| 282 |
+
rounded = (u32 + 0x7FFF + ((u32 >> 16) & 1)) >> 16
|
| 283 |
+
return rounded.astype(np.uint16).tobytes()
|
| 284 |
+
raise ValueError(f"unsupported dtype {dtype}")
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _gguf_tensor_f32(t):
|
| 288 |
+
import numpy as np
|
| 289 |
+
from gguf.constants import GGMLQuantizationType as Q
|
| 290 |
+
from gguf.quants import dequantize
|
| 291 |
+
if t.tensor_type == Q.F32:
|
| 292 |
+
return np.ascontiguousarray(t.data, dtype=np.float32)
|
| 293 |
+
if t.tensor_type == Q.F16:
|
| 294 |
+
return t.data.astype(np.float32)
|
| 295 |
+
return dequantize(t.data, t.tensor_type)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def _gguf_raw_to_f32(raw, ggml_type, dims):
|
| 299 |
+
import numpy as np
|
| 300 |
+
from gguf.constants import GGMLQuantizationType as Q
|
| 301 |
+
from gguf.quants import dequantize, quant_shape_to_byte_shape
|
| 302 |
+
rshape = tuple(reversed(dims))
|
| 303 |
+
if ggml_type == Q.F32:
|
| 304 |
+
return np.frombuffer(raw, dtype=np.float32).reshape(rshape).astype(np.float32)
|
| 305 |
+
if ggml_type == Q.F16:
|
| 306 |
+
return np.frombuffer(raw, dtype=np.float16).reshape(rshape).astype(np.float32)
|
| 307 |
+
return dequantize(np.frombuffer(raw, dtype=np.uint8).reshape(quant_shape_to_byte_shape(rshape, ggml_type)), ggml_type)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def _gguf_raw_slice_to_f32(raw, ggml_type, off, n):
|
| 311 |
+
"""Dequantize one element slice [off, off+n) of a quant tensor's raw bytes.
|
| 312 |
+
Expands to block alignment (blocks decode independently), dequantizes only the
|
| 313 |
+
covering blocks, then cuts the exact slice. Peak F32 is O(chunk), not O(tensor)."""
|
| 314 |
+
import numpy as np
|
| 315 |
+
from gguf.constants import GGML_QUANT_SIZES
|
| 316 |
+
from gguf.quants import dequantize
|
| 317 |
+
block_size, type_size = GGML_QUANT_SIZES[ggml_type]
|
| 318 |
+
u8 = np.frombuffer(raw, dtype=np.uint8)
|
| 319 |
+
b0 = off // block_size
|
| 320 |
+
b1 = (off + n + block_size - 1) // block_size
|
| 321 |
+
f32 = dequantize(u8[b0 * type_size:b1 * type_size].reshape(-1, type_size), ggml_type).reshape(-1)
|
| 322 |
+
return f32[off - b0 * block_size:off - b0 * block_size + n].astype(np.float32)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
_DELTA_CHUNK_ELEMS = 1 << 20 # 1M-elem (~4MB F32) streaming seam for delta math; mirrors the fast path
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def _gguf_nbytes(dims, ggml_type):
|
| 329 |
+
from gguf.constants import GGML_QUANT_SIZES
|
| 330 |
+
n = 1
|
| 331 |
+
for d in dims:
|
| 332 |
+
n *= int(d)
|
| 333 |
+
block_size, type_size = GGML_QUANT_SIZES[ggml_type]
|
| 334 |
+
return n * type_size // block_size
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def _parse_gguf_header(url):
|
| 338 |
+
"""Fetch a remote GGUF's metadata + tensor table.
|
| 339 |
+
Returns (arch, header_size, {name: (dims, ggml_type, data_offset, n_bytes)}),
|
| 340 |
+
where header_size is the aligned offset of the data section."""
|
| 341 |
+
from gguf.constants import GGMLQuantizationType as Q
|
| 342 |
+
buf, base = b"", 0
|
| 343 |
+
|
| 344 |
+
def need(n):
|
| 345 |
+
nonlocal buf, base
|
| 346 |
+
while len(buf) < n:
|
| 347 |
+
a = base + len(buf)
|
| 348 |
+
b = a + max(n - len(buf), 256 * 1024) - 1
|
| 349 |
+
req = urllib.request.Request(url, headers=_hf_auth_headers(url, {"Range": f"bytes={a}-{b}"}))
|
| 350 |
+
with urllib.request.urlopen(req, timeout=120) as r:
|
| 351 |
+
chunk = r.read()
|
| 352 |
+
if not chunk:
|
| 353 |
+
break
|
| 354 |
+
buf += chunk
|
| 355 |
+
|
| 356 |
+
def take(n):
|
| 357 |
+
nonlocal buf, base
|
| 358 |
+
need(n)
|
| 359 |
+
out, buf, base = buf[:n], buf[n:], base + n
|
| 360 |
+
return out
|
| 361 |
+
|
| 362 |
+
u32 = lambda: struct.unpack("<I", take(4))[0]
|
| 363 |
+
u64 = lambda: struct.unpack("<Q", take(8))[0]
|
| 364 |
+
string = lambda: take(u64()).decode("utf-8", "replace")
|
| 365 |
+
sizes = {0: 1, 1: 1, 2: 2, 3: 2, 4: 4, 5: 4, 6: 4, 7: 1, 10: 8, 11: 8, 12: 8}
|
| 366 |
+
|
| 367 |
+
def skip_value(t):
|
| 368 |
+
if t == 8:
|
| 369 |
+
take(u64()); return
|
| 370 |
+
if t == 9:
|
| 371 |
+
et = u32(); n = u64()
|
| 372 |
+
if et not in sizes and et != 8:
|
| 373 |
+
raise ValueError(f"unknown GGUF array element type {et}")
|
| 374 |
+
if et == 8:
|
| 375 |
+
for _ in range(n):
|
| 376 |
+
take(u64())
|
| 377 |
+
else:
|
| 378 |
+
take(n * sizes[et])
|
| 379 |
+
return
|
| 380 |
+
if t not in sizes:
|
| 381 |
+
raise ValueError(f"unknown GGUF value type {t}")
|
| 382 |
+
take(sizes[t])
|
| 383 |
+
|
| 384 |
+
if u32() != 0x46554747:
|
| 385 |
+
raise ValueError("not a GGUF file")
|
| 386 |
+
u32() # version
|
| 387 |
+
tensor_count = u64()
|
| 388 |
+
kv_count = u64()
|
| 389 |
+
arch = None
|
| 390 |
+
alignment = 32
|
| 391 |
+
for _ in range(kv_count):
|
| 392 |
+
key = string()
|
| 393 |
+
t = u32()
|
| 394 |
+
if key == "general.architecture" and t == 8:
|
| 395 |
+
arch = take(u64()).decode("utf-8", "replace")
|
| 396 |
+
elif key == "general.alignment" and t == 4:
|
| 397 |
+
alignment = max(1, u32())
|
| 398 |
+
else:
|
| 399 |
+
skip_value(t)
|
| 400 |
+
tensors = {}
|
| 401 |
+
for _ in range(tensor_count):
|
| 402 |
+
name = string()
|
| 403 |
+
nd = u32()
|
| 404 |
+
dims = [u64() for _ in range(nd)]
|
| 405 |
+
gtype = u32()
|
| 406 |
+
data_offset = u64()
|
| 407 |
+
tensors[name] = (dims, Q(gtype), data_offset, _gguf_nbytes(dims, Q(gtype)))
|
| 408 |
+
# Tensor data_offsets are relative to the data section, which starts after
|
| 409 |
+
# the header (+ tensor infos), padded up to `general.alignment`.
|
| 410 |
+
header_size = base + ((-base) % alignment)
|
| 411 |
+
return arch, header_size, tensors
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def _is_gguf_file(path):
|
| 415 |
+
try:
|
| 416 |
+
with open(path, "rb") as f:
|
| 417 |
+
data = f.read(4)
|
| 418 |
+
return len(data) == 4 and data == b"GGUF"
|
| 419 |
+
except Exception:
|
| 420 |
+
return False
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
_QUANT_DTYPES = frozenset()
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
try:
|
| 427 |
+
from gguf.constants import GGMLQuantizationType as _QALL
|
| 428 |
+
_QUANT_DTYPES = frozenset(t.name for t in _QALL) - {"F32", "F16", "F64", "BF16",
|
| 429 |
+
"I8", "I16", "I32", "I64"}
|
| 430 |
+
except Exception:
|
| 431 |
+
_QUANT_DTYPES = frozenset({"Q4_0", "Q4_1", "Q5_0", "Q5_1", "Q8_0", "Q8_1", "Q8_K", "Q1_0",
|
| 432 |
+
"Q2_K", "Q3_K", "Q3_K_XS", "Q3_K_S", "Q3_K_M", "Q3_K_L",
|
| 433 |
+
"Q4_K", "Q4_K_S", "Q4_K_M", "Q5_K", "Q5_K_M", "Q6_K", "Q8_K",
|
| 434 |
+
"IQ1_S", "IQ1_M", "IQ2_XXS", "IQ2_XS", "IQ2_S", "IQ3_XXS", "IQ3_S", "IQ4_XS", "IQ4_NL",
|
| 435 |
+
"TQ1_0", "TQ2_0", "MXFP4", "NVFP4"})
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def _is_ggml_quant(dtype):
|
| 439 |
+
return dtype.upper() in _QUANT_DTYPES
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def _is_quant_fetched(fetched):
|
| 443 |
+
return _is_ggml_quant(fetched[3])
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _is_delta_safetensors(header_or_path):
|
| 447 |
+
"""Check if file/header indicates delta via marker or metadata."""
|
| 448 |
+
try:
|
| 449 |
+
if isinstance(header_or_path, (str, Path)):
|
| 450 |
+
with open(header_or_path, "rb") as f:
|
| 451 |
+
hs = struct.unpack("<Q", f.read(8))[0]
|
| 452 |
+
hdr = json.loads(f.read(hs))
|
| 453 |
+
else:
|
| 454 |
+
hdr = header_or_path
|
| 455 |
+
if "delta.voice.marker" in hdr:
|
| 456 |
+
return True
|
| 457 |
+
meta = hdr.get("__metadata__")
|
| 458 |
+
if isinstance(meta, dict) and meta.get("type") == "delta":
|
| 459 |
+
return True
|
| 460 |
+
except Exception:
|
| 461 |
+
pass
|
| 462 |
+
return False
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
def _write_single_tensor_gguf(tensor_name, raw_path, n_bytes, ggml_type, shape, out_path):
|
| 466 |
+
"""Write one tensor's exact raw quant blocks as a minimal standalone GGUF."""
|
| 467 |
+
from gguf import GGUFWriter
|
| 468 |
+
from gguf.constants import GGMLQuantizationType as Q
|
| 469 |
+
from gguf.quants import quant_shape_to_byte_shape
|
| 470 |
+
import numpy as np
|
| 471 |
+
qtype = Q[ggml_type]
|
| 472 |
+
# byte blocks are laid out in logical order (no reversal: the writer stores
|
| 473 |
+
# reversed file dims itself, and the reader exposes them back the same way)
|
| 474 |
+
byte_shape = quant_shape_to_byte_shape(tuple(int(x) for x in shape), qtype)
|
| 475 |
+
need = 1
|
| 476 |
+
for _d in byte_shape:
|
| 477 |
+
need *= _d
|
| 478 |
+
raw = np.fromfile(str(raw_path), dtype=np.uint8, count=need)
|
| 479 |
+
if raw.size != need:
|
| 480 |
+
_fail(f" β Short tensor data for '{tensor_name}' ({raw.size}/{need} bytes).")
|
| 481 |
+
tmp = str(out_path) + ".tmp"
|
| 482 |
+
writer = GGUFWriter(tmp, "voice", use_temp_file=False)
|
| 483 |
+
writer.add_tensor(tensor_name, raw.reshape(byte_shape), raw_dtype=qtype)
|
| 484 |
+
writer.write_header_to_file()
|
| 485 |
+
writer.write_kv_data_to_file()
|
| 486 |
+
writer.write_tensors_to_file()
|
| 487 |
+
writer.close()
|
| 488 |
+
os.replace(tmp, str(out_path))
|
vlib/ui.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Terminal output, prompts, and formatting."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
_PLAIN_MAP = {"β": "[OK]", "β": "[ERR]", "β ": "[WARN]", "β": ">", "β³": "->", "Β·": "-", "β¦": "...", "β": "-", "β": "-", "β": "<-", "β": "<->"}
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _plain():
|
| 11 |
+
return os.environ.get("VOICE_PLAIN") == "1"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _transform(msg):
|
| 15 |
+
if not _plain():
|
| 16 |
+
return msg
|
| 17 |
+
for k, v in _PLAIN_MAP.items():
|
| 18 |
+
msg = msg.replace(k, v)
|
| 19 |
+
return msg
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _now_iso():
|
| 23 |
+
from datetime import datetime, timezone
|
| 24 |
+
return datetime.now(timezone.utc).isoformat()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _say(msg):
|
| 28 |
+
print(_transform(msg), flush=True)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _ok(msg):
|
| 32 |
+
_say(f" β {msg}")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _warn(msg):
|
| 36 |
+
_say(f" β {msg}")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _step(msg):
|
| 40 |
+
_say(f" β {msg}")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _note(msg):
|
| 44 |
+
_say(f" β³ {msg}")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _fail(msg):
|
| 48 |
+
sys.stdout.flush()
|
| 49 |
+
msg = str(msg).lstrip()
|
| 50 |
+
if msg.startswith("β"):
|
| 51 |
+
msg = msg[1:].lstrip()
|
| 52 |
+
_say(f" β {msg}")
|
| 53 |
+
sys.exit(1)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _noninteractive(args):
|
| 57 |
+
if getattr(args, "non_interactive", False) or getattr(args, "yes", False):
|
| 58 |
+
return True
|
| 59 |
+
try:
|
| 60 |
+
return not sys.stdin.isatty()
|
| 61 |
+
except Exception:
|
| 62 |
+
return True
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _progress_pref():
|
| 66 |
+
if _plain() or os.environ.get("REDUCE_MOTION") == "1":
|
| 67 |
+
return True
|
| 68 |
+
try:
|
| 69 |
+
return not sys.stdout.isatty()
|
| 70 |
+
except Exception:
|
| 71 |
+
return True
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _confirm(question, args):
|
| 75 |
+
if getattr(args, "yes", False):
|
| 76 |
+
return True
|
| 77 |
+
if _noninteractive(args):
|
| 78 |
+
return False
|
| 79 |
+
try:
|
| 80 |
+
ans = input(question).strip().lower()
|
| 81 |
+
except (EOFError, KeyboardInterrupt):
|
| 82 |
+
_say(" β Cancelled.")
|
| 83 |
+
sys.exit(0)
|
| 84 |
+
return ans in ("y", "yes")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _trunc(s, w):
|
| 88 |
+
s = str(s)
|
| 89 |
+
return s[: w - 1] + "β¦" if len(s) > w else s
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _term_width():
|
| 93 |
+
try:
|
| 94 |
+
import shutil
|
| 95 |
+
return shutil.get_terminal_size().columns
|
| 96 |
+
except Exception:
|
| 97 |
+
return 80
|
voice-cli.tar.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b49d8f2c7bed7be28fa5c650fe9f0f892641bb1fa35236cb524aa4b760162a7a
|
| 3 |
+
size 66864
|
voice.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
voice.pyz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2148e2de112c40fe5eaaf2ad880c72e4ac925775fc3d6f51dbd8d6d36da7a103
|
| 3 |
+
size 245086
|