@jskit-ai/assistant-voice-sherpa 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,8 +73,42 @@ unfinished native synthesis; the next request recreates that worker if needed.
73
73
  `--pack kokoro` uses Kokoro 1.0 with named female
74
74
  Heart, Bella and Emma voices, and male Michael, Adam, George and Daniel voices.
75
75
  The service advertises these IDs/labels; the browser selects a speaker per reply.
76
+ Built-in voice labels include their model family: Piper, Kitten, Kokoro or Kokoro
77
+ q8f16. Voice IDs and speaker choices remain stable when labels change.
76
78
  Model weights are not included in the npm artifact.
77
79
 
80
+ `--pack kokoro-q8f16` offers all 28 American/British English Kokoro voices with
81
+ `model_q8f16.onnx` from the pinned Kokoro 1.0 ONNX Community revision. The 86 MB
82
+ mixed int8/fp16 file is SHA-256 checked before staging. Preparation appends only
83
+ Sherpa's required ONNX metadata; every original graph/weight byte remains intact.
84
+ The installed file keeps its `model_q8f16.onnx` name and its prepared hash is
85
+ recorded in the existing integrity manifest. The pack retains Sherpa's pinned
86
+ voice table, tokenizer, lexicons, eSpeak data and licenses. It does not load the
87
+ full-size Kokoro model. All these English voices share one synthesis worker at
88
+ 24,000 Hz; voice selection uses the same settings catalogue and per-reply ID.
89
+
90
+ The preset ships a CPU provider configuration disabling only ONNX Runtime's
91
+ `NchwcTransformer`, which crashes while loading this mixed-precision graph on
92
+ x64 with the pinned native runtime. Other graph optimizations remain enabled.
93
+ Do not remove this configuration when supplying a custom `serve --config` file.
94
+ Measure latency on your server: smaller weights do not guarantee faster CPU
95
+ inference or real-time speech. Capture, duplex admission, playback and cancellation
96
+ continue through the existing controller and synthesis process.
97
+
98
+ `--pack piper-kitten-kokoro-q8f16` keeps all five Piper voices and eight Kitten
99
+ voices, then adds those 28 Kokoro voices to one catalogue (41 choices). It retains
100
+ Piper's Cori default, existing voice IDs and Kitten Bella's 1.15× speed. Kokoro's
101
+ files and CPU configuration live beneath `tts/kokoro-q8f16`; Piper and Kitten
102
+ files are never overwritten. The existing single synthesis process switches
103
+ models between replies and releases the previous model before loading another.
104
+ The last-used synthesis worker stays loaded while idle. Replies selecting that
105
+ same model reuse it, including another speaker in the same Kitten/Kokoro model.
106
+ Cancellation terminates the worker immediately; the next reply loads a new one.
107
+ This saves model startup time but does not reduce the inference required to
108
+ generate speech. Only one model remains resident.
109
+ Output keeps the existing combined pack's 22,050 Hz rate through the same
110
+ resampler. The standalone presets remain available.
111
+
78
112
  `--pack kitten` offers **Bella, Luna, Rosie and Kiki** (female) and
79
113
  **Jasper, Bruno, Hugo and Leo** (male) from Kitten TTS Micro 0.8. All eight share
80
114
  one model and retain its native 24,000 Hz output. Bella uses a 1.15× speech speed;
@@ -137,6 +171,13 @@ voice does not redirect an in-flight reply or another conversation.
137
171
  For other models, `prepare --sources-file FILE` accepts the source manifest shape
138
172
  shown in `models/voice-models-piper.json`: recognition and synthesis archives, pinned
139
173
  SHA-256 values, explicit source/destination file mappings and native configuration.
174
+ A source entry's optional `format: "file"` stages its pinned download without
175
+ archive extraction; the default is `tar.bz2`. `archive`/`archiveSha256` still name
176
+ and authenticate that download. The optional `onnxMetadata` object maps staged
177
+ `.onnx` paths to string metadata entries, appended after the unchanged source
178
+ bytes. `textFiles` maps staged paths to literal native configuration text;
179
+ it cannot overwrite a mapped source file. These outputs are covered by the
180
+ same prepared-file integrity manifest and atomic replacement as archived models.
140
181
  Paths in mappings must stay inside their extracted/staged roots. Model compatibility
141
182
  still needs native inference validation; a matching hash is only integrity proof.
142
183
 
@@ -0,0 +1,254 @@
1
+ {
2
+ "schema": "vibe64.voice-model-sources.v1",
3
+ "packRevision": 1,
4
+ "models": [
5
+ {
6
+ "id": "streaming-zipformer-en-2023-06-26",
7
+ "kind": "stt",
8
+ "archive": "sherpa-onnx-streaming-zipformer-en-2023-06-26.tar.bz2",
9
+ "archiveSha256": "639e25b578e9e997131402199419c13a941f8e4e198e2da1ce57dbf5cf401282",
10
+ "url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-en-2023-06-26.tar.bz2",
11
+ "license": "Apache-2.0",
12
+ "source": "https://huggingface.co/csukuangfj/sherpa-onnx-streaming-zipformer-en-2023-06-26",
13
+ "files": {
14
+ "sherpa-onnx-streaming-zipformer-en-2023-06-26/encoder-epoch-99-avg-1-chunk-16-left-128.int8.onnx": "stt/encoder.int8.onnx",
15
+ "sherpa-onnx-streaming-zipformer-en-2023-06-26/decoder-epoch-99-avg-1-chunk-16-left-128.int8.onnx": "stt/decoder.int8.onnx",
16
+ "sherpa-onnx-streaming-zipformer-en-2023-06-26/joiner-epoch-99-avg-1-chunk-16-left-128.int8.onnx": "stt/joiner.int8.onnx",
17
+ "sherpa-onnx-streaming-zipformer-en-2023-06-26/tokens.txt": "stt/tokens.txt",
18
+ "sherpa-onnx-streaming-zipformer-en-2023-06-26/README.md": "stt/SOURCE.md"
19
+ }
20
+ },
21
+ {
22
+ "id": "kokoro-multi-lang-v1_0",
23
+ "kind": "tts",
24
+ "archive": "kokoro-multi-lang-v1_0.tar.bz2",
25
+ "archiveSha256": "c5f7e2d2caf082bc1d20fb70334a61d99d20b484500aad32e7cf84c128ea3298",
26
+ "url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-multi-lang-v1_0.tar.bz2",
27
+ "license": "Apache-2.0",
28
+ "source": "https://huggingface.co/hexgrad/Kokoro-82M",
29
+ "files": {
30
+ "kokoro-multi-lang-v1_0/tokens.txt": "tts/kokoro-q8f16/tokens.txt",
31
+ "kokoro-multi-lang-v1_0/voices.bin": "tts/kokoro-q8f16/voices.bin",
32
+ "kokoro-multi-lang-v1_0/espeak-ng-data": "tts/kokoro-q8f16/espeak-ng-data",
33
+ "kokoro-multi-lang-v1_0/lexicon-us-en.txt": "tts/kokoro-q8f16/lexicon-us-en.txt",
34
+ "kokoro-multi-lang-v1_0/lexicon-gb-en.txt": "tts/kokoro-q8f16/lexicon-gb-en.txt",
35
+ "kokoro-multi-lang-v1_0/LICENSE": "tts/kokoro-q8f16/LICENSE",
36
+ "kokoro-multi-lang-v1_0/README.md": "tts/kokoro-q8f16/README.md"
37
+ }
38
+ },
39
+ {
40
+ "id": "kokoro-v1_0-q8f16",
41
+ "kind": "tts",
42
+ "format": "file",
43
+ "archive": "model_q8f16.onnx",
44
+ "archiveSha256": "04c658aec1b6008857c2ad10f8c589d4180d0ec427e7e6118ceb487e215c3cd0",
45
+ "url": "https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX/resolve/1939ad2a8e416c0acfeecc08a694d14ef25f2231/onnx/model_q8f16.onnx",
46
+ "license": "Apache-2.0",
47
+ "source": "https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX",
48
+ "files": {
49
+ "model_q8f16.onnx": "tts/kokoro-q8f16/model_q8f16.onnx"
50
+ }
51
+ }
52
+ ],
53
+ "configuration": {
54
+ "synthesizer": {
55
+ "maxNumSentences": 1,
56
+ "model": {
57
+ "numThreads": 1,
58
+ "provider": "cpu:${MODELS_ROOT}/tts/kokoro-q8f16/kokoro-cpu.conf",
59
+ "kokoro": {
60
+ "model": "${MODELS_ROOT}/tts/kokoro-q8f16/model_q8f16.onnx",
61
+ "tokens": "${MODELS_ROOT}/tts/kokoro-q8f16/tokens.txt",
62
+ "voices": "${MODELS_ROOT}/tts/kokoro-q8f16/voices.bin",
63
+ "dataDir": "${MODELS_ROOT}/tts/kokoro-q8f16/espeak-ng-data",
64
+ "lexicon": "${MODELS_ROOT}/tts/kokoro-q8f16/lexicon-us-en.txt"
65
+ }
66
+ }
67
+ },
68
+ "defaultVoice": "af_heart",
69
+ "voices": [
70
+ {
71
+ "id": "af_alloy",
72
+ "label": "Alloy · female · American English · Kokoro q8f16",
73
+ "language": "en-US",
74
+ "speakerId": 0
75
+ },
76
+ {
77
+ "id": "af_aoede",
78
+ "label": "Aoede · female · American English · Kokoro q8f16",
79
+ "language": "en-US",
80
+ "speakerId": 1
81
+ },
82
+ {
83
+ "id": "af_bella",
84
+ "label": "Bella · female · American English · Kokoro q8f16",
85
+ "language": "en-US",
86
+ "speakerId": 2
87
+ },
88
+ {
89
+ "id": "af_heart",
90
+ "label": "Heart · female · American English · Kokoro q8f16",
91
+ "language": "en-US",
92
+ "speakerId": 3
93
+ },
94
+ {
95
+ "id": "af_jessica",
96
+ "label": "Jessica · female · American English · Kokoro q8f16",
97
+ "language": "en-US",
98
+ "speakerId": 4
99
+ },
100
+ {
101
+ "id": "af_kore",
102
+ "label": "Kore · female · American English · Kokoro q8f16",
103
+ "language": "en-US",
104
+ "speakerId": 5
105
+ },
106
+ {
107
+ "id": "af_nicole",
108
+ "label": "Nicole · female · American English · Kokoro q8f16",
109
+ "language": "en-US",
110
+ "speakerId": 6
111
+ },
112
+ {
113
+ "id": "af_nova",
114
+ "label": "Nova · female · American English · Kokoro q8f16",
115
+ "language": "en-US",
116
+ "speakerId": 7
117
+ },
118
+ {
119
+ "id": "af_river",
120
+ "label": "River · female · American English · Kokoro q8f16",
121
+ "language": "en-US",
122
+ "speakerId": 8
123
+ },
124
+ {
125
+ "id": "af_sarah",
126
+ "label": "Sarah · female · American English · Kokoro q8f16",
127
+ "language": "en-US",
128
+ "speakerId": 9
129
+ },
130
+ {
131
+ "id": "af_sky",
132
+ "label": "Sky · female · American English · Kokoro q8f16",
133
+ "language": "en-US",
134
+ "speakerId": 10
135
+ },
136
+ {
137
+ "id": "am_adam",
138
+ "label": "Adam · male · American English · Kokoro q8f16",
139
+ "language": "en-US",
140
+ "speakerId": 11
141
+ },
142
+ {
143
+ "id": "am_echo",
144
+ "label": "Echo · male · American English · Kokoro q8f16",
145
+ "language": "en-US",
146
+ "speakerId": 12
147
+ },
148
+ {
149
+ "id": "am_eric",
150
+ "label": "Eric · male · American English · Kokoro q8f16",
151
+ "language": "en-US",
152
+ "speakerId": 13
153
+ },
154
+ {
155
+ "id": "am_fenrir",
156
+ "label": "Fenrir · male · American English · Kokoro q8f16",
157
+ "language": "en-US",
158
+ "speakerId": 14
159
+ },
160
+ {
161
+ "id": "am_liam",
162
+ "label": "Liam · male · American English · Kokoro q8f16",
163
+ "language": "en-US",
164
+ "speakerId": 15
165
+ },
166
+ {
167
+ "id": "am_michael",
168
+ "label": "Michael · male · American English · Kokoro q8f16",
169
+ "language": "en-US",
170
+ "speakerId": 16
171
+ },
172
+ {
173
+ "id": "am_onyx",
174
+ "label": "Onyx · male · American English · Kokoro q8f16",
175
+ "language": "en-US",
176
+ "speakerId": 17
177
+ },
178
+ {
179
+ "id": "am_puck",
180
+ "label": "Puck · male · American English · Kokoro q8f16",
181
+ "language": "en-US",
182
+ "speakerId": 18
183
+ },
184
+ {
185
+ "id": "am_santa",
186
+ "label": "Santa · male · American English · Kokoro q8f16",
187
+ "language": "en-US",
188
+ "speakerId": 19
189
+ },
190
+ {
191
+ "id": "bf_alice",
192
+ "label": "Alice · female · British English · Kokoro q8f16",
193
+ "language": "en-GB",
194
+ "speakerId": 20
195
+ },
196
+ {
197
+ "id": "bf_emma",
198
+ "label": "Emma · female · British English · Kokoro q8f16",
199
+ "language": "en-GB",
200
+ "speakerId": 21
201
+ },
202
+ {
203
+ "id": "bf_isabella",
204
+ "label": "Isabella · female · British English · Kokoro q8f16",
205
+ "language": "en-GB",
206
+ "speakerId": 22
207
+ },
208
+ {
209
+ "id": "bf_lily",
210
+ "label": "Lily · female · British English · Kokoro q8f16",
211
+ "language": "en-GB",
212
+ "speakerId": 23
213
+ },
214
+ {
215
+ "id": "bm_daniel",
216
+ "label": "Daniel · male · British English · Kokoro q8f16",
217
+ "language": "en-GB",
218
+ "speakerId": 24
219
+ },
220
+ {
221
+ "id": "bm_fable",
222
+ "label": "Fable · male · British English · Kokoro q8f16",
223
+ "language": "en-GB",
224
+ "speakerId": 25
225
+ },
226
+ {
227
+ "id": "bm_george",
228
+ "label": "George · male · British English · Kokoro q8f16",
229
+ "language": "en-GB",
230
+ "speakerId": 26
231
+ },
232
+ {
233
+ "id": "bm_lewis",
234
+ "label": "Lewis · male · British English · Kokoro q8f16",
235
+ "language": "en-GB",
236
+ "speakerId": 27
237
+ }
238
+ ]
239
+ },
240
+ "onnxMetadata": {
241
+ "tts/kokoro-q8f16/model_q8f16.onnx": {
242
+ "model_type": "kokoro",
243
+ "sample_rate": "24000",
244
+ "version": "2",
245
+ "n_speakers": "54",
246
+ "has_espeak": "1",
247
+ "voice": "en-us",
248
+ "style_dim": "510,1,256"
249
+ }
250
+ },
251
+ "textFiles": {
252
+ "tts/kokoro-q8f16/kokoro-cpu.conf": "SessionConfig.optimization.disable_specified_optimizers=NchwcTransformer\n"
253
+ }
254
+ }
@@ -57,43 +57,43 @@
57
57
  "voices": [
58
58
  {
59
59
  "id": "af_heart",
60
- "label": "Heart \u00b7 female \u00b7 American English",
60
+ "label": "Heart \u00b7 female \u00b7 American English \u00b7 Kokoro",
61
61
  "language": "en-US",
62
62
  "speakerId": 3
63
63
  },
64
64
  {
65
65
  "id": "af_bella",
66
- "label": "Bella \u00b7 female \u00b7 American English",
66
+ "label": "Bella \u00b7 female \u00b7 American English \u00b7 Kokoro",
67
67
  "language": "en-US",
68
68
  "speakerId": 2
69
69
  },
70
70
  {
71
71
  "id": "am_michael",
72
- "label": "Michael \u00b7 male \u00b7 American English",
72
+ "label": "Michael \u00b7 male \u00b7 American English \u00b7 Kokoro",
73
73
  "language": "en-US",
74
74
  "speakerId": 16
75
75
  },
76
76
  {
77
77
  "id": "am_adam",
78
- "label": "Adam \u00b7 male \u00b7 American English",
78
+ "label": "Adam \u00b7 male \u00b7 American English \u00b7 Kokoro",
79
79
  "language": "en-US",
80
80
  "speakerId": 11
81
81
  },
82
82
  {
83
83
  "id": "bf_emma",
84
- "label": "Emma \u00b7 female \u00b7 British English",
84
+ "label": "Emma \u00b7 female \u00b7 British English \u00b7 Kokoro",
85
85
  "language": "en-GB",
86
86
  "speakerId": 21
87
87
  },
88
88
  {
89
89
  "id": "bm_george",
90
- "label": "George \u00b7 male \u00b7 British English",
90
+ "label": "George \u00b7 male \u00b7 British English \u00b7 Kokoro",
91
91
  "language": "en-GB",
92
92
  "speakerId": 26
93
93
  },
94
94
  {
95
95
  "id": "bm_daniel",
96
- "label": "Daniel \u00b7 male \u00b7 British English",
96
+ "label": "Daniel \u00b7 male \u00b7 British English \u00b7 Kokoro",
97
97
  "language": "en-GB",
98
98
  "speakerId": 24
99
99
  }
@@ -99,35 +99,35 @@
99
99
  "voices": [
100
100
  {
101
101
  "id": "cori",
102
- "label": "Cori · female · British English",
102
+ "label": "Cori · female · British English · Piper",
103
103
  "speakerId": 0,
104
104
  "language": "en-GB",
105
105
  "modelId": "cori"
106
106
  },
107
107
  {
108
108
  "id": "alba",
109
- "label": "Alba · female · Scottish English",
109
+ "label": "Alba · female · Scottish English · Piper",
110
110
  "speakerId": 0,
111
111
  "language": "en-GB",
112
112
  "modelId": "alba"
113
113
  },
114
114
  {
115
115
  "id": "joe",
116
- "label": "Joe · male · American English",
116
+ "label": "Joe · male · American English · Piper",
117
117
  "speakerId": 0,
118
118
  "language": "en-US",
119
119
  "modelId": "joe"
120
120
  },
121
121
  {
122
122
  "id": "bryce",
123
- "label": "Bryce · male · American English",
123
+ "label": "Bryce · male · American English · Piper",
124
124
  "speakerId": 0,
125
125
  "language": "en-US",
126
126
  "modelId": "bryce"
127
127
  },
128
128
  {
129
129
  "id": "northern_english_male",
130
- "label": "Northern English · male · British English",
130
+ "label": "Northern English · male · British English · Piper",
131
131
  "speakerId": 0,
132
132
  "language": "en-GB",
133
133
  "modelId": "northern_english_male"
@@ -39,7 +39,7 @@
39
39
  "voices": [
40
40
  {
41
41
  "id": "cori",
42
- "label": "Cori \u00b7 female \u00b7 British English",
42
+ "label": "Cori \u00b7 female \u00b7 British English \u00b7 Piper",
43
43
  "speakerId": 0,
44
44
  "language": "en-GB"
45
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-voice-sherpa",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "description": "Optional native Sherpa speech engine and explicit model preparation for assistant voice.",
6
6
  "scripts": {
@@ -19,7 +19,7 @@
19
19
  "tooling"
20
20
  ],
21
21
  "dependencies": {
22
- "@jskit-ai/assistant-voice": "0.1.7",
22
+ "@jskit-ai/assistant-voice": "0.1.9",
23
23
  "sherpa-onnx-node": "1.13.6"
24
24
  },
25
25
  "jskit": {
package/src/cli.js CHANGED
@@ -50,7 +50,7 @@ function usage() {
50
50
  "",
51
51
  "Commands:",
52
52
  " serve --models-root PATH --key-file PATH [--host ADDRESS] [--port NUMBER] [--config FILE]",
53
- " prepare --models-root PATH [--pack cori|piper|kitten|piper-kitten|kokoro] [--sources-file FILE] [--download-cache PATH] [--hotwords-file PATH]",
53
+ " prepare --models-root PATH [--pack cori|piper|kitten|piper-kitten|kokoro|kokoro-q8f16|piper-kitten-kokoro-q8f16] [--sources-file FILE] [--download-cache PATH] [--hotwords-file PATH]",
54
54
  " verify --models-root PATH",
55
55
  " token --key-file PATH --tenant NAME"
56
56
  ].join("\n");
@@ -77,17 +77,25 @@ async function runVoiceCli(argv = process.argv.slice(2), { createDaemon = create
77
77
  "retain-downloads": "RETAIN_DOWNLOADS", "hotwords-file": "HOTWORDS_FILE"
78
78
  })) if (options[option]) env[`JSKIT_VOICE_${variable}`] = options[option];
79
79
  const pack = options.pack || process.env.JSKIT_VOICE_PACK || "cori";
80
- const manifests = { cori: "voice-models.json", piper: "voice-models-piper.json", kitten: "voice-models-kitten.json", kokoro: "voice-models-kokoro.json" };
81
- if (pack !== "piper-kitten" && !Object.hasOwn(manifests, pack)) throw new Error("Choose --pack cori, piper, kitten, piper-kitten or kokoro, or supply --sources-file for your own model pack.");
80
+ const manifests = { cori: "voice-models.json", piper: "voice-models-piper.json", kitten: "voice-models-kitten.json", kokoro: "voice-models-kokoro.json", "kokoro-q8f16": "voice-models-kokoro-q8f16.json" };
81
+ if (pack !== "piper-kitten" && pack !== "piper-kitten-kokoro-q8f16" && !Object.hasOwn(manifests, pack)) throw new Error("Choose --pack cori, piper, kitten, piper-kitten, kokoro, kokoro-q8f16 or piper-kitten-kokoro-q8f16, or supply --sources-file for your own model pack.");
82
82
  let temporary;
83
83
  try {
84
84
  env.JSKIT_VOICE_SOURCES_FILE = options["sources-file"] || process.env.JSKIT_VOICE_SOURCES_FILE;
85
- if (!env.JSKIT_VOICE_SOURCES_FILE && pack === "piper-kitten") {
85
+ if (!env.JSKIT_VOICE_SOURCES_FILE && (pack === "piper-kitten" || pack === "piper-kitten-kokoro-q8f16")) {
86
86
  const piper = JSON.parse(await readFile(new URL("../models/voice-models-piper.json", import.meta.url), "utf8"));
87
87
  const kitten = JSON.parse(await readFile(new URL("../models/voice-models-kitten.json", import.meta.url), "utf8"));
88
88
  piper.models.push(...kitten.models.filter(model => model.kind === "tts"));
89
89
  piper.configuration.voices.unshift(...kitten.configuration.voices);
90
90
  Object.assign(piper.configuration.synthesizers, kitten.configuration.synthesizers);
91
+ if (pack === "piper-kitten-kokoro-q8f16") {
92
+ const kokoro = JSON.parse(await readFile(new URL("../models/voice-models-kokoro-q8f16.json", import.meta.url), "utf8"));
93
+ piper.models.push(...kokoro.models.filter(model => model.kind === "tts"));
94
+ piper.configuration.voices.push(...kokoro.configuration.voices.map(voice => ({ ...voice, modelId: "kokoro" })));
95
+ piper.configuration.synthesizers.kokoro = kokoro.configuration.synthesizer;
96
+ piper.onnxMetadata = kokoro.onnxMetadata;
97
+ piper.textFiles = kokoro.textFiles;
98
+ }
91
99
  piper.configuration.outputSampleRate = 22050;
92
100
  temporary = await mkdtemp(path.join(os.tmpdir(), "jskit-voice-sources-"));
93
101
  env.JSKIT_VOICE_SOURCES_FILE = path.join(temporary, "sources.json");
@@ -103,7 +103,7 @@ async function createSherpaSpeechEngine({
103
103
 
104
104
  const voices = configuration.voices || (configuration.synthesizer || configuration.synthesizers
105
105
  ? [{ id: "default", label: "Default voice", speakerId: 0 }]
106
- : [{ id: "cori", label: "Cori (British English)", language: "en-GB", speakerId: 0 }]);
106
+ : [{ id: "cori", label: "Cori · female · British English · Piper", language: "en-GB", speakerId: 0 }]);
107
107
  if (!Array.isArray(voices) || !voices.length || voices.length > 128 || new Set(voices.map(voice => voice?.id)).size !== voices.length ||
108
108
  voices.some(voice => !voice || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u.test(voice.id) || typeof voice.label !== "string" || !voice.label.trim() || voice.label.length > 100 ||
109
109
  !Number.isSafeInteger(voice.speakerId) || voice.speakerId < 0)) {
@@ -58,9 +58,11 @@ read_model_field() {
58
58
  const fs = require("node:fs");
59
59
  const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
60
60
  const model = manifest.models.find((entry) => entry.id === process.argv[2]);
61
- if (!model || !model[process.argv[3]]) process.exit(2);
62
- process.stdout.write(String(model[process.argv[3]]));
63
- ' "$source_manifest" "$model_id" "$field"
61
+ if (!model) process.exit(2);
62
+ const value = model[process.argv[3]] ?? process.argv[4];
63
+ if (!value) process.exit(2);
64
+ process.stdout.write(String(value));
65
+ ' "$source_manifest" "$model_id" "$field" "${3:-}"
64
66
  }
65
67
 
66
68
  model_ids="$("$node_bin" -e 'console.log(JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).models.map(model => model.id).join("\n"))' "$source_manifest")"
@@ -125,7 +127,11 @@ archives=()
125
127
  while IFS= read -r model_id; do
126
128
  archive="$(fetch_archive "$model_id")"
127
129
  archives+=("$archive")
128
- tar --no-same-owner -xjf "$archive" -C "$extract_root"
130
+ case "$(read_model_field "$model_id" format tar.bz2)" in
131
+ tar.bz2) tar --no-same-owner -xjf "$archive" -C "$extract_root" ;;
132
+ file) cp -- "$archive" "$extract_root/$(basename -- "$archive")" ;;
133
+ *) echo "[assistant-voice] Unsupported model source format for $model_id" >&2; exit 1 ;;
134
+ esac
129
135
  done <<< "$model_ids"
130
136
 
131
137
  "$node_bin" "$script_dir/stage-voice-models.mjs" "$source_manifest" "$extract_root" "$stage_root"
@@ -1,4 +1,4 @@
1
- import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { appendFile, cp, lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  const [manifestPath, extractedRoot, stagedRoot] = process.argv.slice(2);
@@ -17,4 +17,44 @@ for (const model of manifest.models) {
17
17
  await cp(contained(extractedRoot, source), target, { recursive: true, errorOnExist: true, force: false });
18
18
  }
19
19
  }
20
+ // ONNX ModelProto metadata_props is field 14 (StringStringEntryProto).
21
+ // Append metadata only; the pinned source graph and weights remain byte-for-byte.
22
+ function protobufField(number, bytes) {
23
+ const length = [];
24
+ let remaining = bytes.length;
25
+ while (remaining > 127) { length.push((remaining & 127) | 128); remaining = Math.floor(remaining / 128); }
26
+ return Buffer.concat([Buffer.from([number * 8 + 2, ...length, remaining]), bytes]);
27
+ }
28
+ for (const [relative, entries] of Object.entries(manifest.onnxMetadata || {})) {
29
+ const target = contained(stagedRoot, relative);
30
+ if (!relative.endsWith(".onnx") || !entries || typeof entries !== "object" || Array.isArray(entries)) {
31
+ throw new Error("ONNX metadata must map model paths to string entries.");
32
+ }
33
+ const fields = Object.entries(entries).map(([key, value]) => {
34
+ if (!key || typeof value !== "string") throw new Error("ONNX metadata keys and values must be strings.");
35
+ return protobufField(14, Buffer.concat([protobufField(1, Buffer.from(key)), protobufField(2, Buffer.from(value))]));
36
+ });
37
+ if (!(await lstat(target)).isFile()) throw new Error("ONNX metadata requires a staged regular model file.");
38
+ const physicalRoot = await realpath(stagedRoot);
39
+ if (!(await realpath(target)).startsWith(`${physicalRoot}${path.sep}`)) {
40
+ throw new Error("ONNX metadata must stay inside its staged pack.");
41
+ }
42
+ await appendFile(target, Buffer.concat(fields));
43
+ }
44
+ for (const [relative, content] of Object.entries(manifest.textFiles || {})) {
45
+ if (typeof content !== "string") throw new Error("Model configuration files must contain text.");
46
+ const target = contained(stagedRoot, relative);
47
+ const physicalRoot = await realpath(stagedRoot);
48
+ let parent = path.dirname(target);
49
+ let physicalParent;
50
+ while (!physicalParent) {
51
+ try { physicalParent = await realpath(parent); }
52
+ catch (error) { if (error.code !== "ENOENT") throw error; parent = path.dirname(parent); }
53
+ }
54
+ if (physicalParent !== physicalRoot && !physicalParent.startsWith(`${physicalRoot}${path.sep}`)) {
55
+ throw new Error("Model configuration files must stay inside their staged pack.");
56
+ }
57
+ await mkdir(path.dirname(target), { recursive: true });
58
+ await writeFile(target, content, { flag: "wx" });
59
+ }
20
60
  await writeFile(path.join(stagedRoot, "speech.json"), JSON.stringify(manifest.configuration || {}, null, 2) + "\n");