Upload 6 files
Browse files- DOCS.md +110 -0
- README.md +384 -0
- benchmark.md +91 -0
- lockup-circle-wordmark-2-refined.png +0 -0
- voice +3 -0
- voice.py +1861 -0
DOCS.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# voice β Help
|
| 2 |
+
|
| 3 |
+
> Generated from `voice --help` and `voice <cmd> --help` (`voice.py` 7 cmds). Build mode.
|
| 4 |
+
|
| 5 |
+
## voice --help
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
usage: voice [-h] [-v] {get,cast,path,remove,list,delta,info} ...
|
| 9 |
+
|
| 10 |
+
Get a voice, then cast it onto your model.
|
| 11 |
+
|
| 12 |
+
positional arguments:
|
| 13 |
+
{get,cast,path,remove,list,delta,info}
|
| 14 |
+
get Get a voice from a HuggingFace model
|
| 15 |
+
cast Cast a voice onto your model
|
| 16 |
+
path Add 'voice' to your system PATH
|
| 17 |
+
remove Remove a stored voice
|
| 18 |
+
list List stored voices
|
| 19 |
+
delta Make a style you can re-use (voice β base)
|
| 20 |
+
info Show voice metadata
|
| 21 |
+
|
| 22 |
+
options:
|
| 23 |
+
-h, --help show this help message and exit
|
| 24 |
+
-v, --verbose Show detailed progress
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
## voice get --help
|
| 28 |
+
|
| 29 |
+
```
|
| 30 |
+
usage: voice get [-h] [-q] model_id
|
| 31 |
+
|
| 32 |
+
positional arguments:
|
| 33 |
+
model_id HuggingFace model ID (e.g. google/gemma-3-1b)
|
| 34 |
+
|
| 35 |
+
options:
|
| 36 |
+
-h, --help show this help message and exit
|
| 37 |
+
-q, --quiet Skip license detection
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
## voice delta --help
|
| 41 |
+
|
| 42 |
+
```
|
| 43 |
+
usage: voice delta [-h] [voicename]
|
| 44 |
+
|
| 45 |
+
positional arguments:
|
| 46 |
+
voicename Voice name from registry (from voice list)
|
| 47 |
+
|
| 48 |
+
options:
|
| 49 |
+
-h, --help show this help message and exit
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
## voice cast --help
|
| 53 |
+
|
| 54 |
+
```
|
| 55 |
+
usage: voice cast [-h] [--out OUT] target_gguf voice
|
| 56 |
+
|
| 57 |
+
positional arguments:
|
| 58 |
+
target_gguf Path to the target GGUF model
|
| 59 |
+
voice Voice name (from registry) or path to .safetensors
|
| 60 |
+
|
| 61 |
+
options:
|
| 62 |
+
-h, --help show this help message and exit
|
| 63 |
+
--out OUT Output directory or file (default:
|
| 64 |
+
./<model>/<voice>-<model>.gguf)
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
## voice list --help
|
| 68 |
+
|
| 69 |
+
```
|
| 70 |
+
usage: voice list [-h] [{base}]
|
| 71 |
+
|
| 72 |
+
positional arguments:
|
| 73 |
+
{base} List bases instead of voices (voice list base)
|
| 74 |
+
|
| 75 |
+
options:
|
| 76 |
+
-h, --help show this help message and exit
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
## voice info --help
|
| 80 |
+
|
| 81 |
+
```
|
| 82 |
+
usage: voice info [-h] voicename
|
| 83 |
+
|
| 84 |
+
positional arguments:
|
| 85 |
+
voicename Voice name to show info for
|
| 86 |
+
|
| 87 |
+
options:
|
| 88 |
+
-h, --help show this help message and exit
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
## voice remove --help
|
| 92 |
+
|
| 93 |
+
```
|
| 94 |
+
usage: voice remove [-h] voice
|
| 95 |
+
|
| 96 |
+
positional arguments:
|
| 97 |
+
voice Voice name to remove
|
| 98 |
+
|
| 99 |
+
options:
|
| 100 |
+
-h, --help show this help message and exit
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
## voice path --help
|
| 104 |
+
|
| 105 |
+
```
|
| 106 |
+
usage: voice path [-h]
|
| 107 |
+
|
| 108 |
+
options:
|
| 109 |
+
-h, --help show this help message and exit
|
| 110 |
+
```
|
README.md
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
pipeline_tag: text-generation
|
| 3 |
+
library_name: gguf
|
| 4 |
+
language:
|
| 5 |
+
- en
|
| 6 |
+
license: other
|
| 7 |
+
base_model: google/gemma-4-26b-a4b-it
|
| 8 |
+
base_model_relation: quantized
|
| 9 |
+
tags:
|
| 10 |
+
- gemma
|
| 11 |
+
- moe
|
| 12 |
+
- text-generation
|
| 13 |
+
- style-transfer
|
| 14 |
+
- gguf
|
| 15 |
+
- q8_0
|
| 16 |
+
- roleplay
|
| 17 |
+
- voice
|
| 18 |
+
datasets:
|
| 19 |
+
- gryphe/styletune-roleplay-200
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
<p align="center"><img src="./lockup-circle-wordmark-2-refined.png" width="360" alt="Voice" /></p>
|
| 23 |
+
|
| 24 |
+
# Voice
|
| 25 |
+
|
| 26 |
+
Gemma 4 Β· 26B MoE Β· StyleTune Β· GGUF Q8_0
|
| 27 |
+
|
| 28 |
+
Your model, in your voice. One file to runβnothing extra to set up.
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Two steps to start
|
| 33 |
+
|
| 34 |
+
One-time setup, then two commands. One model, one voice:
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
python3 ship/voice.py path # once β adds voice to PATH (then use voice directly)
|
| 38 |
+
voice get Gryphe/Gemma-4-26B-A4B-StyleTune-V2
|
| 39 |
+
# Name it styletune-v2 when prompted
|
| 40 |
+
voice cast ./model.gguf styletune-v2 --out ./voiced/
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
Run it:
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
llama serve -m ./voiced/model.gguf
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
No rebuild. No extra setup at runtime.
|
| 50 |
+
|
| 51 |
+
> Example voice shown. Any compatible `safetensors` voice works the same way. Names are lowercased on diskβ`styletune-v2` is the stored form. Model and voice names here are examples; use any compatible pair that shares the same architecture and vocabulary.
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
+
## How it works
|
| 56 |
+
|
| 57 |
+
Two benefits, no extras to learn:
|
| 58 |
+
|
| 59 |
+
- **Get what you need:** Voice pulls only the voice from Hugging Face and saves it to your library. Light and fast.
|
| 60 |
+
- **Run fused:** Cast writes a complete model you can run anywhere. No extra setup.
|
| 61 |
+
|
| 62 |
+
You will see each step as it happens and get a ready-to-run file at the end.
|
| 63 |
+
|
| 64 |
+
Take the voice of any compatible model or finetune and cast it onto the file you already have. Mix and match. Keep the stronger model for reasoning and the better voice for style. You chooseβno extra steps at runtime.
|
| 65 |
+
|
| 66 |
+
### When you need a delta
|
| 67 |
+
|
| 68 |
+
Most voices cast directly and just work. Some models loop or repeat. If you see looping, try the delta path insteadβit blends more gently.
|
| 69 |
+
|
| 70 |
+
<details>
|
| 71 |
+
<summary>Why a delta helps when looping</summary>
|
| 72 |
+
|
| 73 |
+
A delta carries only the difference from its base, so it blends more gently with the target. On some abliterated targets, a tiny shift can change behavior. MoE routing is discrete, so a small change in the residual stream can tip which expert fires next. If direct casting loops, the delta path often avoids it.
|
| 74 |
+
|
| 75 |
+
</details>
|
| 76 |
+
|
| 77 |
+
---
|
| 78 |
+
|
| 79 |
+
## Commands
|
| 80 |
+
|
| 81 |
+
Seven verbs. One idea each:
|
| 82 |
+
|
| 83 |
+
| Command | Description |
|
| 84 |
+
|---|---|
|
| 85 |
+
| `voice get <org/model>` | Get a voice from Hugging Face and save it to your library |
|
| 86 |
+
| `voice delta [name]` | Make a portable style you can reuse across compatible models |
|
| 87 |
+
| `voice cast <model.gguf> <voice>` | Fuse a voice into your model and write a complete, ready-to-run file |
|
| 88 |
+
| `voice list` | Show your saved voicesβadd `base` to list cached bases |
|
| 89 |
+
| `voice info <voice>` | Show details for a saved voice or delta |
|
| 90 |
+
| `voice remove <voice>` | Remove a voice from your library |
|
| 91 |
+
| `voice path` | Add `voice` to your PATH |
|
| 92 |
+
|
| 93 |
+
Example:
|
| 94 |
+
|
| 95 |
+
```bash
|
| 96 |
+
voice get Gryphe/Gemma-4-26B-A4B-StyleTune-V2
|
| 97 |
+
# Name it styletune-v2 when prompted
|
| 98 |
+
voice list
|
| 99 |
+
# Name Source Type
|
| 100 |
+
# ββββββββββββββββββ βββββββββββββββββββββββββββββββββββββββ ββββββββ
|
| 101 |
+
# styletune-v2 Gryphe/Gemma-4-26B-A4B-StyleTune-V2 voice
|
| 102 |
+
# delta-styletune-v2 Gryphe/Gemma-4-26B-A4B-StyleTune-V2 delta
|
| 103 |
+
|
| 104 |
+
voice info styletune-v2
|
| 105 |
+
voice list base
|
| 106 |
+
voice cast ./model.gguf styletune-v2 --out ./voiced/
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
> `delta-` is reserved. Voice names cannot start with it. Deltas always do. Full CLI reference: [`DOCS.md`](./DOCS.md) (generated from `voice --help`, 7 commands). Names are lowercased when saved.
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
## What StyleTune showed
|
| 113 |
+
|
| 114 |
+
Gryphe reports for StyleTune-V2βon their private 200-prompt roleplay eval (private regex, certified clichΓ©-free, 1β20 turns, greedy 0.0)β52% fewer clichΓ©s per 100 words (1.141β0.551) and 19.9% shared trigram vocabulary vs base instruct.[^1]
|
| 115 |
+
|
| 116 |
+
[^1]: Source: [Gryphe/Gemma-4-26B-A4B-StyleTune-V2](https://huggingface.co/Gryphe/Gemma-4-26B-A4B-StyleTune-V2) model card and [discussion #1](https://huggingface.co/Gryphe/Gemma-4-26B-A4B-StyleTune-V2/discussions/1). We cite their numbers, not ours. No `model-index` is published hereβthis is a private metric, not a reproducible Hub leaderboard.
|
| 117 |
+
|
| 118 |
+
<details>
|
| 119 |
+
<summary>How Gryphe tested</summary>
|
| 120 |
+
|
| 121 |
+
- Single epoch, 100% narrative data; prompts certified clichΓ©-free and not seen in training.
|
| 122 |
+
- 200 prompts sampled at intervals 1 to 20 turns, each ending on a user turn.
|
| 123 |
+
- Run at `temperature 0.0` greedy deterministic.
|
| 124 |
+
- ClichΓ©s via their private regex (for example, `calculating` and `predatory`) counted per 100 words; phrasing via shared trigram vocabulary.
|
| 125 |
+
|
| 126 |
+
</details>
|
| 127 |
+
|
| 128 |
+
### Our quick checkβheuristic, n=10, not comparable
|
| 129 |
+
|
| 130 |
+
**Small sample, heuristic, not comparableβdirection only.** With that caveat, the check suggests distinct phrasing.
|
| 131 |
+
|
| 132 |
+
We used 10 roleplay prompts in chat. Each was run at `temp 1.0` and at greedy `0.0` (`max_tokens 2000`, `seed 42`). This is a small-sample heuristic using our own 50-phrase list, not Gryphe's private regex. Absolute per-100 numbers are not comparable to Gryphe's.
|
| 133 |
+
|
| 134 |
+
With that caveat, overall trigram overlap vs base was **1.6% at 1.0 and 3.2% at greedy**. Every prompt was under 5%. We saw 5β5.7k words total. Thinking tokens averaged about 2.6k. There were no loops and no empties. The voice was clearly distinct. Greedy was slightly lower on clichΓ©s than 1.0. Direction and distinctness align with Gryphe's report.
|
| 135 |
+
|
| 136 |
+
Details, per-prompt tables, and harness: [`benchmark.md`](./benchmark.md) Β· [`eval.md`](./eval.md).
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
## A smarter base: quantization-aware training
|
| 141 |
+
|
| 142 |
+
Google trains a second kind of checkpoint for on-device useβQuantization-Aware Training (QAT). Instead of shrinking the model after it is done, QAT simulates low-precision math during training so the weights learn to survive it.
|
| 143 |
+
|
| 144 |
+
In Google's recipe for Gemma 3 and Gemma 4, that is about 5,000 steps with a fake-quantized forward pass and KL divergence to the BF16 teacher as the target. The model stays in high precision, but it computes as if it were already quantized, learning to be friendly to compression while it can still adapt.
|
| 145 |
+
|
| 146 |
+
The gain, per Google, is meaningful at the size you actually run: **54% less perplexity drop at Q4_0 vs standard post-training quantization** ([Google Developers Blog](https://developers.googleblog.com/en/gemma-3-quantized-aware-trained-state-of-the-art-ai-to-consumer-gpus/), April 18, 2025).
|
| 147 |
+
|
| 148 |
+
StyleTune-V2, by Gryphe, is built from the regular `gemma-4-26B-A4B-it`, not the QAT variant. Many asked for QAT, and QAT is not available for every finetune. Voice makes that simple: keep the QAT model as your base, get the voice you like, and cast it on. Any finetune from `gemma-4-26B-A4B` or `gemma-4-26B-A4B-it` that has no QAT counterpart can now lend its voiceβnot the whole model, just the voice. Credits stay where they belong: StyleTune by Gryphe, QAT by Google, portability by Voice.
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
---
|
| 154 |
+
|
| 155 |
+
## Evaluation
|
| 156 |
+
|
| 157 |
+
**Gryphe (author, n=200, greedy 0.0, private regex):** 1.141β0.551 clichΓ©s/100w (β52%), 19.9% shared vocabularyβprivate regex, certified clichΓ©-free prompts, 1β20 turn intervals. Source: Gryphe model card + discussion #1. Private metric for citation onlyβnot a Hub leaderboard.
|
| 158 |
+
|
| 159 |
+
**Ours (independent, heuristic, n=10, not comparableβdirection only):**
|
| 160 |
+
|
| 161 |
+
| Condition | Base clichΓ©s/100 | Voiced clichΓ©s/100 | Shared trigram (Jaccard) | Notes |
|
| 162 |
+
|---|---|---|---|---|
|
| 163 |
+
| `temp 1.0` | 0.18 (10 hits / 5463w) | 0.14 (8 hits / 5563w) | **1.62%** overall (176/10877), per-prompt 0.2β1.9% | All <5%, distinct phrasing |
|
| 164 |
+
| `greedy 0.0` | 0.12 (6 hits / 5085w) | 0.10 (6 hits / 5723w) | **3.23%** overall (336/10406), per-prompt 1.0β4.8% | Slight canonical convergence, still far below 19.9% |
|
| 165 |
+
|
| 166 |
+
Full tables, sampler-vs-voice dominance, and per-prompt first-80: [`benchmark.md`](./benchmark.md) (deep, 4 subagents) and [`eval.md`](./eval.md) (1.0 numbers). Harness via `POST /v1/chat/completions` with Gemma 4 chat template and `<|channel>thought` parsing.
|
| 167 |
+
|
| 168 |
+
**For official leaderboard submission:** When you have Hub dataset IDs, add YAML files under `.eval_results/` per [Evaluation Results docs](https://huggingface.co/docs/hub/eval-results). This card cites Gryphe's private evalβit does not publish a leaderboard entry.
|
| 169 |
+
|
| 170 |
+
---
|
| 171 |
+
|
| 172 |
+
## Limitations
|
| 173 |
+
|
| 174 |
+
- **Heuristic mismatch:** Our 50-phrase list misses Gryphe's private regex (`calculating xx`, `predatory xx`, and others), so absolute 0.10β0.18 vs 1.141β0.551 is not comparableβonly direction and low shared vocabulary are signal.
|
| 175 |
+
- **Sample size and prompts:** 10 single-turn prompts vs Gryphe's 200 multi-turn certified set (intervals 1β20, ends on user turn). Small sample, higher variance.
|
| 176 |
+
- **Thinking overhead:** Gemma 4 thinking mode (`<|channel>thought`) adds about 2.6k thinking tokens on average. Gryphe's eval predates thinking configs and did not report this split.
|
| 177 |
+
- **Determinism:** Greedy 0.0 reduces variance but still yields more canonical phrasing (shared 1.6%β3.2%)βexpected, not a failure.
|
| 178 |
+
- **If you see looping, use the delta path** (`voice delta` β `voice cast β¦ delta-β¦`). Tiny shifts can affect behavior on abliterated targets. See βWhy a delta helps when loopingβ above.
|
| 179 |
+
- **Scope:** Style transfer changes phrasing and voice, not knowledge or factuality. Not evaluated for safety, bias, or instruction following. See Gemma 4 model card Limitations.
|
| 180 |
+
|
| 181 |
+
---
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
## References
|
| 185 |
+
|
| 186 |
+
- **Gemma 4 Technical Report**βGemma Team, 2026. [arXiv:2607.02770](https://arxiv.org/abs/2607.02770)βbase model `google/gemma-4-26B-A4B-it` (MoE 26B A4B, 256K context, 262K vocab).
|
| 187 |
+
- **Gemma 3 Quantization-Aware Training**βGoogle Developers Blog, Apr 18, 2025: [State-of-the-art AI to consumer GPUs](https://developers.googleblog.com/en/gemma-3-quantized-aware-trained-state-of-the-art-ai-to-consumer-gpus/)βQAT recipe (5k steps, fake-quant + KL to BF16 teacher, 54% less perplexity drop at Q4_0).
|
| 188 |
+
- **Gryphe StyleTune-V2**β[Model card](https://huggingface.co/Gryphe/Gemma-4-26B-A4B-StyleTune-V2) and [discussion #1](https://huggingface.co/Gryphe/Gemma-4-26B-A4B-StyleTune-V2/discussions/1)β200-prompt eval, private regex, 1.141β0.551 and 19.9% shared vocabulary.
|
| 189 |
+
|
| 190 |
+
---
|
| 191 |
+
|
| 192 |
+
## Use with Hugging Face
|
| 193 |
+
|
| 194 |
+
### GGUF (this repoβlocal with llama.cpp)
|
| 195 |
+
|
| 196 |
+
This is the native path for the fused model aboveβno Python, no adapter at runtime:
|
| 197 |
+
|
| 198 |
+
```bash
|
| 199 |
+
llama serve -m ./voiced/model.gguf --jinja
|
| 200 |
+
curl http://localhost:8080/v1/chat/completions \
|
| 201 |
+
-H "Content-Type: application/json" \
|
| 202 |
+
-d '{"model":"voiced","messages":[{"role":"user","content":"Write a tavern scene, no clichΓ©s."}],"temperature":1.0}'
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
### Transformers (HF-native snippetβfor the base or other safetensors voices)
|
| 206 |
+
|
| 207 |
+
The Voice tool extracts the `lm_head` from `safetensors` models. For any HF `transformers` model (including the base below), the canonical snippet is:
|
| 208 |
+
|
| 209 |
+
```python
|
| 210 |
+
from transformers import pipeline
|
| 211 |
+
|
| 212 |
+
# Base modelβvalid, lowercased HF ID (link verified, HF is case-insensitive)
|
| 213 |
+
# https://huggingface.co/google/gemma-4-26b-a4b-it
|
| 214 |
+
pipe = pipeline("text-generation", model="google/gemma-4-26b-a4b-it")
|
| 215 |
+
print(pipe("Write a short tavern scene with varied diction:")[0]["generated_text"])
|
| 216 |
+
|
| 217 |
+
# For a StyleTune finetune example (illustrative):
|
| 218 |
+
# pipe = pipeline("text-generation", model="gryphe/gemma-4-26b-a4b-styletune-v2")
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
> **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: quantized` (fused GGUF head quantized to `Q8_0`, remainder preserved).
|
| 222 |
+
|
| 223 |
+
---
|
| 224 |
+
|
| 225 |
+
## Demo
|
| 226 |
+
|
| 227 |
+
Local demoβsame one-file run: `llama serve -m ./voiced/model.gguf`.
|
| 228 |
+
|
| 229 |
+
A Hugging Face Space that loads the model directly from this repo will be linked here when published (so downloads cross-link on the model page).
|
| 230 |
+
|
| 231 |
+
---
|
| 232 |
+
|
| 233 |
+
## Choosing a style
|
| 234 |
+
|
| 235 |
+
Pick the voice you like. Cast it directly. That is the default. Make a delta only when you need it:
|
| 236 |
+
|
| 237 |
+
- **Full voice:** The exact voice from the source model. Use it when you want that model's voice, exactly. Two steps: get and cast.
|
| 238 |
+
- **Delta:** The portable style derived from voice and base. Use it when a target loops or you want the gentlest blend. One delta casts onto any compatible target.
|
| 239 |
+
|
| 240 |
+
Both fuse the same way into the output file. Small, sharp, and complete.
|
| 241 |
+
|
| 242 |
+
If you are not sure, start direct:
|
| 243 |
+
|
| 244 |
+
```bash
|
| 245 |
+
voice cast ./model.gguf styletune-v2 --out ./voiced/
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
If a model loops, try delta:
|
| 249 |
+
|
| 250 |
+
```bash
|
| 251 |
+
voice delta styletune-v2
|
| 252 |
+
voice cast ./model.gguf delta-styletune-v2 --out ./voiced/
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
---
|
| 256 |
+
|
| 257 |
+
## Compatibility
|
| 258 |
+
|
| 259 |
+
Runs with compatible models that share the same architecture and vocabulary. Check shape before you cast:
|
| 260 |
+
|
| 261 |
+
**Works with:**
|
| 262 |
+
|
| 263 |
+
- Hugging Face `safetensors` models that expose an output projection
|
| 264 |
+
- GGUF models of the same architecture and vocab sizeβthe rest of the model stays exactly as it was
|
| 265 |
+
- Models with tied weights, handled automatically
|
| 266 |
+
- `llama.cpp` and anything that reads GGUFβrun with `llama serve -m <voiced.gguf>`
|
| 267 |
+
|
| 268 |
+
**Does not work with:**
|
| 269 |
+
|
| 270 |
+
- Mismatched architectures or vocab sizesβshape must match
|
| 271 |
+
- Missing output projection
|
| 272 |
+
- Heads where the last dimension is not a multiple of 32βrequired for the Q8_0 fused output
|
| 273 |
+
|
| 274 |
+
Your voiced GGUF is standalone. No extra files. No extra steps to run.
|
| 275 |
+
|
| 276 |
+
**Splitβquantized vs full precision:** The source `safetensors` voice is full precision (`BF16`/`F16`/`F32`), full shape. The fused GGUF keeps **all tensors at their original quantization except the output head, which is written at `Q8_0`** (quantized). For deltas, the math is `abliterated_head (dequantized to F32) + delta (F16βF32)` then quantized to `Q8_0`. The `Q8_0` block requires the last dimension `% 32 == 0` (true for Gemma 4 26B: hidden 2816, vocab 262144). All other tensors and metadata (tokenizer, vocab, arch) are byte-copied.
|
| 277 |
+
|
| 278 |
+
**Disclosures:** `get` and `delta` fetch from Hugging Face and need network access. `cast` writes a new GGUF to disk, so ensure you have free space for a full model copy.
|
| 279 |
+
|
| 280 |
+
---
|
| 281 |
+
|
| 282 |
+
## Your files, where they are
|
| 283 |
+
|
| 284 |
+
Your library lives at `~/.voices/`:
|
| 285 |
+
|
| 286 |
+
```
|
| 287 |
+
~/.voices/
|
| 288 |
+
styletune-v2/voice.safetensors # your voice, single tensor
|
| 289 |
+
styletune-v2/voice.json # metadata for that voice
|
| 290 |
+
delta-styletune-v2/ # portable style for reuse
|
| 291 |
+
delta-styletune-v2/voice.safetensors
|
| 292 |
+
delta-styletune-v2/voice.json
|
| 293 |
+
base/<org>/<model>.safetensors # cached base head
|
| 294 |
+
base/<org>/<model>.json # metadata for the base
|
| 295 |
+
```
|
| 296 |
+
|
| 297 |
+
Notes:
|
| 298 |
+
|
| 299 |
+
- Names are lowercased when saved. Input `StyleTune-V2` is stored as `styletune-v2`.
|
| 300 |
+
- `voice list` shows voices and deltas. `voice list base` shows cached bases. `voice info <name>` shows details for any entry.
|
| 301 |
+
|
| 302 |
+
---
|
| 303 |
+
|
| 304 |
+
## Troubleshooting
|
| 305 |
+
|
| 306 |
+
**Voice not found:**
|
| 307 |
+
|
| 308 |
+
Use `voice list` to see what is saved. Names match case-insensitively. `StyleTune-V2` and `styletune-v2` resolve to the same entry.
|
| 309 |
+
|
| 310 |
+
**Shape mismatch. Does not fit this model:**
|
| 311 |
+
|
| 312 |
+
The voice and the target must share the same head shape. A 26B head will not fit a 12B model. Try a voice from the same architecture and vocab size.
|
| 313 |
+
|
| 314 |
+
**Tied weights:**
|
| 315 |
+
|
| 316 |
+
Some models tie the output projection to the embedding. Voice finds the correct tensor automatically. You do not need to specify it.
|
| 317 |
+
|
| 318 |
+
**Could not reach the model:**
|
| 319 |
+
|
| 320 |
+
Check the `org/model` name and your connection. Voice fetches the header first and will tell you if the model is not accessible.
|
| 321 |
+
|
| 322 |
+
**Output folder not writable:**
|
| 323 |
+
|
| 324 |
+
Pass `--out` to a folder you own or to a full file path:
|
| 325 |
+
|
| 326 |
+
```bash
|
| 327 |
+
voice cast ./model.gguf styletune-v2 --out ./voiced/
|
| 328 |
+
voice cast ./model.gguf styletune-v2 --out ./voiced/model.gguf
|
| 329 |
+
```
|
| 330 |
+
|
| 331 |
+
Add `-v` to any command for detailed progress. You can pass a direct `.safetensors` path to `cast` instead of a saved name when needed.
|
| 332 |
+
|
| 333 |
+
---
|
| 334 |
+
|
| 335 |
+
## Details
|
| 336 |
+
|
| 337 |
+
<details>
|
| 338 |
+
<summary>What is inside the files</summary>
|
| 339 |
+
|
| 340 |
+
- Voice: single-tensor `safetensors`, `lm_head.weight` or `output.weight`, original dtype (`BF16`/`F16`/`F32`), full shape.
|
| 341 |
+
- Delta: single-tensor `safetensors`, `F16`, same shape, `voice β base`.
|
| 342 |
+
- 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.
|
| 343 |
+
|
| 344 |
+
How it works under the hood:
|
| 345 |
+
|
| 346 |
+
- `get` downloads only the head bytes with range requests, not the whole file, then saves to `~/.voices/<name>/`.
|
| 347 |
+
- `delta` subtracts the base head from the voice head (`voice β base`) and saves an `F16` delta as `delta-<name>`. The base head is cached once at `~/.voices/base/<org>/<model>.safetensors`.
|
| 348 |
+
- `cast` replaces the head and writes a new GGUF with the head at `Q8_0` (`delta to Q8_0` for deltas, direct fuse for full voices). The result is a complete model.
|
| 349 |
+
|
| 350 |
+
A voice lives in the last layer. Changing that layer changes how the model sounds. Voice saves that layer and casts it onto a compatible model you already haveβfused, standalone, and ready to run. Transposition between `[vocab, hidden]` and `[hidden, vocab]` is handled automatically. So is `BF16 to F32` for the math and `F32 to Q8_0` for the result. The last dimension must be a multiple of 32 for `Q8_0`.
|
| 351 |
+
|
| 352 |
+
**Scope:** This card documents the Voice tool (adapter extraction) and its quantized GGUF output (head at `Q8_0`). Base weights remain under their source license; tool code is Apache-2.0βsee [License](#license).
|
| 353 |
+
|
| 354 |
+
</details>
|
| 355 |
+
|
| 356 |
+
---
|
| 357 |
+
|
| 358 |
+
## Collection
|
| 359 |
+
|
| 360 |
+
This voice builds on **Gemma 4**. Find all Gemma 4 models in the official collection:
|
| 361 |
+
|
| 362 |
+
- **Google Gemma 4 Collection:** [https://huggingface.co/collections/google/gemma-4](https://huggingface.co/collections/google/gemma-4)βincludes `google/gemma-4-26B-A4B-it` (this card's `base_model`) and `google/gemma-4-26B-A4B`.
|
| 363 |
+
|
| 364 |
+
Related StyleTune voices (examples, any compatible `safetensors` works):
|
| 365 |
+
|
| 366 |
+
- StyleTune: [12B](https://huggingface.co/Gryphe/Gemma-4-12B-StyleTune) Β· [26B](https://huggingface.co/Gryphe/Gemma-4-26B-A4B-StyleTune) Β· [26B V2](https://huggingface.co/Gryphe/Gemma-4-26B-A4B-StyleTune-V2) Β· [31B](https://huggingface.co/Gryphe/Gemma-4-31B-StyleTune)
|
| 367 |
+
|
| 368 |
+
---
|
| 369 |
+
|
| 370 |
+
## Credits
|
| 371 |
+
|
| 372 |
+
[Gryphe](https://huggingface.co/Gryphe) proved one tensor changes everything. [Anthracite](https://huggingface.co/Anthracite) and [Latitude](https://huggingface.co/Latitude) helped make it happen. The MythoMax lineage found that `lm_head` carries style. [Hugging Face](https://huggingface.co/docs/safetensors) made the format portable. [llama.cpp](https://github.com/ggerganov/llama.cpp) made it runnable.
|
| 373 |
+
|
| 374 |
+
StyleTune: [12B](https://huggingface.co/Gryphe/Gemma-4-12B-StyleTune) Β· [26B](https://huggingface.co/Gryphe/Gemma-4-26B-A4B-StyleTune) Β· [26B V2](https://huggingface.co/Gryphe/Gemma-4-26B-A4B-StyleTune-V2) Β· [31B](https://huggingface.co/Gryphe/Gemma-4-31B-StyleTune)
|
| 375 |
+
|
| 376 |
+
> All StyleTune links are examples. Replace with any compatible voice you prefer.
|
| 377 |
+
|
| 378 |
+
---
|
| 379 |
+
|
| 380 |
+
## License
|
| 381 |
+
|
| 382 |
+
**Voice tooling** in this repository is **Apache-2.0**βsee [`LICENSE`](https://www.apache.org/licenses/LICENSE-2.0). This covers the `voice` code and packaging only. `license: other` in the frontmatter reflects the split below (Hub requires a single `license` key).
|
| 383 |
+
|
| 384 |
+
**Voices, deltas, and base heads keep their source model license.** When you run `voice get` or `voice delta`, the saved `voice.json` records the source license for your reference. For example, `google/gemma-4-26b-a4b-it` (display: `google/gemma-4-26B-A4B-it`) remains under its Gemma license (Apache-2.0 with Gemma termsβsee [google/gemma-4-26B-A4B-it](https://huggingface.co/google/gemma-4-26B-A4B-it) and [ai.google.dev/gemma/docs/gemma_4_license](https://ai.google.dev/gemma/docs/gemma_4_license)). Check the Hugging Face model card before you share a voiced model. The `google/gemma-4-26b-a4b-it` name in the frontmatter is the lowercased, verifiable HF ID (link lives, case-insensitive); `gryphe/gemma-4-26b-a4b-styletune-v2` and other StyleTune names are illustrative examplesβuse any compatible model you have access to.
|
benchmark.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Benchmark β OG vs TEST across samplers (deep, anchor, 4 subagents)
|
| 2 |
+
|
| 3 |
+
> **Anchor:** fanned out 4 isolated subagents (A OG1βOGg, B TEST1βTESTg, C OGg vs TESTg, D cross). All 10 prompts `max_tokens 2000` `seed 42` same 10 prompts. Chat template Gemma4 `<|channel>thought β <channel|>` parsed, `thinking` scraped, `text` stripped. Files verified `10/10` each. Heuristic clichΓ© list 50-phrase distinct substring (not Gryphe private regex `calculating/predatory xx`). `words = len(text.split())`, `trigram Jaccard = |aβ©b|/|aβͺb|`, `TTR=|unique|/|toks|`, `maxRep=Counter(trigrams)`.
|
| 4 |
+
|
| 5 |
+
## 1. OG 1.0 β OG greedy (sampler effect on base, voice fixed)
|
| 6 |
+
|
| 7 |
+
*Subagent A* β **Greedy shortens and denoises.**
|
| 8 |
+
|
| 9 |
+
| metric | OG 1.0 | OG greedy | Ξ |
|
| 10 |
+
|---|---|---|---|
|
| 11 |
+
| total words |5463|5085|-378 -6.9%|
|
| 12 |
+
| clichΓ© hits distinct |10|6|-4|
|
| 13 |
+
| per100 |0.183|0.118|-0.065|
|
| 14 |
+
| thinking avg|2654|2580|-75|
|
| 15 |
+
| text avg|3099|2880|-219|
|
| 16 |
+
| th/txt|0.857|0.896|+0.039|
|
| 17 |
+
| TTR avg|0.500|0.494|-0.006|
|
| 18 |
+
| rep max|3.4|3.0|-0.4|
|
| 19 |
+
| empty|0/10|0/10|0|
|
| 20 |
+
| overall Jaccard self| β | β | **3.67% 370/10076** per-prompt 0.8-4.8% |
|
| 21 |
+
|
| 22 |
+
`whisper 7β2 (-71%)`, `beautiful 4β3`, `echoed/suddenly` gone, only `shimmering` added. Per-prompt Ξw: `lighthouse +28`, `tavern +13`, `forest +14`, `scholar -114`, `dragon -102`, `city -149`, `market -141`. **Sampler alone rephrases <5% trigrams, cuts clichΓ©, no TTR collapse.**
|
| 23 |
+
|
| 24 |
+
## 2. TEST 1.0 β TEST greedy (sampler effect on voiced)
|
| 25 |
+
|
| 26 |
+
*Subagent B* β **Greedy lengthens and diverges more.**
|
| 27 |
+
|
| 28 |
+
| metric | TEST 1.0 | TEST greedy | Ξ |
|
| 29 |
+
|---|---|---|---|
|
| 30 |
+
| total words |5563|5723|+160 +2.9%|
|
| 31 |
+
| hits distinct |8|6 (occ 9β10) |+1 (+0.014/100w) eval -2 (-0.04)|
|
| 32 |
+
| per100 (eval)|0.14|0.10|-0.04|
|
| 33 |
+
| thinking avg|2628|2663|+35 +1.3%|
|
| 34 |
+
| text avg|3336|3378|+42|
|
| 35 |
+
| th/txt|0.788|0.788|0.00|
|
| 36 |
+
| TTR|0.535|0.506|-0.029|
|
| 37 |
+
| rep max|2.6|3.4|+0.8|
|
| 38 |
+
| overall J self| β | β | **1.99% 221/11128 avg per-prompt 1.53% 0.8-3.1%**|
|
| 39 |
+
|
| 40 |
+
`whisper 5β2 (-60%)` but `beautiful 1β2, magnificent 0β1, suddenly 0β1` trade β net flat. Per-prompt `id1 235β372 +137` flipped opening, `id5 483β328 -155` dialogue swapped. **Opposite length vs OG (-6.9% vs +2.9% ΞΞ +9.8pp), less self-consistent 1.99% < 3.67% β voice flattens temp sensitivity but amplifies phrasing shift.**
|
| 41 |
+
|
| 42 |
+
## 3. OG greedy vs TEST greedy (fair voice benchmark, temp 0.0 matches Gryphe `discussion/1` greedy)
|
| 43 |
+
|
| 44 |
+
*Subagent C* β **Voice persists deterministically.**
|
| 45 |
+
|
| 46 |
+
| id | OGg words | OGg clichΓ© | OGg first80 | TESTg words | TESTg clichΓ© | TESTg first80 | J | Ξw |
|
| 47 |
+
|---|---|---|---|---|---|---|---:|---|
|
| 48 |
+
|1|360|0|The old lighthouse had stoodβ¦ She climbedβ¦|372|1 `whisper`|The iron groanedβ¦ heartbeatβ¦|2.3% 17/731|+12|
|
| 49 |
+
|2|901|1 `whisper`|The rain hammeredβ¦ The Crooked Tankardβ¦|880|0|The rain outside the Rusty Nailβ¦ bony fingersβ¦|3.9% 69/1749|-21|
|
| 50 |
+
|3|188|1 `shimmering`|The sky bleedsβ¦ bruised violetβ¦|230|0|Pale gold light fracturesβ¦ honeyβ¦|2.7% 11/412|+42|
|
| 51 |
+
|4|615|0|The answer dependsβ¦ scholar's characterβ¦|628|0|Because this is a creative writing promptβ¦|4.8% 55/1137|+13|
|
| 52 |
+
|5|486|0|"I already talked to the realtorβ¦|" |328|0|"The realtor called again."|2.3% 19/823|-158|
|
| 53 |
+
|6|372|0|They call him **Vaelith**β¦|399|2 `beautiful,suddenly`|You would expect a hoard of coinβ¦|1.0% 8/768|+27|
|
| 54 |
+
|7|386|1 `whisper`|The neon signs bledβ¦|581|1 `beautiful`|Elias knew city's baselineβ¦|1.5% 15/971|+195|
|
| 55 |
+
|8|415|1 `beautiful`|Dearest Clara, β¦ candleβ¦|479|0|October 14th My Dearest Claraβ¦ gardenβ¦|3.0% 26/881|+64|
|
| 56 |
+
|9|665|1 `beautiful`|The shutdown sequence beganβ¦|914|1 `shimmering`|Unit 7344 initiatedβ¦|1.7% 27/1569|+249|
|
| 57 |
+
|10|697|1 `beautiful`|The market has no fixed locationβ¦|912|1 `whisper`|The market appears only when you stop lookingβ¦|2.3% 36/1561|+215|
|
| 58 |
+
|
| 59 |
+
**Aggregates greedy:** `OG 5085w 6 hits 0.12/100w thinking 2580 text 2880 0/10 TTR 0.494` vs `TEST 5723w 6 hits 0.10/100w 2663 3378 0/10 TTR 0.506` **Ξ +638w +12.5%, -0.013/100w, +83 thinking, +498 text, TTR +0.012, overall J 3.23% (336/10406) per-prompt 1.0-4.8% median 2.3%`. All openings diverge within 40c even greedy. **Greedy shared 3.2% > 1.0 shared 1.62% β canonical convergence, still << author `19.9%` shared (their 200 multi-turn certified, private regex `calculating/predatory xx` β `1.141β0.551`). Our heuristic undercounts absolute (`0.12β0.10` vs `1.141β0.551`) but direction matches.
|
| 60 |
+
|
| 61 |
+
## 4. Cross (voice vs sampler dominance)
|
| 62 |
+
|
| 63 |
+
*Subagent D* β **Voice dominates sampler at 1.0.**
|
| 64 |
+
|
| 65 |
+
| Pair | A w | B w | A/100 | B/100 | thA | thB | txtA | txtB | per-prompt J avg | overall J | TTR AβB | rep |
|
| 66 |
+
|---|---|---|---|---|---|---|---|---|
|
| 67 |
+
| **c OG1 vs TEST1** |5463|5563|0.18|0.14|2654|2628|3099|3336|0.91%|1.62% 176/10877|0.500β0.535|3.4β2.6|
|
| 68 |
+
| **d OGg vs TESTg** |5085|5723|0.12|0.10|2580|2663|2880|3378|2.57%|3.23% 336/10406|0.494β0.506|3.0β3.4|
|
| 69 |
+
| **a OG1 vs TESTg** |5463|5723|0.18|0.10|2654|2663|3099|3378|1.94%|2.59% 282/10892|0.500β0.506|3.4β3.4|
|
| 70 |
+
| **b TEST1 vs OGg** |5563|5085|0.14|0.12|2628|2580|3336|2880|1.28%|1.75% 183/10438|0.535β0.494|2.6β3.0|
|
| 71 |
+
| **self OG1 vs OGg** |5463|5085|0.18|0.12|2654|2580|3099|2880|2.80%|3.67% 370/10076|0.500β0.494|3.4β3.0|
|
| 72 |
+
| **self TEST1 vs TESTg** |5563|5723|0.14|0.10|2628|2663|3336|3378|1.53%|1.99% 221/11128|0.535β0.506|2.6β3.4|
|
| 73 |
+
|
| 74 |
+
Sorted overall J lowβhigh (lower = more distance): `1.62% OG1vsTEST1 < 1.75% TEST1vsOGg < 1.99% TEST self < 2.59% OG1vsTESTg < 3.23% OGg vs TESTg < 3.67% OG self`. Cross voice `1.75-2.59%` < self sampler `1.99-3.67%` and near same-temp voice `1.62%` β **voice cross-temp stays in voice regime, far below same-model sampler 3.67%.** Voice @1.0 dominates sampler by `2.05pp` (`1.62 vs 3.67 0.44Γ`); at greedy `3.23 vs 1.99` sampler TEST more divergent. No prompt collapses: cross per-prompt `0.7-3.2%` same as same-temp `0.2-1.9%`.
|
| 75 |
+
|
| 76 |
+
Cross first80 flips: `OG1 "Clara's boots rang..." vs TESTg "The iron groaned..."` 2.0% even cross-temp; `TEST1 "The Rat and Whistle smelled of sour ale..." vs OGg "The rain hammered... The Crooked Tankard"` 1.1% β voice changes tavern name entirely. Self OG anchor `id4 4.8%` both `The answer depends entirely...` shows deterministic outline.
|
| 77 |
+
|
| 78 |
+
## 5. QA Anchor Review
|
| 79 |
+
|
| 80 |
+
- **Data gathering effective:** 4 agents isolated, explicit 50-phrase heuristic, `words split()` not regex, `THINK_RES` channel, `raw` with `<|channel>thought` reconstruction verified. All `4Γ10/10` present, `empty 0/10` after `2000` fix (was `TEST 2/10 empty` at 800), `max 2000` sufficient (`thinking 1.9-3.5k` + `text 1-5k`).
|
| 81 |
+
- **Consistency:** Word totals `5463/5085/5563/5723` reproduce within `Β±14`, J self `3.67/1.99/3.23` stable, per100 `0.18β0.12` OG, `0.14β0.10` TEST direction in all agents (A/B/C/D agree).
|
| 82 |
+
- **Limitations:** `50` heuristic misses Gryphe private `calculating/predatory xx` regex, so absolute `0.1-0.2` vs `1.14` scale not comparable; small `n=10` single-turn vs `200` multi-turn `1-20` intervals certified clichΓ©-free; `temp 1.0` creative vs `0.0` greedy absolute not comparable β direction and `shared` low is signal.
|
| 83 |
+
- **Synthesis:** Sampler effect: OG shortens/denses, TEST lengthens/diverges β voice-specific temp response. Voice effect persists deterministic (`OGg vs TESTg 3.2%` all `<5%`), cross-temp `~2%` β same-temp voice, far below sampler `3.7%` β **style transfer robust across samplers, not sampler artifact**. Voice dominates at `1.0`, still strong at `0.0`. Canonical convergence `1.6%β3.2%` expected but far below `19.9%` author β our prompts more divergent or trigram Jaccard `|aβ©b|/|aβͺb|` stricter than their vocabulary %.
|
| 84 |
+
- **Next extra (per discussion/1):** to replicate `1.141β0.551` need `200` prompts + private regex at `0.0` greedy, certified set; also report `thinking` overhead separate (Gryphe pre-thinking, not mentioned).
|
| 85 |
+
|
| 86 |
+
## Files for reproduction
|
| 87 |
+
|
| 88 |
+
- `voice_test.py` `max_tokens 2000`
|
| 89 |
+
- Style delta via `voice delta` / `voice_delta.py`
|
| 90 |
+
- `checkpoint.md` dense, `eval.md` 1.0 numbers, this `benchmark.md` deep.
|
| 91 |
+
|
lockup-circle-wordmark-2-refined.png
ADDED
|
voice
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
SCRIPT="$(readlink -f "$0")"
|
| 3 |
+
exec python3 "$(dirname "$SCRIPT")/voice.py" "$@"
|
voice.py
ADDED
|
@@ -0,0 +1,1861 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
voice - Get a voice, then cast it onto your model.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
voice get <huggingface-model> # Get a voice from a HuggingFace model
|
| 7 |
+
voice cast <target.gguf> <voice> # Cast a voice onto your model
|
| 8 |
+
voice delta [voice] # Make a style you can re-use (voice β base)
|
| 9 |
+
voice list [base] # List stored voices (or cached bases)
|
| 10 |
+
voice info <voice> # Show voice details
|
| 11 |
+
voice remove <voice> # Remove a stored voice
|
| 12 |
+
voice path # Put `voice` on your PATH
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import struct
|
| 19 |
+
import sys
|
| 20 |
+
import time
|
| 21 |
+
import urllib.error
|
| 22 |
+
import re
|
| 23 |
+
import urllib.request
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from urllib.parse import urlparse
|
| 26 |
+
|
| 27 |
+
VOICES_DIR = Path.home() / ".voices"
|
| 28 |
+
|
| 29 |
+
DTYPE_BYTES = {"F32": 4, "F16": 2, "BF16": 2}
|
| 30 |
+
|
| 31 |
+
VERBOSE = False
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
# Output helpers
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
|
| 38 |
+
def _say(msg):
|
| 39 |
+
print(msg, flush=True)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _fail(msg):
|
| 43 |
+
sys.stdout.flush()
|
| 44 |
+
print(msg, flush=True)
|
| 45 |
+
sys.exit(1)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
# HTTP helpers
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
def http_range(url, start, end, timeout=60):
|
| 53 |
+
req = urllib.request.Request(url, headers={"Range": f"bytes={start}-{end}"})
|
| 54 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 55 |
+
etag = resp.headers.get("ETag")
|
| 56 |
+
return resp.read(), resp.status, etag
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def http_head_etag(url, timeout=15):
|
| 60 |
+
"""Fetch ETag via HEAD request, return etag or None."""
|
| 61 |
+
try:
|
| 62 |
+
req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "voice/1.0"})
|
| 63 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 64 |
+
return resp.headers.get("ETag")
|
| 65 |
+
except Exception:
|
| 66 |
+
if VERBOSE:
|
| 67 |
+
_say(f" β Could not fetch ETag for {url}")
|
| 68 |
+
return None
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def fetch_header(url):
|
| 72 |
+
data, _, _ = http_range(url, 0, 7)
|
| 73 |
+
header_size = struct.unpack("<Q", data)[0]
|
| 74 |
+
data, _, _ = http_range(url, 0, 8 + header_size - 1)
|
| 75 |
+
header = json.loads(data[8 : 8 + header_size])
|
| 76 |
+
return header, header_size
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ---------------------------------------------------------------------------
|
| 80 |
+
# Safetensors read / write
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
|
| 83 |
+
def read_safetensors(path):
|
| 84 |
+
try:
|
| 85 |
+
file_size = os.path.getsize(path)
|
| 86 |
+
with open(path, "rb") as f:
|
| 87 |
+
raw = f.read(8)
|
| 88 |
+
if len(raw) < 8:
|
| 89 |
+
raise ValueError("file too short for safetensors header")
|
| 90 |
+
header_size = struct.unpack("<Q", raw)[0]
|
| 91 |
+
if header_size <= 0 or header_size >= file_size:
|
| 92 |
+
raise ValueError(
|
| 93 |
+
f"header_size {header_size} not sane for file of {file_size} bytes"
|
| 94 |
+
)
|
| 95 |
+
header = json.loads(f.read(header_size))
|
| 96 |
+
except (json.JSONDecodeError, ValueError, OSError, struct.error) as exc:
|
| 97 |
+
raise ValueError(f"{path} is not a valid .safetensors file.") from exc
|
| 98 |
+
tensors = {k: v for k, v in header.items() if k != "__metadata__"}
|
| 99 |
+
return header, tensors
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def write_safetensors(tensors_dict, output_path):
|
| 103 |
+
header = {}
|
| 104 |
+
offset = 0
|
| 105 |
+
for name, (data, dtype, shape) in tensors_dict.items():
|
| 106 |
+
n_bytes = len(data)
|
| 107 |
+
header[name] = {
|
| 108 |
+
"dtype": dtype,
|
| 109 |
+
"shape": shape,
|
| 110 |
+
"data_offsets": [offset, offset + n_bytes],
|
| 111 |
+
}
|
| 112 |
+
offset += n_bytes
|
| 113 |
+
header_json = json.dumps(header).encode("utf-8")
|
| 114 |
+
with open(output_path, "wb") as f:
|
| 115 |
+
f.write(struct.pack("<Q", len(header_json)))
|
| 116 |
+
f.write(header_json)
|
| 117 |
+
for name in tensors_dict:
|
| 118 |
+
f.write(tensors_dict[name][0])
|
| 119 |
+
return os.path.getsize(output_path)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
# Registry helpers
|
| 124 |
+
# ---------------------------------------------------------------------------
|
| 125 |
+
|
| 126 |
+
def _registry_resolve(name_or_path):
|
| 127 |
+
"""Resolve a voice name or path. Returns (Path, metadata_dict_or_None)."""
|
| 128 |
+
p = Path(name_or_path)
|
| 129 |
+
if p.is_file() and p.suffix == ".safetensors":
|
| 130 |
+
return p, None
|
| 131 |
+
if p.is_dir() and (p / "voice.safetensors").exists():
|
| 132 |
+
vpath = p / "voice.safetensors"
|
| 133 |
+
meta_path = p / "voice.json"
|
| 134 |
+
meta = None
|
| 135 |
+
if meta_path.exists():
|
| 136 |
+
try:
|
| 137 |
+
meta = json.loads(meta_path.read_text())
|
| 138 |
+
except (json.JSONDecodeError, ValueError):
|
| 139 |
+
if VERBOSE:
|
| 140 |
+
_say(f" β unreadable metadata: {meta_path}")
|
| 141 |
+
return vpath, meta
|
| 142 |
+
target = name_or_path.strip().lower()
|
| 143 |
+
if not VOICES_DIR.exists():
|
| 144 |
+
return None, None
|
| 145 |
+
for d in VOICES_DIR.iterdir():
|
| 146 |
+
if d.is_dir() and d.name.lower() == target:
|
| 147 |
+
vpath = d / "voice.safetensors"
|
| 148 |
+
if vpath.exists():
|
| 149 |
+
meta_path = d / "voice.json"
|
| 150 |
+
meta = None
|
| 151 |
+
if meta_path.exists():
|
| 152 |
+
try:
|
| 153 |
+
meta = json.loads(meta_path.read_text())
|
| 154 |
+
except (json.JSONDecodeError, ValueError):
|
| 155 |
+
if VERBOSE:
|
| 156 |
+
_say(f" β unreadable metadata: {meta_path}")
|
| 157 |
+
return vpath, meta
|
| 158 |
+
return None, None
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _registry_name_exists(name):
|
| 162 |
+
target = name.strip().lower()
|
| 163 |
+
if not VOICES_DIR.exists():
|
| 164 |
+
return False
|
| 165 |
+
for d in VOICES_DIR.iterdir():
|
| 166 |
+
if d.is_dir() and d.name.lower() == target:
|
| 167 |
+
return True
|
| 168 |
+
return False
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _registry_folder(name):
|
| 172 |
+
return VOICES_DIR / name.strip().lower()
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _sanitize_voice_name(name):
|
| 176 |
+
"""Return sanitized name or None if invalid."""
|
| 177 |
+
if not name or not name.strip():
|
| 178 |
+
return None
|
| 179 |
+
name = name.strip()
|
| 180 |
+
if name.lower().startswith("delta-"):
|
| 181 |
+
return None
|
| 182 |
+
if name in (".", ".."):
|
| 183 |
+
return None
|
| 184 |
+
bad_chars = set("/\\.: ")
|
| 185 |
+
for c in name:
|
| 186 |
+
if c in bad_chars or ord(c) < 32:
|
| 187 |
+
return None
|
| 188 |
+
return name
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _sanitize_delta_component(name):
|
| 192 |
+
"""Sanitize delta component (voicename for delta). Reject slash, backslash, dot and control chars."""
|
| 193 |
+
if not name or not name.strip():
|
| 194 |
+
return None
|
| 195 |
+
name = name.strip()
|
| 196 |
+
if name in (".", ".."):
|
| 197 |
+
return None
|
| 198 |
+
bad_chars = set("/\\.: ")
|
| 199 |
+
for c in name:
|
| 200 |
+
if c in bad_chars or ord(c) < 32:
|
| 201 |
+
return None
|
| 202 |
+
return name
|
| 203 |
+
# ---------------------------------------------------------------------------
|
| 204 |
+
# Delta helpers (base detection, normalization, base cache)
|
| 205 |
+
# ---------------------------------------------------------------------------
|
| 206 |
+
|
| 207 |
+
def _normalize_hf_repo(s):
|
| 208 |
+
"""Normalize HF repo: accept org/model or https://huggingface.co/org/model[/...] -> org/model."""
|
| 209 |
+
if not s or not s.strip():
|
| 210 |
+
return None
|
| 211 |
+
s = s.strip().rstrip("/")
|
| 212 |
+
if s.startswith("http://") or s.startswith("https://"):
|
| 213 |
+
try:
|
| 214 |
+
parsed = urlparse(s)
|
| 215 |
+
path = parsed.path.strip("/")
|
| 216 |
+
# huggingface.co/<org>/<model>/...
|
| 217 |
+
parts = path.split("/")
|
| 218 |
+
if len(parts) >= 2:
|
| 219 |
+
org = parts[0].strip()
|
| 220 |
+
model = parts[1].strip()
|
| 221 |
+
if org and model:
|
| 222 |
+
return f"{org}/{model}"
|
| 223 |
+
return None
|
| 224 |
+
except Exception:
|
| 225 |
+
return None
|
| 226 |
+
else:
|
| 227 |
+
# strip possible revision suffix like @main or :revision
|
| 228 |
+
s = s.split("@")[0].split(":")[0].strip()
|
| 229 |
+
parts = s.split("/")
|
| 230 |
+
if len(parts) >= 2 and parts[0] and parts[1]:
|
| 231 |
+
return f"{parts[0].strip()}/{parts[1].strip()}"
|
| 232 |
+
return None
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _hf_api_base_model(source_id):
|
| 236 |
+
"""Fetch HF api/models/<id> -> cardData.base_model (str or list). Returns list or None."""
|
| 237 |
+
url = f"https://huggingface.co/api/models/{source_id}"
|
| 238 |
+
try:
|
| 239 |
+
req = urllib.request.Request(url, headers={"User-Agent": "voice/1.0"})
|
| 240 |
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
| 241 |
+
data = json.loads(resp.read())
|
| 242 |
+
card = data.get("cardData") or {}
|
| 243 |
+
bm = card.get("base_model")
|
| 244 |
+
if bm is None:
|
| 245 |
+
# sometimes base_model at top-level?
|
| 246 |
+
bm = data.get("base_model")
|
| 247 |
+
if bm is None:
|
| 248 |
+
return None
|
| 249 |
+
if isinstance(bm, str):
|
| 250 |
+
return [bm]
|
| 251 |
+
if isinstance(bm, list):
|
| 252 |
+
# filter strings
|
| 253 |
+
return [x for x in bm if isinstance(x, str) and x.strip()]
|
| 254 |
+
return None
|
| 255 |
+
except Exception as e:
|
| 256 |
+
if VERBOSE:
|
| 257 |
+
_say(f" β could not fetch base model for {source_id}: {e}")
|
| 258 |
+
return None
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def _hf_mergekit_base(source_id):
|
| 262 |
+
"""Fetch mergekit_config.yml and parse base_model. Returns str or None."""
|
| 263 |
+
for tmpl in [
|
| 264 |
+
f"https://huggingface.co/{source_id}/raw/main/mergekit_config.yml",
|
| 265 |
+
f"https://huggingface.co/{source_id}/resolve/main/mergekit_config.yml",
|
| 266 |
+
f"https://huggingface.co/{source_id}/raw/main/mergekit_config.yaml",
|
| 267 |
+
]:
|
| 268 |
+
try:
|
| 269 |
+
req = urllib.request.Request(tmpl, headers={"User-Agent": "voice/1.0"})
|
| 270 |
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
| 271 |
+
text = resp.read().decode("utf-8", errors="replace")
|
| 272 |
+
# simple regex for base_model
|
| 273 |
+
# could be: base_model: org/model or base_model: "org/model"
|
| 274 |
+
m = re.search(r'base_model\s*:\s*["\']?\s*([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)\s*["\']?', text)
|
| 275 |
+
if m:
|
| 276 |
+
cand = m.group(1).strip()
|
| 277 |
+
if "/" in cand:
|
| 278 |
+
return cand
|
| 279 |
+
# also look for model: org/model in list
|
| 280 |
+
# fallback: first org/model occurrence
|
| 281 |
+
# but prefer base_model
|
| 282 |
+
except Exception as e:
|
| 283 |
+
if VERBOSE:
|
| 284 |
+
_say(f" β mergekit fetch failed for {tmpl}: {e}")
|
| 285 |
+
continue
|
| 286 |
+
return None
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _detect_base(source_id):
|
| 290 |
+
"""Return (suggested_base:str or None, is_multi:bool, count:int, raw_list:list)."""
|
| 291 |
+
raw = _hf_api_base_model(source_id)
|
| 292 |
+
if raw:
|
| 293 |
+
if len(raw) == 1:
|
| 294 |
+
return raw[0], False, 1, raw
|
| 295 |
+
elif len(raw) > 1:
|
| 296 |
+
# try mergekit
|
| 297 |
+
tried = _hf_mergekit_base(source_id)
|
| 298 |
+
if tried:
|
| 299 |
+
return tried, True, len(raw), raw
|
| 300 |
+
# fallback to first
|
| 301 |
+
return raw[0], True, len(raw), raw
|
| 302 |
+
# heuristic fallback
|
| 303 |
+
low = source_id.lower()
|
| 304 |
+
if "styletune" in low or "gryphe" in low:
|
| 305 |
+
return "google/gemma-4-26B-A4B-it", False, 1, ["google/gemma-4-26B-A4B-it"]
|
| 306 |
+
if "goetia" in low:
|
| 307 |
+
return "google/gemma-4-26B-A4B", False, 1, ["google/gemma-4-26B-A4B"]
|
| 308 |
+
# generic fallback: unknown -> none, caller will prompt
|
| 309 |
+
return None, False, 0, []
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _base_paths(base_id):
|
| 313 |
+
"""Return (file_path:Path, json_path:Path, nested_dir:Path, nested_file:Path, nested_json:Path) for base cache."""
|
| 314 |
+
norm = _normalize_hf_repo(base_id)
|
| 315 |
+
if norm is None:
|
| 316 |
+
return None
|
| 317 |
+
parts = norm.split("/")
|
| 318 |
+
org = parts[0].lower()
|
| 319 |
+
model = parts[1].lower()
|
| 320 |
+
file_path = VOICES_DIR / "base" / org / f"{model}.safetensors"
|
| 321 |
+
json_path = VOICES_DIR / "base" / org / f"{model}.json"
|
| 322 |
+
nested_dir = VOICES_DIR / "base" / org / model
|
| 323 |
+
nested_file = nested_dir / "base.safetensors"
|
| 324 |
+
nested_json = nested_dir / "base.json"
|
| 325 |
+
return file_path, json_path, nested_dir, nested_file, nested_json
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def _fetch_base_head_info(base_id):
|
| 329 |
+
"""Fetch lm_head info for base without downloading tensor bytes. Returns (shard_url, tensor_name, header, header_size, info) or None."""
|
| 330 |
+
# reuse logic from cmd_get: try index then probe
|
| 331 |
+
lm_head_shard_url = None
|
| 332 |
+
lm_head_tensor_name = None
|
| 333 |
+
header = None
|
| 334 |
+
header_size = None
|
| 335 |
+
index = _hf_model_index(base_id)
|
| 336 |
+
if index and "weight_map" in index:
|
| 337 |
+
wm = index["weight_map"]
|
| 338 |
+
found = next((k for k in wm if _is_lm_head_name(k)), None)
|
| 339 |
+
if found:
|
| 340 |
+
shard_file = wm[found]
|
| 341 |
+
lm_head_shard_url = f"https://huggingface.co/{base_id}/resolve/main/{shard_file}"
|
| 342 |
+
lm_head_tensor_name = found
|
| 343 |
+
if lm_head_shard_url is None:
|
| 344 |
+
# probe urls; fetch header to confirm
|
| 345 |
+
for url in _hf_model_urls(base_id):
|
| 346 |
+
try:
|
| 347 |
+
h, hs = fetch_header(url)
|
| 348 |
+
tensors = {k: v for k, v in h.items() if k != "__metadata__"}
|
| 349 |
+
found = next((k for k in ("lm_head.weight","output.weight") if k in tensors), next((k for k in tensors if _is_lm_head_name(k)), None))
|
| 350 |
+
if found:
|
| 351 |
+
lm_head_shard_url = url
|
| 352 |
+
header = h
|
| 353 |
+
header_size = hs
|
| 354 |
+
lm_head_tensor_name = found
|
| 355 |
+
break
|
| 356 |
+
except Exception:
|
| 357 |
+
continue
|
| 358 |
+
if lm_head_shard_url is None:
|
| 359 |
+
return None
|
| 360 |
+
if header is None:
|
| 361 |
+
try:
|
| 362 |
+
header, header_size = fetch_header(lm_head_shard_url)
|
| 363 |
+
except Exception:
|
| 364 |
+
return None
|
| 365 |
+
tensors = {k: v for k, v in header.items() if k != "__metadata__"}
|
| 366 |
+
if lm_head_tensor_name not in tensors:
|
| 367 |
+
return None
|
| 368 |
+
info = tensors[lm_head_tensor_name]
|
| 369 |
+
return lm_head_shard_url, lm_head_tensor_name, header, header_size, info
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def _download_base_tensor(base_id):
|
| 373 |
+
"""Download base lm_head and save to cache. Returns (file_path, json_path, info_dict)."""
|
| 374 |
+
fetched = _fetch_base_head_info(base_id)
|
| 375 |
+
if fetched is None:
|
| 376 |
+
if VERBOSE:
|
| 377 |
+
_say(f" base lookup tried lm_head tensor for {base_id} β not found")
|
| 378 |
+
_fail(f" β We couldn't find that base model. Check the name and try again.")
|
| 379 |
+
lm_head_shard_url, lm_head_tensor_name, header, header_size, info = fetched
|
| 380 |
+
src_dtype = info["dtype"]
|
| 381 |
+
shape = info["shape"]
|
| 382 |
+
begin, end = info["data_offsets"]
|
| 383 |
+
tensor_size = end - begin
|
| 384 |
+
abs_start = 8 + header_size + begin
|
| 385 |
+
abs_end = 8 + header_size + end - 1
|
| 386 |
+
_say(f" β¦ Fetching base head ({base_id})...")
|
| 387 |
+
if VERBOSE:
|
| 388 |
+
_say(f" Tensor: {lm_head_tensor_name} {shape} {src_dtype} {tensor_size/1e6:.1f} MB")
|
| 389 |
+
_say(f" From: {lm_head_shard_url.split('/')[-1]}")
|
| 390 |
+
try:
|
| 391 |
+
data, status, etag = http_range(lm_head_shard_url, abs_start, abs_end, timeout=300)
|
| 392 |
+
except Exception as e:
|
| 393 |
+
if VERBOSE:
|
| 394 |
+
_say(f" β Network error fetching base: {e}")
|
| 395 |
+
_fail(f" β We couldn't reach the base model. Check your connection and try again.")
|
| 396 |
+
if status != 206:
|
| 397 |
+
_fail(f" β We couldn't reach the base model. Check your connection and try again.")
|
| 398 |
+
# fallback ETag via HEAD if Range didn't return one
|
| 399 |
+
if not etag:
|
| 400 |
+
etag = http_head_etag(lm_head_shard_url)
|
| 401 |
+
# prepare paths
|
| 402 |
+
bps = _base_paths(base_id)
|
| 403 |
+
if bps is None:
|
| 404 |
+
_fail(f" β Invalid base repo '{base_id}'.")
|
| 405 |
+
file_path, json_path, nested_dir, nested_file, nested_json = bps
|
| 406 |
+
# ensure dirs - single layout only: VOICES_DIR/base/<org>/<model>.safetensors
|
| 407 |
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 408 |
+
# write safetensors
|
| 409 |
+
tensors_out = {lm_head_tensor_name: (data, src_dtype, shape)}
|
| 410 |
+
# primary file (hierarchical spec)
|
| 411 |
+
write_safetensors(tensors_out, str(file_path))
|
| 412 |
+
from datetime import datetime, timezone
|
| 413 |
+
meta = {
|
| 414 |
+
"source_hf_model": base_id,
|
| 415 |
+
"tensor_name": lm_head_tensor_name,
|
| 416 |
+
"shape": shape,
|
| 417 |
+
"dtype": src_dtype,
|
| 418 |
+
"bytes": tensor_size,
|
| 419 |
+
"file_bytes": len(data),
|
| 420 |
+
"shard_url": lm_head_shard_url,
|
| 421 |
+
"etag": etag,
|
| 422 |
+
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
| 423 |
+
"repo": f"https://huggingface.co/{base_id}",
|
| 424 |
+
"header_size": header_size,
|
| 425 |
+
}
|
| 426 |
+
# write jsons - single layout only
|
| 427 |
+
json_path.write_text(json.dumps(meta, indent=2) + "\n")
|
| 428 |
+
return file_path, meta, lm_head_shard_url, tensor_size
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
# ---------------------------------------------------------------------------
|
| 435 |
+
# CMD: get
|
| 436 |
+
# ---------------------------------------------------------------------------
|
| 437 |
+
|
| 438 |
+
def _hf_model_urls(model_id):
|
| 439 |
+
"""Yield likely safetensors URLs for a HuggingFace model."""
|
| 440 |
+
base = f"https://huggingface.co/{model_id}/resolve/main"
|
| 441 |
+
for fname in [
|
| 442 |
+
"model.safetensors",
|
| 443 |
+
"model-00001-of-00002.safetensors",
|
| 444 |
+
"model-00002-of-00002.safetensors",
|
| 445 |
+
"model-00003-of-00003.safetensors",
|
| 446 |
+
"model-00004-of-00004.safetensors",
|
| 447 |
+
"model-00005-of-00005.safetensors",
|
| 448 |
+
]:
|
| 449 |
+
yield f"{base}/{fname}"
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def _hf_model_index(model_id):
|
| 453 |
+
"""Fetch the model index to find which shard has lm_head."""
|
| 454 |
+
url = f"https://huggingface.co/{model_id}/resolve/main/model.safetensors.index.json"
|
| 455 |
+
try:
|
| 456 |
+
req = urllib.request.Request(url)
|
| 457 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 458 |
+
return json.loads(resp.read())
|
| 459 |
+
except (urllib.error.HTTPError, urllib.error.URLError):
|
| 460 |
+
return None
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def _hf_config(model_id):
|
| 464 |
+
"""Fetch config.json from a HF model."""
|
| 465 |
+
url = f"https://huggingface.co/{model_id}/resolve/main/config.json"
|
| 466 |
+
try:
|
| 467 |
+
req = urllib.request.Request(url)
|
| 468 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 469 |
+
return json.loads(resp.read())
|
| 470 |
+
except Exception:
|
| 471 |
+
return None
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def _hf_license(model_id):
|
| 475 |
+
"""Try to detect license from the model card."""
|
| 476 |
+
url = f"https://huggingface.co/{model_id}/resolve/main/LICENSE"
|
| 477 |
+
try:
|
| 478 |
+
req = urllib.request.Request(url)
|
| 479 |
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
| 480 |
+
text = resp.read(2000).decode("utf-8", errors="replace").strip()
|
| 481 |
+
if len(text) > 10:
|
| 482 |
+
return text[:200]
|
| 483 |
+
except Exception:
|
| 484 |
+
pass
|
| 485 |
+
url2 = f"https://huggingface.co/{model_id}/raw/main/LICENSE"
|
| 486 |
+
try:
|
| 487 |
+
req = urllib.request.Request(url2)
|
| 488 |
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
| 489 |
+
text = resp.read(2000).decode("utf-8", errors="replace").strip()
|
| 490 |
+
if len(text) > 10:
|
| 491 |
+
return text[:200]
|
| 492 |
+
except Exception:
|
| 493 |
+
pass
|
| 494 |
+
return None
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def _is_lm_head_name(name):
|
| 498 |
+
"""True if name is the tied output projection / lm_head (HF names vary by arch)."""
|
| 499 |
+
if "lm_head" in name or name.endswith("output.weight"):
|
| 500 |
+
return True
|
| 501 |
+
if name.endswith("embed_tokens.weight"):
|
| 502 |
+
return True
|
| 503 |
+
return False
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
def cmd_get(args):
|
| 507 |
+
model_id = args.model_id.strip().rstrip("/")
|
| 508 |
+
if not model_id:
|
| 509 |
+
_fail(" β Enter a model name β for example: voice get google/gemma-3-1b")
|
| 510 |
+
_say(f" β¦ Finding the voice in {model_id}...")
|
| 511 |
+
|
| 512 |
+
lm_head_shard_url = None
|
| 513 |
+
lm_head_tensor_name = None
|
| 514 |
+
header = None
|
| 515 |
+
header_size = None
|
| 516 |
+
|
| 517 |
+
index = _hf_model_index(model_id)
|
| 518 |
+
if index and "weight_map" in index:
|
| 519 |
+
wm = index["weight_map"]
|
| 520 |
+
found = next((k for k in wm if _is_lm_head_name(k)), None)
|
| 521 |
+
if found:
|
| 522 |
+
shard_file = wm[found]
|
| 523 |
+
lm_head_shard_url = (
|
| 524 |
+
f"https://huggingface.co/{model_id}/resolve/main/{shard_file}"
|
| 525 |
+
)
|
| 526 |
+
lm_head_tensor_name = found
|
| 527 |
+
if VERBOSE:
|
| 528 |
+
_say(f" Found {found} in {shard_file}")
|
| 529 |
+
else:
|
| 530 |
+
for url in _hf_model_urls(model_id):
|
| 531 |
+
if VERBOSE:
|
| 532 |
+
_say(f" Probing {url.split('/')[-1]}...")
|
| 533 |
+
try:
|
| 534 |
+
h, hs = fetch_header(url)
|
| 535 |
+
tensors = {k: v for k, v in h.items() if k != "__metadata__"}
|
| 536 |
+
found = next(
|
| 537 |
+
(
|
| 538 |
+
k
|
| 539 |
+
for k in ("lm_head.weight", "output.weight")
|
| 540 |
+
if k in tensors
|
| 541 |
+
),
|
| 542 |
+
next(
|
| 543 |
+
(k for k in tensors if _is_lm_head_name(k)),
|
| 544 |
+
None,
|
| 545 |
+
),
|
| 546 |
+
)
|
| 547 |
+
if found:
|
| 548 |
+
lm_head_shard_url = url
|
| 549 |
+
header = h
|
| 550 |
+
header_size = hs
|
| 551 |
+
lm_head_tensor_name = found
|
| 552 |
+
if VERBOSE:
|
| 553 |
+
_say(f" Found {found} in {url.split('/')[-1]}")
|
| 554 |
+
break
|
| 555 |
+
except urllib.error.HTTPError as e:
|
| 556 |
+
if e.code in (401,):
|
| 557 |
+
_fail(f" β Model '{model_id}' was not found or is not accessible.")
|
| 558 |
+
continue
|
| 559 |
+
except (urllib.error.URLError, TimeoutError, OSError):
|
| 560 |
+
_fail(f" β We couldn't reach the model. Check your connection and try again.")
|
| 561 |
+
except Exception:
|
| 562 |
+
continue
|
| 563 |
+
|
| 564 |
+
if lm_head_shard_url is None:
|
| 565 |
+
_fail(
|
| 566 |
+
f' β "{model_id}" doesn\'t have a voice. Try another model.\n'
|
| 567 |
+
f" (no lm_head found β this model may not expose one)"
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
if header is None:
|
| 571 |
+
if VERBOSE:
|
| 572 |
+
_say(f" Fetching header...")
|
| 573 |
+
try:
|
| 574 |
+
header, header_size = fetch_header(lm_head_shard_url)
|
| 575 |
+
except urllib.error.HTTPError:
|
| 576 |
+
_fail(f" β We couldn't reach the model. Check your connection and try again.")
|
| 577 |
+
except (urllib.error.URLError, TimeoutError, OSError):
|
| 578 |
+
_fail(f" β We couldn't reach the model. Check your connection and try again.")
|
| 579 |
+
|
| 580 |
+
tensors = {k: v for k, v in header.items() if k != "__metadata__"}
|
| 581 |
+
if lm_head_tensor_name not in tensors:
|
| 582 |
+
_fail(
|
| 583 |
+
f' β "{model_id}" doesn\'t have a voice. Try another model.\n'
|
| 584 |
+
f" (lm_head tensor not found in the model's header)"
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
info = tensors[lm_head_tensor_name]
|
| 588 |
+
src_dtype = info["dtype"]
|
| 589 |
+
shape = info["shape"]
|
| 590 |
+
begin, end = info["data_offsets"]
|
| 591 |
+
tensor_size = end - begin
|
| 592 |
+
|
| 593 |
+
if VERBOSE:
|
| 594 |
+
_say(f" Tensor: {lm_head_tensor_name}")
|
| 595 |
+
_say(f" Dtype: {src_dtype}")
|
| 596 |
+
_say(f" Shape: {shape}")
|
| 597 |
+
_say(f" Size: {tensor_size:,} bytes ({tensor_size / 1e6:.1f} MB)")
|
| 598 |
+
|
| 599 |
+
abs_start = 8 + header_size + begin
|
| 600 |
+
abs_end = 8 + header_size + end - 1
|
| 601 |
+
if VERBOSE:
|
| 602 |
+
_say(f" Downloading...")
|
| 603 |
+
t0 = time.time()
|
| 604 |
+
try:
|
| 605 |
+
data, status, _etag = http_range(lm_head_shard_url, abs_start, abs_end, timeout=300)
|
| 606 |
+
except urllib.error.HTTPError:
|
| 607 |
+
_fail(f" β We couldn't reach the model. Check your connection and try again.")
|
| 608 |
+
except (urllib.error.URLError, TimeoutError, OSError):
|
| 609 |
+
_fail(f" β We couldn't reach the model. Check your connection and try again.")
|
| 610 |
+
t1 = time.time()
|
| 611 |
+
if status != 206:
|
| 612 |
+
_fail(f" β We couldn't reach the model. Check your connection and try again.")
|
| 613 |
+
if VERBOSE:
|
| 614 |
+
_say(f" Downloaded {len(data):,} bytes in {t1 - t0:.1f}s")
|
| 615 |
+
|
| 616 |
+
default_name = model_id.split("/")[-1]
|
| 617 |
+
while True:
|
| 618 |
+
try:
|
| 619 |
+
name = input(f" β¦ Found the voice. Name it: [{default_name}] ").strip()
|
| 620 |
+
except (EOFError, KeyboardInterrupt):
|
| 621 |
+
_say(" β Cancelled.")
|
| 622 |
+
sys.exit(0)
|
| 623 |
+
if not name:
|
| 624 |
+
name = default_name
|
| 625 |
+
if name.strip().lower().startswith("delta-"):
|
| 626 |
+
_say(f" β delta- is reserved for deltas.")
|
| 627 |
+
continue
|
| 628 |
+
if _sanitize_voice_name(name) is None:
|
| 629 |
+
_say(
|
| 630 |
+
f" β Invalid name. Use letters, numbers, hyphens, underscores (no /, \\, or dots)."
|
| 631 |
+
)
|
| 632 |
+
continue
|
| 633 |
+
break
|
| 634 |
+
|
| 635 |
+
folder = _registry_folder(name)
|
| 636 |
+
if _registry_name_exists(name) and folder.exists():
|
| 637 |
+
try:
|
| 638 |
+
ans = input(f" Voice '{name}' already exists. Overwrite? [y/N]: ").strip().lower()
|
| 639 |
+
except (EOFError, KeyboardInterrupt):
|
| 640 |
+
_say(" β Cancelled.")
|
| 641 |
+
sys.exit(0)
|
| 642 |
+
if ans not in ("y", "yes"):
|
| 643 |
+
_say(" Cancelled.")
|
| 644 |
+
sys.exit(0)
|
| 645 |
+
|
| 646 |
+
if VERBOSE:
|
| 647 |
+
_say(f" Fetching model config...")
|
| 648 |
+
config = _hf_config(model_id)
|
| 649 |
+
vocab_size = None
|
| 650 |
+
hidden_dim = None
|
| 651 |
+
architecture = None
|
| 652 |
+
if config:
|
| 653 |
+
vocab_size = config.get("vocab_size")
|
| 654 |
+
hidden_dim = config.get("hidden_size") or config.get("n_embd")
|
| 655 |
+
architecture = (
|
| 656 |
+
config.get("architectures", [None])[0]
|
| 657 |
+
if config.get("architectures")
|
| 658 |
+
else None
|
| 659 |
+
)
|
| 660 |
+
|
| 661 |
+
if not args.quiet:
|
| 662 |
+
if VERBOSE:
|
| 663 |
+
_say(f" Detecting license...")
|
| 664 |
+
license_text = _hf_license(model_id)
|
| 665 |
+
else:
|
| 666 |
+
license_text = None
|
| 667 |
+
|
| 668 |
+
from datetime import datetime, timezone
|
| 669 |
+
|
| 670 |
+
folder.mkdir(parents=True, exist_ok=True)
|
| 671 |
+
tensors_out = {lm_head_tensor_name: (data, src_dtype, shape)}
|
| 672 |
+
fpath = folder / "voice.safetensors"
|
| 673 |
+
write_safetensors(tensors_out, str(fpath))
|
| 674 |
+
|
| 675 |
+
meta = {
|
| 676 |
+
"name": name,
|
| 677 |
+
"source_hf_model": model_id,
|
| 678 |
+
"tensor_name": lm_head_tensor_name,
|
| 679 |
+
"vocab_size": vocab_size,
|
| 680 |
+
"hidden_dim": hidden_dim,
|
| 681 |
+
"dtype": src_dtype,
|
| 682 |
+
"shape": shape,
|
| 683 |
+
"architecture": architecture,
|
| 684 |
+
"license": license_text,
|
| 685 |
+
"downloaded_at": datetime.now(timezone.utc).isoformat(),
|
| 686 |
+
}
|
| 687 |
+
(folder / "voice.json").write_text(json.dumps(meta, indent=2) + "\n")
|
| 688 |
+
|
| 689 |
+
_say(f" β¦ Done. Your new voice is ready.")
|
| 690 |
+
_say(f" Saved to: {folder}")
|
| 691 |
+
_say(f" Use it: voice cast <your-model.gguf> {name}")
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
# ---------------------------------------------------------------------------
|
| 695 |
+
# CMD: cast (v2 fused β replace output.weight with Q8_0 voice)
|
| 696 |
+
# ---------------------------------------------------------------------------
|
| 697 |
+
|
| 698 |
+
def _bf16_to_f32(bf16_u16):
|
| 699 |
+
"""Convert BF16 uint16 array to float32."""
|
| 700 |
+
import numpy as np
|
| 701 |
+
# bf16 bits are top 16 of f32: shift left 16
|
| 702 |
+
u32 = bf16_u16.astype(np.uint32) << 16
|
| 703 |
+
return u32.view(np.float32).copy()
|
| 704 |
+
|
| 705 |
+
|
| 706 |
+
def cmd_cast(args):
|
| 707 |
+
import numpy as np
|
| 708 |
+
|
| 709 |
+
gguf_path = args.target_gguf
|
| 710 |
+
if not os.path.exists(gguf_path):
|
| 711 |
+
_fail(f' β "{gguf_path}" couldn\'t be found. Check the path and try again.')
|
| 712 |
+
if os.path.isdir(gguf_path):
|
| 713 |
+
_fail(f' β "{gguf_path}" is a folder, not a model file.')
|
| 714 |
+
|
| 715 |
+
gguf_base = os.path.splitext(os.path.basename(gguf_path))[0]
|
| 716 |
+
|
| 717 |
+
voice_basename = os.path.basename(args.voice).lower()
|
| 718 |
+
voice_basename = os.path.splitext(voice_basename)[0]
|
| 719 |
+
|
| 720 |
+
# Compute out_path without creating directories yet (fail-fast)
|
| 721 |
+
if args.out:
|
| 722 |
+
out_path = args.out
|
| 723 |
+
looks_like_dir = (
|
| 724 |
+
out_path.endswith("/")
|
| 725 |
+
or out_path.endswith(os.sep)
|
| 726 |
+
or os.path.isdir(out_path)
|
| 727 |
+
or not os.path.splitext(out_path)[1]
|
| 728 |
+
)
|
| 729 |
+
if looks_like_dir:
|
| 730 |
+
# defer mkdir until after validation
|
| 731 |
+
out_path = os.path.join(out_path, f"{voice_basename}-{gguf_base}.gguf")
|
| 732 |
+
else:
|
| 733 |
+
out_path = os.path.join(os.path.abspath(gguf_base), f"{voice_basename}-{gguf_base}.gguf")
|
| 734 |
+
|
| 735 |
+
voice_path, voice_meta = _registry_resolve(args.voice)
|
| 736 |
+
if voice_path is None or not voice_path.exists():
|
| 737 |
+
_fail(
|
| 738 |
+
f" β Voice '{args.voice}' not found. "
|
| 739 |
+
f"Use 'voice list' to see available voices, or pass a .safetensors path."
|
| 740 |
+
)
|
| 741 |
+
|
| 742 |
+
# HIG P0-3 + P1-2: robust delta detection (meta or folder name + dtype check)
|
| 743 |
+
voice_label = voice_meta.get("name", args.voice) if voice_meta else str(voice_path)
|
| 744 |
+
is_delta = False
|
| 745 |
+
if voice_meta and voice_meta.get("type") == "delta":
|
| 746 |
+
is_delta = True
|
| 747 |
+
if not is_delta:
|
| 748 |
+
try:
|
| 749 |
+
parent_name = Path(voice_path).parent.name
|
| 750 |
+
if parent_name.lower().startswith("delta-"):
|
| 751 |
+
is_delta = True
|
| 752 |
+
except Exception:
|
| 753 |
+
pass
|
| 754 |
+
#(dtype check is deferred until after reading tensor; but also handle quickly if meta has base)
|
| 755 |
+
if not is_delta and voice_meta and voice_meta.get("base"):
|
| 756 |
+
try:
|
| 757 |
+
# need dtype; will refine after reading, but mark tentative
|
| 758 |
+
pass
|
| 759 |
+
except Exception:
|
| 760 |
+
pass
|
| 761 |
+
|
| 762 |
+
# we will finalize dtype check after reading header (see below)
|
| 763 |
+
_say(f" β¦ Checking your voice ({voice_label})..." + (" (delta)" if is_delta else ""))
|
| 764 |
+
try:
|
| 765 |
+
_, voice_tensors = read_safetensors(str(voice_path))
|
| 766 |
+
except ValueError as exc:
|
| 767 |
+
_fail(f" β {exc}")
|
| 768 |
+
|
| 769 |
+
if not voice_tensors:
|
| 770 |
+
_fail(f" β No tensors found in {voice_path}")
|
| 771 |
+
|
| 772 |
+
vt_name = list(voice_tensors.keys())[0]
|
| 773 |
+
vinfo = voice_tensors[vt_name]
|
| 774 |
+
v_dtype = vinfo["dtype"]
|
| 775 |
+
v_shape = tuple(vinfo["shape"])
|
| 776 |
+
v_begin, v_end = vinfo["data_offsets"]
|
| 777 |
+
# HIG P0-3 dtype check: deltas are stored as F16; use as fallback signal
|
| 778 |
+
if not is_delta and v_dtype.upper() == "F16":
|
| 779 |
+
try:
|
| 780 |
+
if Path(voice_path).parent.name.lower().startswith("delta-"):
|
| 781 |
+
is_delta = True
|
| 782 |
+
elif voice_meta and voice_meta.get("base"):
|
| 783 |
+
is_delta = True
|
| 784 |
+
except Exception:
|
| 785 |
+
pass
|
| 786 |
+
# if is_delta was prematurely true, reflect in message
|
| 787 |
+
if is_delta and " (delta)" not in f"{voice_label}":
|
| 788 |
+
# re-announce as delta if needed (no extra line, just continue)
|
| 789 |
+
pass
|
| 790 |
+
|
| 791 |
+
with open(voice_path, "rb") as f:
|
| 792 |
+
f.seek(
|
| 793 |
+
8
|
| 794 |
+
+ struct.unpack("<Q", open(voice_path, "rb").read(8))[0]
|
| 795 |
+
+ v_begin
|
| 796 |
+
)
|
| 797 |
+
voice_raw = f.read(v_end - v_begin)
|
| 798 |
+
|
| 799 |
+
# Decode voice to float32
|
| 800 |
+
dtype_upper = v_dtype.upper()
|
| 801 |
+
if dtype_upper in ("BF16", "BF16E8M"):
|
| 802 |
+
v_u16 = np.frombuffer(voice_raw, dtype=np.uint16).reshape(v_shape)
|
| 803 |
+
voice_f32 = _bf16_to_f32(v_u16)
|
| 804 |
+
elif dtype_upper in ("F16", "FP16", "F16E5M6"):
|
| 805 |
+
voice_f32 = np.frombuffer(voice_raw, dtype=np.float16).reshape(v_shape).astype(np.float32)
|
| 806 |
+
elif dtype_upper in ("F32", "FP32"):
|
| 807 |
+
voice_f32 = np.frombuffer(voice_raw, dtype=np.float32).reshape(v_shape).astype(np.float32)
|
| 808 |
+
else:
|
| 809 |
+
_fail(f" β This voice uses an unsupported format ({v_dtype}).")
|
| 810 |
+
|
| 811 |
+
_say(f" β¦ Checking your model ({os.path.basename(gguf_path)})...")
|
| 812 |
+
from gguf import GGUFReader
|
| 813 |
+
from gguf.constants import GGMLQuantizationType
|
| 814 |
+
|
| 815 |
+
try:
|
| 816 |
+
reader = GGUFReader(gguf_path)
|
| 817 |
+
except Exception:
|
| 818 |
+
_fail(f" β {gguf_path} is not a valid GGUF file.")
|
| 819 |
+
|
| 820 |
+
gguf_arch = None
|
| 821 |
+
try:
|
| 822 |
+
for field in reader.fields.values():
|
| 823 |
+
if field.name == "general.architecture":
|
| 824 |
+
raw = field.parts[field.data[0]]
|
| 825 |
+
try:
|
| 826 |
+
gguf_arch = raw.tobytes().decode()
|
| 827 |
+
except Exception:
|
| 828 |
+
gguf_arch = str(raw)
|
| 829 |
+
break
|
| 830 |
+
except Exception:
|
| 831 |
+
gguf_arch = None
|
| 832 |
+
|
| 833 |
+
target_tensor = None
|
| 834 |
+
used_name = None
|
| 835 |
+
|
| 836 |
+
for candidate in ["lm_head.weight", "output.weight"]:
|
| 837 |
+
for tensor in reader.tensors:
|
| 838 |
+
if tensor.name == candidate:
|
| 839 |
+
target_tensor = tensor
|
| 840 |
+
used_name = candidate
|
| 841 |
+
break
|
| 842 |
+
if target_tensor is not None:
|
| 843 |
+
break
|
| 844 |
+
|
| 845 |
+
if target_tensor is None:
|
| 846 |
+
largest_2d = None
|
| 847 |
+
largest_size = 0
|
| 848 |
+
for tensor in reader.tensors:
|
| 849 |
+
if len(tensor.shape) == 2:
|
| 850 |
+
size = 1
|
| 851 |
+
for s in tensor.shape:
|
| 852 |
+
size *= int(s)
|
| 853 |
+
if size > largest_size:
|
| 854 |
+
largest_size = size
|
| 855 |
+
largest_2d = tensor
|
| 856 |
+
used_name = tensor.name
|
| 857 |
+
if largest_2d is not None:
|
| 858 |
+
target_tensor = largest_2d
|
| 859 |
+
if VERBOSE:
|
| 860 |
+
_say(f" β Using '{used_name}' (largest 2-D tensor)")
|
| 861 |
+
|
| 862 |
+
if target_tensor is None:
|
| 863 |
+
_fail(
|
| 864 |
+
f" β Could not find lm_head.weight, output.weight, or any 2-D tensor in {gguf_path}"
|
| 865 |
+
)
|
| 866 |
+
|
| 867 |
+
gguf_shape = tuple(int(x) for x in target_tensor.shape)
|
| 868 |
+
if VERBOSE:
|
| 869 |
+
_say(f" GGUF tensor '{used_name}': shape={gguf_shape}")
|
| 870 |
+
_say(f" Voice tensor: shape={v_shape} ({v_dtype})")
|
| 871 |
+
|
| 872 |
+
# Determine logical shape of base head (GGUF stores shape reversed).
|
| 873 |
+
from gguf.constants import GGMLQuantizationType as _QT2
|
| 874 |
+
if target_tensor.tensor_type in (_QT2.F32, _QT2.F16):
|
| 875 |
+
try:
|
| 876 |
+
base_logical = tuple(int(x) for x in target_tensor.data.shape)
|
| 877 |
+
if len(base_logical) != len(gguf_shape):
|
| 878 |
+
base_logical = gguf_shape[::-1]
|
| 879 |
+
except Exception:
|
| 880 |
+
base_logical = gguf_shape[::-1]
|
| 881 |
+
else:
|
| 882 |
+
base_logical = gguf_shape[::-1]
|
| 883 |
+
|
| 884 |
+
# Orient voice to match GGUF logical shape
|
| 885 |
+
if voice_f32.shape == base_logical:
|
| 886 |
+
pass
|
| 887 |
+
elif voice_f32.shape == base_logical[::-1]:
|
| 888 |
+
if VERBOSE:
|
| 889 |
+
_say(f" Transposing voice to match model layout...")
|
| 890 |
+
voice_f32 = np.ascontiguousarray(voice_f32.T)
|
| 891 |
+
else:
|
| 892 |
+
_say(
|
| 893 |
+
f" β This voice doesn't fit this model. It's made for a different size of model, so they won't speak together."
|
| 894 |
+
)
|
| 895 |
+
if VERBOSE:
|
| 896 |
+
_say(f" Voice shape: {voice_f32.shape} ({v_dtype})")
|
| 897 |
+
_say(f" Model shape: {gguf_shape} ({target_tensor.tensor_type.name})")
|
| 898 |
+
if voice_meta:
|
| 899 |
+
_say(f" Voice source: {voice_meta.get('source_hf_model', voice_meta.get('source', 'unknown'))}")
|
| 900 |
+
sys.exit(1)
|
| 901 |
+
|
| 902 |
+
if voice_meta and voice_meta.get("architecture") and gguf_arch:
|
| 903 |
+
if voice_meta["architecture"].lower() != gguf_arch.lower():
|
| 904 |
+
_say(
|
| 905 |
+
f" β This voice was trained for a {voice_meta['architecture']} model, "
|
| 906 |
+
f"but this file is {gguf_arch}. It may not sound right."
|
| 907 |
+
)
|
| 908 |
+
try:
|
| 909 |
+
ans = input(f" Continue anyway? [y/N]: ").strip().lower()
|
| 910 |
+
except (EOFError, KeyboardInterrupt):
|
| 911 |
+
_say(" β Cancelled.")
|
| 912 |
+
sys.exit(0)
|
| 913 |
+
if ans not in ("y", "yes"):
|
| 914 |
+
_say(" Cancelled.")
|
| 915 |
+
sys.exit(0)
|
| 916 |
+
|
| 917 |
+
# After validation, ensure output directory exists (fail-fast before heavy quantize)
|
| 918 |
+
out_abs = os.path.abspath(out_path)
|
| 919 |
+
out_dir = os.path.dirname(out_abs) or "."
|
| 920 |
+
if out_dir and not os.path.isdir(out_dir):
|
| 921 |
+
try:
|
| 922 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 923 |
+
except Exception:
|
| 924 |
+
pass
|
| 925 |
+
if out_dir and not os.path.isdir(out_dir):
|
| 926 |
+
_fail(
|
| 927 |
+
f" β The voiced model couldn't be saved. The folder \"{out_dir}\" "
|
| 928 |
+
f"doesn't exist or isn't writable."
|
| 929 |
+
)
|
| 930 |
+
|
| 931 |
+
# Branch: delta vs full
|
| 932 |
+
from gguf.quants import quantize
|
| 933 |
+
|
| 934 |
+
if is_delta:
|
| 935 |
+
# Delta mode: new_head = abliterated_head + delta
|
| 936 |
+
_say(f" β¦ Adding style to {os.path.basename(gguf_path)} (delta β Q8_0)...")
|
| 937 |
+
from gguf.quants import dequantize
|
| 938 |
+
delta_f32 = voice_f32
|
| 939 |
+
if delta_f32.shape[1] % 32 != 0:
|
| 940 |
+
_fail(
|
| 941 |
+
f" β Style shape {delta_f32.shape} not compatible with Q8_0 (last dim must be multiple of 32)."
|
| 942 |
+
)
|
| 943 |
+
# Dequant abliterated head to F32
|
| 944 |
+
if target_tensor.tensor_type in (GGMLQuantizationType.F32, GGMLQuantizationType.F16):
|
| 945 |
+
if target_tensor.tensor_type == GGMLQuantizationType.F16:
|
| 946 |
+
ab_f32 = target_tensor.data.astype(np.float32)
|
| 947 |
+
else:
|
| 948 |
+
ab_f32 = target_tensor.data.view(np.float32).astype(np.float32) if target_tensor.data.dtype != np.float32 else target_tensor.data.astype(np.float32)
|
| 949 |
+
ab_f32 = np.ascontiguousarray(ab_f32)
|
| 950 |
+
else:
|
| 951 |
+
ab_f32 = dequantize(target_tensor.data, target_tensor.tensor_type)
|
| 952 |
+
if ab_f32.dtype != np.float32:
|
| 953 |
+
ab_f32 = ab_f32.astype(np.float32)
|
| 954 |
+
if ab_f32.shape != delta_f32.shape:
|
| 955 |
+
_fail(
|
| 956 |
+
f" β Style shape {delta_f32.shape} doesn't match model head {ab_f32.shape}."
|
| 957 |
+
)
|
| 958 |
+
try:
|
| 959 |
+
ab_f32 += delta_f32
|
| 960 |
+
except Exception as e:
|
| 961 |
+
if VERBOSE:
|
| 962 |
+
_say(f" Details: {e}")
|
| 963 |
+
_fail(f" β Could not apply style. Try again with a different voice or model.")
|
| 964 |
+
del delta_f32
|
| 965 |
+
new_head = ab_f32
|
| 966 |
+
try:
|
| 967 |
+
q_head = quantize(new_head, GGMLQuantizationType.Q8_0)
|
| 968 |
+
except Exception as e:
|
| 969 |
+
if VERBOSE:
|
| 970 |
+
_say(f" Details: {e}")
|
| 971 |
+
_fail(f" β Could not prepare the voiced model. Try again.")
|
| 972 |
+
del new_head
|
| 973 |
+
else:
|
| 974 |
+
# Full replace (existing behavior)
|
| 975 |
+
_say(f" β¦ Casting your voice onto {os.path.basename(gguf_path)}...")
|
| 976 |
+
if voice_f32.shape[1] % 32 != 0:
|
| 977 |
+
_fail(
|
| 978 |
+
f" β Voice shape {voice_f32.shape} not compatible with Q8_0 (last dim must be multiple of 32)."
|
| 979 |
+
)
|
| 980 |
+
try:
|
| 981 |
+
q_head = quantize(voice_f32, GGMLQuantizationType.Q8_0)
|
| 982 |
+
except Exception as e:
|
| 983 |
+
if VERBOSE:
|
| 984 |
+
_say(f" Details: {e}")
|
| 985 |
+
_fail(f" β Could not prepare the voiced model. Try again.")
|
| 986 |
+
|
| 987 |
+
# Build fused GGUF: copy metadata + all tensors, replace target with Q8_0 head
|
| 988 |
+
from gguf import GGUFWriter
|
| 989 |
+
from gguf.gguf_writer import GGUFValue
|
| 990 |
+
|
| 991 |
+
writer = GGUFWriter(out_path, gguf_arch or "llama", use_temp_file=True)
|
| 992 |
+
|
| 993 |
+
# Copy KV metadata (preserve tokenizer, etc.)
|
| 994 |
+
for field in reader.fields.values():
|
| 995 |
+
if field.name == "general.architecture":
|
| 996 |
+
continue # already set via writer arch
|
| 997 |
+
if field.name.startswith("GGUF."):
|
| 998 |
+
continue # header bookkeeping, writer generates these
|
| 999 |
+
try:
|
| 1000 |
+
val = field.contents()
|
| 1001 |
+
except Exception:
|
| 1002 |
+
if VERBOSE:
|
| 1003 |
+
_say(f" β Skipping field {field.name}")
|
| 1004 |
+
continue
|
| 1005 |
+
vtype = field.types[0] if field.types else None
|
| 1006 |
+
if vtype is None:
|
| 1007 |
+
continue
|
| 1008 |
+
subtype = field.types[-1] if len(field.types) > 1 else None
|
| 1009 |
+
# Direct assignment preserves type exactly
|
| 1010 |
+
try:
|
| 1011 |
+
writer.kv_data[0][field.name] = GGUFValue(value=val, type=vtype, sub_type=subtype)
|
| 1012 |
+
except Exception:
|
| 1013 |
+
# Fallback via generic API
|
| 1014 |
+
try:
|
| 1015 |
+
writer.add_key_value(field.name, val, vtype, subtype)
|
| 1016 |
+
except Exception:
|
| 1017 |
+
if VERBOSE:
|
| 1018 |
+
_say(f" β Could not copy field {field.name}")
|
| 1019 |
+
|
| 1020 |
+
# Copy tensors, replacing the head
|
| 1021 |
+
for tensor in reader.tensors:
|
| 1022 |
+
if tensor.name == used_name:
|
| 1023 |
+
writer.add_tensor(tensor.name, q_head, raw_dtype=GGMLQuantizationType.Q8_0)
|
| 1024 |
+
else:
|
| 1025 |
+
if tensor.tensor_type in (GGMLQuantizationType.F32, GGMLQuantizationType.F16):
|
| 1026 |
+
writer.add_tensor(tensor.name, tensor.data)
|
| 1027 |
+
else:
|
| 1028 |
+
writer.add_tensor(tensor.name, tensor.data, raw_dtype=tensor.tensor_type)
|
| 1029 |
+
|
| 1030 |
+
try:
|
| 1031 |
+
writer.write_header_to_file()
|
| 1032 |
+
writer.write_kv_data_to_file()
|
| 1033 |
+
writer.write_tensors_to_file()
|
| 1034 |
+
writer.close()
|
| 1035 |
+
except OSError as e:
|
| 1036 |
+
if VERBOSE:
|
| 1037 |
+
_say(f" Details: {e}")
|
| 1038 |
+
_fail(f" β Could not save the voiced model to {out_path}. Check that the folder is writable and the disk isn't full.")
|
| 1039 |
+
except Exception as e:
|
| 1040 |
+
if VERBOSE:
|
| 1041 |
+
_say(f" Details: {e}")
|
| 1042 |
+
_fail(f" β Could not save the voiced model to {out_path}. Check that the folder is writable and the disk isn't full.")
|
| 1043 |
+
|
| 1044 |
+
file_size = os.path.getsize(out_path)
|
| 1045 |
+
if is_delta:
|
| 1046 |
+
_say(f" β¦ Done β {out_path}")
|
| 1047 |
+
if VERBOSE:
|
| 1048 |
+
_say(f" Head at Q8_0, style fused (abliterated + delta). ({file_size / 1e6:.1f} MB)")
|
| 1049 |
+
else:
|
| 1050 |
+
_say(f" Head ready at full quality, style fused. ({file_size / 1e6:.1f} MB)")
|
| 1051 |
+
_say(f" Load it: llama serve -m {out_path}")
|
| 1052 |
+
else:
|
| 1053 |
+
_say(f" β¦ Done β {out_path}")
|
| 1054 |
+
if VERBOSE:
|
| 1055 |
+
_say(f" Head at Q8_0, model voiced. ({file_size / 1e6:.1f} MB)")
|
| 1056 |
+
else:
|
| 1057 |
+
_say(f" Head ready at full quality. ({file_size / 1e6:.1f} MB)")
|
| 1058 |
+
_say(f" Load it: llama serve -m {out_path}")
|
| 1059 |
+
|
| 1060 |
+
|
| 1061 |
+
# ---------------------------------------------------------------------------
|
| 1062 |
+
# CMD: delta β create style delta (voice - base)
|
| 1063 |
+
# ---------------------------------------------------------------------------
|
| 1064 |
+
|
| 1065 |
+
def cmd_delta(args):
|
| 1066 |
+
import numpy as np
|
| 1067 |
+
# Resolve voicename: if no arg, show list numbered and prompt choose
|
| 1068 |
+
voicename = getattr(args, "voicename", None)
|
| 1069 |
+
if voicename is None or not str(voicename).strip():
|
| 1070 |
+
# show numbered list
|
| 1071 |
+
if not VOICES_DIR.exists() or not any(VOICES_DIR.iterdir()):
|
| 1072 |
+
_fail(" β No voices stored yet. Use 'voice get <model>' to save one.")
|
| 1073 |
+
# collect voices with metadata
|
| 1074 |
+
voices = []
|
| 1075 |
+
for d in sorted(VOICES_DIR.iterdir()):
|
| 1076 |
+
if not d.is_dir():
|
| 1077 |
+
continue
|
| 1078 |
+
if d.name == "base":
|
| 1079 |
+
continue
|
| 1080 |
+
meta_path = d / "voice.json"
|
| 1081 |
+
if not meta_path.exists():
|
| 1082 |
+
continue
|
| 1083 |
+
try:
|
| 1084 |
+
meta = json.loads(meta_path.read_text())
|
| 1085 |
+
except Exception:
|
| 1086 |
+
continue
|
| 1087 |
+
voices.append((d.name, meta))
|
| 1088 |
+
if not voices:
|
| 1089 |
+
_fail(" β No voices stored yet. Use 'voice get <model>' to save one.")
|
| 1090 |
+
_say(" Available voices:")
|
| 1091 |
+
for i, (dirname, meta) in enumerate(voices, 1):
|
| 1092 |
+
name = meta.get("name", dirname)
|
| 1093 |
+
src = meta.get("source_hf_model", meta.get("source", "β"))
|
| 1094 |
+
_say(f" {i}. {name} β {src}")
|
| 1095 |
+
try:
|
| 1096 |
+
choice = input(f" Choose voice [1-{len(voices)}]: ").strip()
|
| 1097 |
+
except (EOFError, KeyboardInterrupt):
|
| 1098 |
+
_say(" β Cancelled.")
|
| 1099 |
+
sys.exit(0)
|
| 1100 |
+
if not choice.isdigit() or not (1 <= int(choice) <= len(voices)):
|
| 1101 |
+
_fail(f" β Invalid choice.")
|
| 1102 |
+
voicename = voices[int(choice)-1][1].get("name", voices[int(choice)-1][0])
|
| 1103 |
+
voicename = voicename.strip()
|
| 1104 |
+
# P0-3: path traversal guard β sanitize voicename / delta component
|
| 1105 |
+
if "/" in voicename or "\\" in voicename:
|
| 1106 |
+
_fail(f" β Invalid voice name '{voicename}'. Use letters, numbers, hyphens, underscores (no /, \\, or dots).")
|
| 1107 |
+
# Use delta-component sanitizer (allows delta- prefix for existing deltas but blocks / \ . )
|
| 1108 |
+
_san_check = _sanitize_delta_component(voicename)
|
| 1109 |
+
if _san_check is None:
|
| 1110 |
+
# allow delta- prefix for existing delta voices: validate suffix
|
| 1111 |
+
if voicename.lower().startswith("delta-"):
|
| 1112 |
+
suffix = voicename[6:]
|
| 1113 |
+
if _sanitize_delta_component(suffix) is None and suffix != "":
|
| 1114 |
+
_fail(f" β Invalid voice name '{voicename}'. Use letters, numbers, hyphens, underscores (no /, \\, or dots).")
|
| 1115 |
+
# also block bare delta- name without suffix?
|
| 1116 |
+
if not suffix:
|
| 1117 |
+
_fail(f" β Invalid voice name '{voicename}'. Use letters, numbers, hyphens, underscores (no /, \\, or dots).")
|
| 1118 |
+
else:
|
| 1119 |
+
_fail(f" β Invalid voice name '{voicename}'. Use letters, numbers, hyphens, underscores (no /, \\, or dots).")
|
| 1120 |
+
# resolve voice
|
| 1121 |
+
voice_path, voice_meta = _registry_resolve(voicename)
|
| 1122 |
+
if voice_path is None or not voice_path.exists():
|
| 1123 |
+
_fail(f" β Voice '{voicename}' not found. Try voice list")
|
| 1124 |
+
# voice_meta may be None if direct path; need to load from folder
|
| 1125 |
+
if voice_meta is None:
|
| 1126 |
+
# try to load voice.json from parent folder
|
| 1127 |
+
maybe_json = Path(voice_path).parent / "voice.json"
|
| 1128 |
+
if maybe_json.exists():
|
| 1129 |
+
try:
|
| 1130 |
+
voice_meta = json.loads(maybe_json.read_text())
|
| 1131 |
+
except Exception:
|
| 1132 |
+
voice_meta = {}
|
| 1133 |
+
else:
|
| 1134 |
+
voice_meta = {}
|
| 1135 |
+
source_hf = voice_meta.get("source_hf_model") or voice_meta.get("source") or "β"
|
| 1136 |
+
_say(f" '{voicename}' is {source_hf}")
|
| 1137 |
+
|
| 1138 |
+
# detect base
|
| 1139 |
+
suggested, is_multi, count, raw_list = _detect_base(source_hf)
|
| 1140 |
+
if suggested is None:
|
| 1141 |
+
# ask user to enter base manually if detection failed
|
| 1142 |
+
_say(f" Base detected: unknown")
|
| 1143 |
+
try:
|
| 1144 |
+
entered = input(f" Enter base HF repo [ ]: ").strip()
|
| 1145 |
+
except (EOFError, KeyboardInterrupt):
|
| 1146 |
+
_say(" β Cancelled.")
|
| 1147 |
+
sys.exit(0)
|
| 1148 |
+
norm = _normalize_hf_repo(entered) if entered else None
|
| 1149 |
+
if not norm:
|
| 1150 |
+
_fail(" β Invalid base repo.")
|
| 1151 |
+
suggested = norm
|
| 1152 |
+
is_multi = False
|
| 1153 |
+
count = 1
|
| 1154 |
+
else:
|
| 1155 |
+
if is_multi:
|
| 1156 |
+
_say(f" Card lists {count} bases, suggested: {suggested}")
|
| 1157 |
+
else:
|
| 1158 |
+
_say(f" Base detected: {suggested}")
|
| 1159 |
+
|
| 1160 |
+
# prompt for base choice
|
| 1161 |
+
try:
|
| 1162 |
+
entered = input(f" Press enter to use {suggested} or enter another HF repo [ {suggested} ]: ").strip()
|
| 1163 |
+
except (EOFError, KeyboardInterrupt):
|
| 1164 |
+
_say(" β Cancelled.")
|
| 1165 |
+
sys.exit(0)
|
| 1166 |
+
if not entered:
|
| 1167 |
+
base_id = suggested
|
| 1168 |
+
else:
|
| 1169 |
+
norm = _normalize_hf_repo(entered)
|
| 1170 |
+
if norm is None:
|
| 1171 |
+
_fail(f" β Invalid HF repo '{entered}'. Use org/model or https://huggingface.co/org/model")
|
| 1172 |
+
base_id = norm
|
| 1173 |
+
|
| 1174 |
+
# Normalize base_id to org/model
|
| 1175 |
+
norm_base = _normalize_hf_repo(base_id)
|
| 1176 |
+
if norm_base is None:
|
| 1177 |
+
_fail(f" β Invalid base repo '{base_id}'.")
|
| 1178 |
+
base_id = norm_base
|
| 1179 |
+
|
| 1180 |
+
# Determine delta name lowercased
|
| 1181 |
+
delta_name = f"delta-{voicename.lower()}"
|
| 1182 |
+
# P0-3: sanitize delta_name component (reject /\)
|
| 1183 |
+
if "/" in delta_name or "\\" in delta_name:
|
| 1184 |
+
_fail(f" β Invalid voice name '{delta_name}'. Use letters, numbers, hyphens, underscores (no /, \\, or dots).")
|
| 1185 |
+
delta_folder = VOICES_DIR / delta_name
|
| 1186 |
+
# P0-1 / HIG P0-4: Overwrite guard before heavy work (like cmd_get)
|
| 1187 |
+
if delta_folder.exists() and (delta_folder / "voice.safetensors").exists():
|
| 1188 |
+
try:
|
| 1189 |
+
ans = input(f" Voice '{delta_name}' already exists. Overwrite? [y/N]: ").strip().lower()
|
| 1190 |
+
except (EOFError, KeyboardInterrupt):
|
| 1191 |
+
_say(" β Cancelled.")
|
| 1192 |
+
sys.exit(0)
|
| 1193 |
+
if ans not in ("y", "yes"):
|
| 1194 |
+
_say(" Cancelled.")
|
| 1195 |
+
sys.exit(0)
|
| 1196 |
+
# check base cache
|
| 1197 |
+
bps = _base_paths(base_id)
|
| 1198 |
+
if bps is None:
|
| 1199 |
+
_fail(f" β Invalid base repo '{base_id}'.")
|
| 1200 |
+
b_file, b_json, b_nested_dir, b_nested_file, b_nested_json = bps
|
| 1201 |
+
|
| 1202 |
+
# decide if base exists
|
| 1203 |
+
base_exists = b_file.exists() or b_nested_file.exists()
|
| 1204 |
+
# prefer file_path existence; but check json for bytes
|
| 1205 |
+
existing_meta = None
|
| 1206 |
+
existing_path = None
|
| 1207 |
+
if b_file.exists():
|
| 1208 |
+
existing_path = b_file
|
| 1209 |
+
if b_json.exists():
|
| 1210 |
+
try:
|
| 1211 |
+
existing_meta = json.loads(b_json.read_text())
|
| 1212 |
+
except Exception:
|
| 1213 |
+
existing_meta = None
|
| 1214 |
+
elif b_nested_file.exists():
|
| 1215 |
+
existing_path = b_nested_file
|
| 1216 |
+
if b_nested_json.exists():
|
| 1217 |
+
try:
|
| 1218 |
+
existing_meta = json.loads(b_nested_json.read_text())
|
| 1219 |
+
except Exception:
|
| 1220 |
+
existing_meta = None
|
| 1221 |
+
|
| 1222 |
+
# if exists, check for update via remote header size
|
| 1223 |
+
if base_exists and existing_meta is not None:
|
| 1224 |
+
try:
|
| 1225 |
+
_say(f" β¦ Checking base cache ({base_id})...")
|
| 1226 |
+
remote = _fetch_base_head_info(base_id)
|
| 1227 |
+
if remote is not None:
|
| 1228 |
+
_, _, _, _, r_info = remote
|
| 1229 |
+
r_begin, r_end = r_info["data_offsets"]
|
| 1230 |
+
remote_bytes = r_end - r_begin
|
| 1231 |
+
# P1-5 bytes missing fallback: graceful handling
|
| 1232 |
+
_local_bytes_raw = existing_meta.get("bytes")
|
| 1233 |
+
if _local_bytes_raw is None:
|
| 1234 |
+
_local_bytes_raw = existing_meta.get("file_bytes")
|
| 1235 |
+
if isinstance(_local_bytes_raw, int):
|
| 1236 |
+
local_bytes = _local_bytes_raw
|
| 1237 |
+
else:
|
| 1238 |
+
# missing or invalid -> treat as 0 and skip update prompt gracefully
|
| 1239 |
+
local_bytes = 0
|
| 1240 |
+
if VERBOSE:
|
| 1241 |
+
_say(f" β local bytes missing for {base_id}, assuming 0")
|
| 1242 |
+
if remote_bytes != local_bytes:
|
| 1243 |
+
# confirm same HF repo and link (P1-4: remove or True tautology)
|
| 1244 |
+
same_repo = (existing_meta.get("source_hf_model","").lower() == base_id.lower()) or (base_id.lower() in existing_meta.get("repo","").lower())
|
| 1245 |
+
if same_repo:
|
| 1246 |
+
try:
|
| 1247 |
+
ans = input(f" Base {base_id} exists but update is available (online {remote_bytes/1e6:.1f} MB vs local {local_bytes/1e6:.1f} MB) β update? [y/N]: ").strip().lower()
|
| 1248 |
+
except (EOFError, KeyboardInterrupt):
|
| 1249 |
+
_say(" β Cancelled.")
|
| 1250 |
+
sys.exit(0)
|
| 1251 |
+
if ans in ("y","yes"):
|
| 1252 |
+
_say(f" β¦ Updating base {base_id}...")
|
| 1253 |
+
_download_base_tensor(base_id)
|
| 1254 |
+
if b_json.exists():
|
| 1255 |
+
try:
|
| 1256 |
+
existing_meta = json.loads(b_json.read_text())
|
| 1257 |
+
except Exception:
|
| 1258 |
+
pass
|
| 1259 |
+
existing_path = b_file if b_file.exists() else b_nested_file
|
| 1260 |
+
else:
|
| 1261 |
+
_say(f" Keeping cached base.")
|
| 1262 |
+
else:
|
| 1263 |
+
if VERBOSE:
|
| 1264 |
+
_say(f" β remote base mismatch, keeping cached")
|
| 1265 |
+
except Exception as e:
|
| 1266 |
+
if VERBOSE:
|
| 1267 |
+
_say(f" β update check failed: {e}")
|
| 1268 |
+
else:
|
| 1269 |
+
# HIG P1-4: network errors silent -> add VERBOSE warning already, but also subtle hint
|
| 1270 |
+
pass
|
| 1271 |
+
# if not exists, download
|
| 1272 |
+
if not (b_file.exists() or b_nested_file.exists()):
|
| 1273 |
+
_download_base_tensor(base_id)
|
| 1274 |
+
if b_file.exists():
|
| 1275 |
+
existing_path = b_file
|
| 1276 |
+
else:
|
| 1277 |
+
existing_path = b_nested_file
|
| 1278 |
+
else:
|
| 1279 |
+
# ensure paths exist after possible update; otherwise already have
|
| 1280 |
+
if not b_file.exists() and b_nested_file.exists():
|
| 1281 |
+
# copy nested to file for spec compliance
|
| 1282 |
+
try:
|
| 1283 |
+
b_file.parent.mkdir(parents=True, exist_ok=True)
|
| 1284 |
+
import shutil as _sh
|
| 1285 |
+
_sh.copy2(str(b_nested_file), str(b_file))
|
| 1286 |
+
if b_nested_json.exists() and not b_json.exists():
|
| 1287 |
+
_sh.copy2(str(b_nested_json), str(b_json))
|
| 1288 |
+
except Exception:
|
| 1289 |
+
pass
|
| 1290 |
+
existing_path = b_file if b_file.exists() else b_nested_file
|
| 1291 |
+
|
| 1292 |
+
# Now we have voice and base ready; compute delta
|
| 1293 |
+
# Load voice header
|
| 1294 |
+
_say(f" β¦ Crafting your style...")
|
| 1295 |
+
try:
|
| 1296 |
+
v_header, v_tensors = read_safetensors(str(voice_path))
|
| 1297 |
+
except ValueError as exc:
|
| 1298 |
+
_fail(f" β {exc}")
|
| 1299 |
+
v_tname = list(v_tensors.keys())[0]
|
| 1300 |
+
v_info = v_tensors[v_tname]
|
| 1301 |
+
v_dtype = v_info["dtype"]
|
| 1302 |
+
v_shape = tuple(v_info["shape"])
|
| 1303 |
+
v_begin, v_end = v_info["data_offsets"]
|
| 1304 |
+
# base header
|
| 1305 |
+
try:
|
| 1306 |
+
b_header, b_tensors = read_safetensors(str(existing_path))
|
| 1307 |
+
except ValueError as exc:
|
| 1308 |
+
_fail(f" β base file invalid: {exc}")
|
| 1309 |
+
b_tname = list(b_tensors.keys())[0]
|
| 1310 |
+
b_info = b_tensors[b_tname]
|
| 1311 |
+
b_dtype = b_info["dtype"]
|
| 1312 |
+
b_shape = tuple(b_info["shape"])
|
| 1313 |
+
b_begin, b_end = b_info["data_offsets"]
|
| 1314 |
+
if v_shape != b_shape:
|
| 1315 |
+
_say(f" β This voice doesn't fit this base. It's made for a different size of model, so they won't blend.")
|
| 1316 |
+
if VERBOSE:
|
| 1317 |
+
_say(f" Voice shape: {v_shape} ({v_dtype})")
|
| 1318 |
+
_say(f" Base shape: {b_shape} ({b_dtype})")
|
| 1319 |
+
sys.exit(1)
|
| 1320 |
+
|
| 1321 |
+
# Prepare output: F16 delta
|
| 1322 |
+
total_elems = 1
|
| 1323 |
+
for d in v_shape:
|
| 1324 |
+
total_elems *= int(d)
|
| 1325 |
+
out_dtype = "F16"
|
| 1326 |
+
out_shape = v_shape
|
| 1327 |
+
out_tensor_name = v_tname # keep original name; delta same shape
|
| 1328 |
+
# Delta uses same tensor name, but we note style.delta compatibility; keep original
|
| 1329 |
+
# For spec, dtype F16
|
| 1330 |
+
header_out = {out_tensor_name: {"dtype": out_dtype, "shape": list(out_shape), "data_offsets": [0, total_elems*2]}}
|
| 1331 |
+
hj = json.dumps(header_out).encode()
|
| 1332 |
+
|
| 1333 |
+
# P0-1 overwrite already prompted above; now create folder
|
| 1334 |
+
delta_folder.mkdir(parents=True, exist_ok=True)
|
| 1335 |
+
out_path = delta_folder / "voice.safetensors"
|
| 1336 |
+
# Open files and compute chunked 1M elems
|
| 1337 |
+
chunk_elems = 1 << 20 # 1M
|
| 1338 |
+
# Determine bytes per elem for each dtype
|
| 1339 |
+
def _dtype_bytes(dt):
|
| 1340 |
+
dt = dt.upper()
|
| 1341 |
+
if dt in ("F32","FP32"):
|
| 1342 |
+
return 4
|
| 1343 |
+
return 2 # F16, BF16
|
| 1344 |
+
v_bpe = _dtype_bytes(v_dtype)
|
| 1345 |
+
b_bpe = _dtype_bytes(b_dtype)
|
| 1346 |
+
|
| 1347 |
+
# Need header sizes to locate data
|
| 1348 |
+
with open(voice_path, "rb") as fv:
|
| 1349 |
+
v_hs = struct.unpack("<Q", fv.read(8))[0]
|
| 1350 |
+
# we already have v_header, but need data start: 8+v_hs
|
| 1351 |
+
v_data_start = 8 + v_hs + v_begin
|
| 1352 |
+
with open(existing_path, "rb") as fb:
|
| 1353 |
+
b_hs = struct.unpack("<Q", fb.read(8))[0]
|
| 1354 |
+
b_data_start = 8 + b_hs + b_begin
|
| 1355 |
+
|
| 1356 |
+
# Chunked processing
|
| 1357 |
+
import shutil as _shutil
|
| 1358 |
+
# We'll write header then stream
|
| 1359 |
+
with open(voice_path, "rb") as fs, open(existing_path, "rb") as fb, open(out_path, "wb") as out:
|
| 1360 |
+
out.write(struct.pack("<Q", len(hj)))
|
| 1361 |
+
out.write(hj)
|
| 1362 |
+
fs.seek(v_data_start)
|
| 1363 |
+
fb.seek(b_data_start)
|
| 1364 |
+
processed = 0
|
| 1365 |
+
while processed < total_elems:
|
| 1366 |
+
cur = min(chunk_elems, total_elems - processed)
|
| 1367 |
+
# read cur * bpe
|
| 1368 |
+
v_bytes = fs.read(cur * v_bpe)
|
| 1369 |
+
b_bytes = fb.read(cur * b_bpe)
|
| 1370 |
+
if len(v_bytes) != cur * v_bpe or len(b_bytes) != cur * b_bpe:
|
| 1371 |
+
_fail(f" β Short read during delta compute")
|
| 1372 |
+
# decode to f32
|
| 1373 |
+
# voice
|
| 1374 |
+
vu = v_dtype.upper()
|
| 1375 |
+
if vu in ("BF16","BF16E8M"):
|
| 1376 |
+
v_u16 = np.frombuffer(v_bytes, dtype=np.uint16)
|
| 1377 |
+
v_f32 = _bf16_to_f32(v_u16)
|
| 1378 |
+
elif vu in ("F16","FP16","F16E5M6"):
|
| 1379 |
+
v_f32 = np.frombuffer(v_bytes, dtype=np.float16).astype(np.float32)
|
| 1380 |
+
elif vu in ("F32","FP32"):
|
| 1381 |
+
v_f32 = np.frombuffer(v_bytes, dtype=np.float32).astype(np.float32)
|
| 1382 |
+
else:
|
| 1383 |
+
_fail(f" β Unsupported voice dtype {v_dtype}")
|
| 1384 |
+
# base
|
| 1385 |
+
bu = b_dtype.upper()
|
| 1386 |
+
if bu in ("BF16","BF16E8M"):
|
| 1387 |
+
b_u16 = np.frombuffer(b_bytes, dtype=np.uint16)
|
| 1388 |
+
b_f32 = _bf16_to_f32(b_u16)
|
| 1389 |
+
elif bu in ("F16","FP16","F16E5M6"):
|
| 1390 |
+
b_f32 = np.frombuffer(b_bytes, dtype=np.float16).astype(np.float32)
|
| 1391 |
+
elif bu in ("F32","FP32"):
|
| 1392 |
+
b_f32 = np.frombuffer(b_bytes, dtype=np.float32).astype(np.float32)
|
| 1393 |
+
else:
|
| 1394 |
+
_fail(f" β Unsupported base dtype {b_dtype}")
|
| 1395 |
+
delta_f32 = v_f32 - b_f32
|
| 1396 |
+
delta_f16 = delta_f32.astype(np.float16)
|
| 1397 |
+
out.write(delta_f16.tobytes())
|
| 1398 |
+
processed += cur
|
| 1399 |
+
if VERBOSE and processed % (10<<20) < chunk_elems:
|
| 1400 |
+
_say(f" {processed/total_elems*100:.1f}% {processed}/{total_elems}")
|
| 1401 |
+
|
| 1402 |
+
# Save delta metadata
|
| 1403 |
+
from datetime import datetime, timezone
|
| 1404 |
+
# Try to fetch base shard url from base meta if exists
|
| 1405 |
+
base_meta = None
|
| 1406 |
+
if b_json.exists():
|
| 1407 |
+
try:
|
| 1408 |
+
base_meta = json.loads(b_json.read_text())
|
| 1409 |
+
except Exception:
|
| 1410 |
+
base_meta = None
|
| 1411 |
+
if base_meta is None and b_nested_json.exists():
|
| 1412 |
+
try:
|
| 1413 |
+
base_meta = json.loads(b_nested_json.read_text())
|
| 1414 |
+
except Exception:
|
| 1415 |
+
base_meta = None
|
| 1416 |
+
shard_url = base_meta.get("shard_url") if base_meta else ""
|
| 1417 |
+
if base_meta:
|
| 1418 |
+
_bb = base_meta.get("bytes")
|
| 1419 |
+
if _bb is None:
|
| 1420 |
+
_bb = base_meta.get("file_bytes")
|
| 1421 |
+
base_bytes = _bb if isinstance(_bb, int) else 0
|
| 1422 |
+
else:
|
| 1423 |
+
base_bytes = 0
|
| 1424 |
+
# voice.json for delta
|
| 1425 |
+
# read voice shape etc already
|
| 1426 |
+
meta = {
|
| 1427 |
+
"name": delta_name,
|
| 1428 |
+
"type": "delta",
|
| 1429 |
+
"source": voicename,
|
| 1430 |
+
"source_hf_model": source_hf,
|
| 1431 |
+
"base": base_id,
|
| 1432 |
+
"base_bytes": base_bytes,
|
| 1433 |
+
"shard_url": shard_url,
|
| 1434 |
+
"etag": base_meta.get("etag") if base_meta else None,
|
| 1435 |
+
"shape": list(v_shape),
|
| 1436 |
+
"dtype": "F16",
|
| 1437 |
+
"tensor_name": out_tensor_name,
|
| 1438 |
+
"bytes": total_elems*2,
|
| 1439 |
+
"downloaded_at": datetime.now(timezone.utc).isoformat(),
|
| 1440 |
+
"base_file": str(existing_path),
|
| 1441 |
+
}
|
| 1442 |
+
(delta_folder / "voice.json").write_text(json.dumps(meta, indent=2) + "\n")
|
| 1443 |
+
|
| 1444 |
+
file_size = os.path.getsize(out_path)
|
| 1445 |
+
_say(f" β¦ Done β {delta_folder}")
|
| 1446 |
+
_say(f" Delta {delta_name} ({file_size/1e6:.1f} MB) from {voicename} β {base_id}")
|
| 1447 |
+
_say(f" Base cached at: {existing_path}")
|
| 1448 |
+
_say(f" Use it: voice cast <your-model.gguf> {delta_name}")
|
| 1449 |
+
|
| 1450 |
+
|
| 1451 |
+
# ---------------------------------------------------------------------------
|
| 1452 |
+
# CMD: info β show voice metadata elegantly
|
| 1453 |
+
# ---------------------------------------------------------------------------
|
| 1454 |
+
|
| 1455 |
+
def cmd_info(args):
|
| 1456 |
+
voicename = args.voicename.strip()
|
| 1457 |
+
voice_path, voice_meta = _registry_resolve(voicename)
|
| 1458 |
+
if voice_path is None or not voice_path.exists():
|
| 1459 |
+
# also check base? base is not voice, but we support base info via base name?
|
| 1460 |
+
# try base dir lookup
|
| 1461 |
+
bps = _base_paths(voicename)
|
| 1462 |
+
if bps is not None:
|
| 1463 |
+
b_file, b_json, _, b_nested_file, b_nested_json = bps
|
| 1464 |
+
# check if base exists
|
| 1465 |
+
target_json = None
|
| 1466 |
+
target_file = None
|
| 1467 |
+
if b_file.exists() and b_json.exists():
|
| 1468 |
+
target_json = b_json
|
| 1469 |
+
target_file = b_file
|
| 1470 |
+
elif b_nested_file.exists() and b_nested_json.exists():
|
| 1471 |
+
target_json = b_nested_json
|
| 1472 |
+
target_file = b_nested_file
|
| 1473 |
+
if target_json is not None:
|
| 1474 |
+
try:
|
| 1475 |
+
meta = json.loads(target_json.read_text())
|
| 1476 |
+
_say(f" Name: {voicename}")
|
| 1477 |
+
_say(f" Source: https://huggingface.co/{meta.get('source_hf_model','β')} ({meta.get('source_hf_model','β')})")
|
| 1478 |
+
_say(f" Type: base")
|
| 1479 |
+
if VERBOSE:
|
| 1480 |
+
_say(f" Shape: {meta.get('shape','β')}")
|
| 1481 |
+
_say(f" Dtype: {meta.get('dtype','β')}")
|
| 1482 |
+
_say(f" Tensor: {meta.get('tensor_name','β')}")
|
| 1483 |
+
# P0-2: elegant bytes via 1409 logic
|
| 1484 |
+
_bv = meta.get("bytes")
|
| 1485 |
+
if _bv is None:
|
| 1486 |
+
_bv = meta.get("file_bytes")
|
| 1487 |
+
if isinstance(_bv, int):
|
| 1488 |
+
_say(f" Bytes: {_bv} ({_bv/1e6:.1f} MB)")
|
| 1489 |
+
elif _bv is not None:
|
| 1490 |
+
_say(f" Bytes: {_bv}")
|
| 1491 |
+
else:
|
| 1492 |
+
_say(f" Bytes: β")
|
| 1493 |
+
# P0-2: ETag display
|
| 1494 |
+
_etag = meta.get("etag")
|
| 1495 |
+
if _etag:
|
| 1496 |
+
_etag_disp = str(_etag)[:64] + "β¦" if len(str(_etag)) > 64 else str(_etag)
|
| 1497 |
+
_say(f" ETag: {_etag_disp}")
|
| 1498 |
+
else:
|
| 1499 |
+
_say(f" ETag: β")
|
| 1500 |
+
_say(f" Fetched: {meta.get('fetched_at','β')}")
|
| 1501 |
+
_say(f" License: {meta.get('license','β') if meta.get('license') else 'β'}")
|
| 1502 |
+
_say(f" File: {target_file}")
|
| 1503 |
+
_say(f" Repo: {meta.get('repo','https://huggingface.co/'+voicename)}")
|
| 1504 |
+
return
|
| 1505 |
+
except Exception as e:
|
| 1506 |
+
if VERBOSE:
|
| 1507 |
+
_say(f" β unreadable base metadata: {e}")
|
| 1508 |
+
pass
|
| 1509 |
+
_fail(f" β Voice '{voicename}' not found. Try voice list")
|
| 1510 |
+
|
| 1511 |
+
# load meta if missing
|
| 1512 |
+
if voice_meta is None:
|
| 1513 |
+
meta_path = Path(voice_path).parent / "voice.json"
|
| 1514 |
+
if meta_path.exists():
|
| 1515 |
+
try:
|
| 1516 |
+
voice_meta = json.loads(meta_path.read_text())
|
| 1517 |
+
except Exception:
|
| 1518 |
+
voice_meta = {}
|
| 1519 |
+
else:
|
| 1520 |
+
voice_meta = {}
|
| 1521 |
+
# Ensure we have metadata dict
|
| 1522 |
+
meta = voice_meta or {}
|
| 1523 |
+
# Try to load from voice.json file for complete data
|
| 1524 |
+
folder = Path(voice_path).parent
|
| 1525 |
+
json_path = folder / "voice.json"
|
| 1526 |
+
if json_path.exists():
|
| 1527 |
+
try:
|
| 1528 |
+
file_meta = json.loads(json_path.read_text())
|
| 1529 |
+
# merge
|
| 1530 |
+
for k,v in file_meta.items():
|
| 1531 |
+
if k not in meta:
|
| 1532 |
+
meta[k] = v
|
| 1533 |
+
else:
|
| 1534 |
+
# prefer file_meta
|
| 1535 |
+
meta[k] = v
|
| 1536 |
+
except Exception:
|
| 1537 |
+
pass
|
| 1538 |
+
|
| 1539 |
+
def _tr(s, w):
|
| 1540 |
+
s = str(s)
|
| 1541 |
+
return s[: w - 1] + "β¦" if len(s) > w else s
|
| 1542 |
+
|
| 1543 |
+
name = meta.get("name", voicename)
|
| 1544 |
+
source = meta.get("source_hf_model", meta.get("source", "β"))
|
| 1545 |
+
typ = meta.get("type", "voice")
|
| 1546 |
+
shape = meta.get("shape", "β")
|
| 1547 |
+
dtype = meta.get("dtype", "β")
|
| 1548 |
+
tensor = meta.get("tensor_name", meta.get("tensor", "β"))
|
| 1549 |
+
fetched = meta.get("downloaded_at", meta.get("fetched_at", "β"))
|
| 1550 |
+
license_text = meta.get("license", "β")
|
| 1551 |
+
base = meta.get("base", None)
|
| 1552 |
+
# P1-5 / P0-2: bytes missing fallback + elegant formatting
|
| 1553 |
+
bytes_val = meta.get("bytes")
|
| 1554 |
+
if bytes_val is None:
|
| 1555 |
+
bytes_val = meta.get("file_bytes")
|
| 1556 |
+
if isinstance(bytes_val, int):
|
| 1557 |
+
bytes_str = f"{bytes_val} ({bytes_val/1e6:.1f} MB)"
|
| 1558 |
+
elif bytes_val is not None:
|
| 1559 |
+
bytes_str = str(bytes_val)
|
| 1560 |
+
else:
|
| 1561 |
+
bytes_str = "β"
|
| 1562 |
+
|
| 1563 |
+
# Elegant multiline (HIG polished)
|
| 1564 |
+
_say(f" Name: {_tr(name, 30)}")
|
| 1565 |
+
_say(f" Source: https://huggingface.co/{source} ({_tr(source,40)})")
|
| 1566 |
+
if base:
|
| 1567 |
+
_say(f" Base: https://huggingface.co/{base} ({_tr(base,40)})")
|
| 1568 |
+
# try to show base local path if exists
|
| 1569 |
+
bps2 = _base_paths(base)
|
| 1570 |
+
if bps2 is not None:
|
| 1571 |
+
bf, bj, _, _, _ = bps2
|
| 1572 |
+
if bf.exists():
|
| 1573 |
+
_say(f" Base file: {bf}")
|
| 1574 |
+
_say(f" Type: {typ}")
|
| 1575 |
+
# Shape/Dtype/Tensor gated under VERBOSE (HIG P1-3 unified)
|
| 1576 |
+
if VERBOSE:
|
| 1577 |
+
_say(f" Shape: {shape}")
|
| 1578 |
+
_say(f" Dtype: {dtype}")
|
| 1579 |
+
_say(f" Tensor: {tensor}")
|
| 1580 |
+
_say(f" Bytes: {bytes_str}")
|
| 1581 |
+
# P0-2: ETag display (show β if missing, truncate for elegance)
|
| 1582 |
+
_etag = meta.get("etag")
|
| 1583 |
+
if _etag:
|
| 1584 |
+
_etag_str = str(_etag)
|
| 1585 |
+
_etag_disp = _etag_str[:64] + "β¦" if len(_etag_str) > 64 else _etag_str
|
| 1586 |
+
_say(f" ETag: {_etag_disp}")
|
| 1587 |
+
else:
|
| 1588 |
+
_say(f" ETag: β")
|
| 1589 |
+
_say(f" Fetched: {fetched}")
|
| 1590 |
+
if license_text and license_text != "β":
|
| 1591 |
+
# truncate long license
|
| 1592 |
+
lic = license_text[:200] if len(license_text) > 200 else license_text
|
| 1593 |
+
_say(f" License: {lic}")
|
| 1594 |
+
else:
|
| 1595 |
+
_say(f" License: β")
|
| 1596 |
+
# Also show file location
|
| 1597 |
+
_say(f" File: {voice_path}")
|
| 1598 |
+
|
| 1599 |
+
|
| 1600 |
+
|
| 1601 |
+
# ---------------------------------------------------------------------------
|
| 1602 |
+
# CMD: list
|
| 1603 |
+
# ---------------------------------------------------------------------------
|
| 1604 |
+
|
| 1605 |
+
def cmd_list(_args):
|
| 1606 |
+
# voice list base β list cached bases
|
| 1607 |
+
if getattr(_args, "target", None) == "base":
|
| 1608 |
+
base_root = VOICES_DIR / "base"
|
| 1609 |
+
if not base_root.exists() or not any(base_root.rglob("*.json")):
|
| 1610 |
+
_say(" No bases cached yet. Use 'voice delta <voice>' to fetch one.")
|
| 1611 |
+
return
|
| 1612 |
+
|
| 1613 |
+
def _tr(s, w):
|
| 1614 |
+
s = str(s)
|
| 1615 |
+
return s[: w - 1] + "β¦" if len(s) > w else s
|
| 1616 |
+
|
| 1617 |
+
_say(f" {_tr('Base', 40):<40} {_tr('Bytes', 18):<18} {_tr('Fetched', 12):<12}")
|
| 1618 |
+
_say(f" {'β'*40} {'β'*18} {'β'*12}")
|
| 1619 |
+
seen = set()
|
| 1620 |
+
for jpath in sorted(base_root.rglob("*.json")):
|
| 1621 |
+
try:
|
| 1622 |
+
meta = json.loads(jpath.read_text())
|
| 1623 |
+
except (json.JSONDecodeError, ValueError):
|
| 1624 |
+
continue
|
| 1625 |
+
# derive base id - support both layouts but dedupe to org/model
|
| 1626 |
+
rel = jpath.relative_to(base_root)
|
| 1627 |
+
parts = rel.parts
|
| 1628 |
+
if len(parts) == 2:
|
| 1629 |
+
base_id = f"{parts[0]}/{Path(parts[1]).stem}"
|
| 1630 |
+
elif len(parts) == 3 and parts[2] == "base.json":
|
| 1631 |
+
base_id = f"{parts[0]}/{parts[1]}"
|
| 1632 |
+
else:
|
| 1633 |
+
base_id = str(rel.with_suffix("")).replace(os.sep, "/")
|
| 1634 |
+
if base_id.endswith("/base"):
|
| 1635 |
+
base_id = base_id[:-5]
|
| 1636 |
+
if base_id in seen:
|
| 1637 |
+
continue
|
| 1638 |
+
seen.add(base_id)
|
| 1639 |
+
b = meta.get("bytes")
|
| 1640 |
+
if b is None:
|
| 1641 |
+
b = meta.get("file_bytes")
|
| 1642 |
+
if isinstance(b, int):
|
| 1643 |
+
b_str = f"{b} ({b/1e6:.1f} MB)"
|
| 1644 |
+
else:
|
| 1645 |
+
b_str = "β"
|
| 1646 |
+
fetched = meta.get("fetched_at", "β")[:10] if isinstance(meta.get("fetched_at"), str) else "β"
|
| 1647 |
+
_say(f" {_tr(base_id, 40):<40} {_tr(b_str, 18):<18} {_tr(fetched, 12):<12}")
|
| 1648 |
+
if VERBOSE:
|
| 1649 |
+
_say(f" {jpath}")
|
| 1650 |
+
return
|
| 1651 |
+
|
| 1652 |
+
if not VOICES_DIR.exists() or not any(VOICES_DIR.iterdir()):
|
| 1653 |
+
_say(" No voices stored yet. Use 'voice get <model>' to save one.")
|
| 1654 |
+
return
|
| 1655 |
+
|
| 1656 |
+
def _tr(s, w):
|
| 1657 |
+
s = str(s)
|
| 1658 |
+
return s[: w - 1] + "β¦" if len(s) > w else s
|
| 1659 |
+
|
| 1660 |
+
_say(f" {_tr('Name', 30):<30} {_tr('Source', 40):<40} {_tr('Type', 8):<8}")
|
| 1661 |
+
_say(f" {'β'*30} {'β'*40} {'β'*8}")
|
| 1662 |
+
for d in sorted(VOICES_DIR.iterdir()):
|
| 1663 |
+
if not d.is_dir():
|
| 1664 |
+
continue
|
| 1665 |
+
if d.name == "base":
|
| 1666 |
+
continue
|
| 1667 |
+
meta_path = d / "voice.json"
|
| 1668 |
+
if not meta_path.exists():
|
| 1669 |
+
_say(f" {_tr(d.name, 30):<30} {_tr('(no metadata)', 40):<40} {_tr('β ', 8):<8}")
|
| 1670 |
+
if VERBOSE:
|
| 1671 |
+
_say(f" β missing metadata: {meta_path}")
|
| 1672 |
+
continue
|
| 1673 |
+
try:
|
| 1674 |
+
meta = json.loads(meta_path.read_text())
|
| 1675 |
+
except (json.JSONDecodeError, ValueError) as e:
|
| 1676 |
+
_say(f" {_tr(d.name, 30):<30} {_tr('β unreadable metadata', 40):<40} {_tr('β ', 8):<8}")
|
| 1677 |
+
if VERBOSE:
|
| 1678 |
+
_say(f" β unreadable metadata: {meta_path} ({e})")
|
| 1679 |
+
continue
|
| 1680 |
+
name = meta.get("name", d.name)
|
| 1681 |
+
src = meta.get("source_hf_model", meta.get("source", "β"))
|
| 1682 |
+
typ = meta.get("type", "voice")
|
| 1683 |
+
_say(f" {_tr(name, 30):<30} {_tr(src, 40):<40} {_tr(typ, 8):<8}")
|
| 1684 |
+
|
| 1685 |
+
|
| 1686 |
+
# ---------------------------------------------------------------------------
|
| 1687 |
+
# CMD: remove
|
| 1688 |
+
# ---------------------------------------------------------------------------
|
| 1689 |
+
|
| 1690 |
+
def cmd_remove(args):
|
| 1691 |
+
name = args.voice.strip().lower()
|
| 1692 |
+
if not VOICES_DIR.exists():
|
| 1693 |
+
_fail(f" β Voice '{args.voice}' not found.")
|
| 1694 |
+
target = None
|
| 1695 |
+
for d in VOICES_DIR.iterdir():
|
| 1696 |
+
if d.is_dir() and d.name.lower() == name:
|
| 1697 |
+
target = d
|
| 1698 |
+
break
|
| 1699 |
+
if target is None:
|
| 1700 |
+
_fail(f" β Voice '{args.voice}' not found.")
|
| 1701 |
+
try:
|
| 1702 |
+
ans = input(f" Remove voice '{target.name}'? [y/N]: ").strip().lower()
|
| 1703 |
+
except (EOFError, KeyboardInterrupt):
|
| 1704 |
+
_say(" β Cancelled.")
|
| 1705 |
+
sys.exit(0)
|
| 1706 |
+
if ans not in ("y", "yes"):
|
| 1707 |
+
_say(" Cancelled.")
|
| 1708 |
+
sys.exit(0)
|
| 1709 |
+
import shutil
|
| 1710 |
+
shutil.rmtree(target)
|
| 1711 |
+
_say(f" β¦ Removed '{target.name}'.")
|
| 1712 |
+
|
| 1713 |
+
|
| 1714 |
+
# ---------------------------------------------------------------------------
|
| 1715 |
+
# CMD: path β put `voice` on your PATH
|
| 1716 |
+
# ---------------------------------------------------------------------------
|
| 1717 |
+
|
| 1718 |
+
def _is_command(name):
|
| 1719 |
+
import shutil
|
| 1720 |
+
return shutil.which(name) is not None
|
| 1721 |
+
|
| 1722 |
+
|
| 1723 |
+
def cmd_path(_args):
|
| 1724 |
+
import shutil
|
| 1725 |
+
from pathlib import Path as _Path
|
| 1726 |
+
|
| 1727 |
+
script = _Path(__file__).resolve().parent / "voice.py"
|
| 1728 |
+
# fallback to "voice" wrapper if voice.py missing (shim compatibility)
|
| 1729 |
+
if not script.exists():
|
| 1730 |
+
alt = _Path(__file__).resolve().parent / "voice"
|
| 1731 |
+
if alt.exists():
|
| 1732 |
+
script = alt
|
| 1733 |
+
else:
|
| 1734 |
+
_fail(" β Can't find the voice script next to voice.py. Check your installation.")
|
| 1735 |
+
|
| 1736 |
+
if sys.platform.startswith("win"):
|
| 1737 |
+
if _is_command("voice"):
|
| 1738 |
+
_say(" β¦ Voice is already on your PATH β you're good to go.")
|
| 1739 |
+
return
|
| 1740 |
+
target_dir = _Path(os.environ.get("LOCALAPPDATA", ""))
|
| 1741 |
+
if target_dir == _Path(""):
|
| 1742 |
+
_fail(" β Couldn't find a Windows app-data folder to install into.")
|
| 1743 |
+
bindir = target_dir / "voice-cli"
|
| 1744 |
+
bindir.mkdir(parents=True, exist_ok=True)
|
| 1745 |
+
script_new = bindir / "voice.py"
|
| 1746 |
+
shutil.copy2(str(script), str(script_new))
|
| 1747 |
+
_say(f" β¦ Voice is ready on your PATH.")
|
| 1748 |
+
_say(f" Add this to your PATH (in PowerShell):")
|
| 1749 |
+
_say(f" setx PATH \"%PATH%;{bindir}\"")
|
| 1750 |
+
_say(f" Then open a new terminal and run: voice help")
|
| 1751 |
+
return
|
| 1752 |
+
|
| 1753 |
+
# Unix (Linux / macOS)
|
| 1754 |
+
candidates = [
|
| 1755 |
+
_Path("/usr/local/bin"),
|
| 1756 |
+
_Path.home() / ".local" / "bin",
|
| 1757 |
+
_Path("/usr/bin"),
|
| 1758 |
+
_Path("/bin"),
|
| 1759 |
+
]
|
| 1760 |
+
on_path = next(
|
| 1761 |
+
(d for d in candidates if d.is_dir() and str(d) in os.environ.get("PATH", "").split(":")),
|
| 1762 |
+
None,
|
| 1763 |
+
)
|
| 1764 |
+
if _is_command("voice"):
|
| 1765 |
+
_say(" β¦ Voice is already on your PATH β you're good to go.")
|
| 1766 |
+
return
|
| 1767 |
+
if on_path is None:
|
| 1768 |
+
_fail(" β No writable directory on PATH found. Add /Lora to your PATH manually.")
|
| 1769 |
+
target = on_path / "voice"
|
| 1770 |
+
try:
|
| 1771 |
+
target.symlink_to(str(script))
|
| 1772 |
+
except FileExistsError:
|
| 1773 |
+
target.unlink()
|
| 1774 |
+
target.symlink_to(str(script))
|
| 1775 |
+
except (PermissionError, OSError):
|
| 1776 |
+
if VERBOSE:
|
| 1777 |
+
import traceback as _tb
|
| 1778 |
+
_say(f" Details: {_tb.format_exc().strip()}")
|
| 1779 |
+
_fail(
|
| 1780 |
+
f" β Couldn't create a link in {on_path}. "
|
| 1781 |
+
f"Try: sudo ln -s {script} {target}"
|
| 1782 |
+
)
|
| 1783 |
+
_say(f" β¦ Voice is ready at {target}. Open a new terminal and run: voice")
|
| 1784 |
+
|
| 1785 |
+
|
| 1786 |
+
# ---------------------------------------------------------------------------
|
| 1787 |
+
# Main CLI
|
| 1788 |
+
# ---------------------------------------------------------------------------
|
| 1789 |
+
|
| 1790 |
+
def main():
|
| 1791 |
+
global VERBOSE
|
| 1792 |
+
|
| 1793 |
+
parser = argparse.ArgumentParser(
|
| 1794 |
+
prog="voice",
|
| 1795 |
+
description="Get a voice, then cast it onto your model.",
|
| 1796 |
+
)
|
| 1797 |
+
parser.add_argument(
|
| 1798 |
+
"-v", "--verbose", action="store_true", help="Show detailed progress"
|
| 1799 |
+
)
|
| 1800 |
+
sub = parser.add_subparsers(dest="command")
|
| 1801 |
+
|
| 1802 |
+
p_get = sub.add_parser("get", help="Get a voice from a HuggingFace model")
|
| 1803 |
+
p_get.add_argument(
|
| 1804 |
+
"model_id", help="HuggingFace model ID (e.g. google/gemma-3-1b)"
|
| 1805 |
+
)
|
| 1806 |
+
p_get.add_argument(
|
| 1807 |
+
"-q", "--quiet", action="store_true", help="Skip license detection"
|
| 1808 |
+
)
|
| 1809 |
+
|
| 1810 |
+
p_cast = sub.add_parser(
|
| 1811 |
+
"cast", help="Cast a voice onto your model"
|
| 1812 |
+
)
|
| 1813 |
+
p_cast.add_argument("target_gguf", help="Path to the target GGUF model")
|
| 1814 |
+
p_cast.add_argument(
|
| 1815 |
+
"voice", help="Voice name (from registry) or path to .safetensors"
|
| 1816 |
+
)
|
| 1817 |
+
p_cast.add_argument(
|
| 1818 |
+
"--out",
|
| 1819 |
+
help="Output directory or file (default: ./<model>/<voice>-<model>.gguf)",
|
| 1820 |
+
)
|
| 1821 |
+
|
| 1822 |
+
sub.add_parser("path", help="Add 'voice' to your system PATH")
|
| 1823 |
+
|
| 1824 |
+
p_rm = sub.add_parser("remove", help="Remove a stored voice")
|
| 1825 |
+
p_rm.add_argument("voice", help="Voice name to remove")
|
| 1826 |
+
|
| 1827 |
+
p_list = sub.add_parser("list", help="List stored voices")
|
| 1828 |
+
p_list.add_argument("target", nargs="?", choices=["base"], help="List bases instead of voices (voice list base)")
|
| 1829 |
+
|
| 1830 |
+
p_delta = sub.add_parser("delta", help="Make a style you can re-use (voice β base)")
|
| 1831 |
+
p_delta.add_argument("voicename", nargs="?", help="Voice name from registry (from voice list)")
|
| 1832 |
+
|
| 1833 |
+
p_info = sub.add_parser("info", help="Show voice metadata")
|
| 1834 |
+
p_info.add_argument("voicename", help="Voice name to show info for")
|
| 1835 |
+
|
| 1836 |
+
args = parser.parse_args()
|
| 1837 |
+
VERBOSE = args.verbose
|
| 1838 |
+
|
| 1839 |
+
if args.command is None:
|
| 1840 |
+
print("Get a voice, then cast it onto your model.\n")
|
| 1841 |
+
parser.print_help()
|
| 1842 |
+
sys.exit(0)
|
| 1843 |
+
|
| 1844 |
+
if args.command == "get":
|
| 1845 |
+
cmd_get(args)
|
| 1846 |
+
elif args.command == "cast":
|
| 1847 |
+
cmd_cast(args)
|
| 1848 |
+
elif args.command == "list":
|
| 1849 |
+
cmd_list(args)
|
| 1850 |
+
elif args.command == "path":
|
| 1851 |
+
cmd_path(args)
|
| 1852 |
+
elif args.command == "remove":
|
| 1853 |
+
cmd_remove(args)
|
| 1854 |
+
elif args.command == "delta":
|
| 1855 |
+
cmd_delta(args)
|
| 1856 |
+
elif args.command == "info":
|
| 1857 |
+
cmd_info(args)
|
| 1858 |
+
|
| 1859 |
+
|
| 1860 |
+
if __name__ == "__main__":
|
| 1861 |
+
main()
|