Wiself commited on
Commit
e1fa6c7
Β·
1 Parent(s): 367206e

Upload 6 files

Browse files
Files changed (6) hide show
  1. DOCS.md +110 -0
  2. README.md +384 -0
  3. benchmark.md +91 -0
  4. lockup-circle-wordmark-2-refined.png +0 -0
  5. voice +3 -0
  6. 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()