@polytts/core 0.1.0
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/LICENSE +21 -0
- package/README.md +230 -0
- package/dist/index.d.ts +381 -0
- package/dist/index.js +817 -0
- package/package.json +50 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
//#region src/audio.ts
|
|
2
|
+
/** Creates an AudioData object from a sample rate and an array of channel buffers. */
|
|
3
|
+
function createAudioData(sampleRate, channels) {
|
|
4
|
+
return {
|
|
5
|
+
sampleRate,
|
|
6
|
+
channels
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/** Wraps a single-channel PCM Float32Array into an AudioData object. */
|
|
10
|
+
function pcmToAudioData(data, sampleRate) {
|
|
11
|
+
return createAudioData(sampleRate, [data]);
|
|
12
|
+
}
|
|
13
|
+
/** Creates a silent AudioData object, useful as a placeholder or for padding. */
|
|
14
|
+
function createSilentAudioData(sampleRate = 24e3, frameCount = 1) {
|
|
15
|
+
return createAudioData(sampleRate, [new Float32Array(frameCount)]);
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/catalog.ts
|
|
19
|
+
/** Creates a model catalog from a static array of model specs. */
|
|
20
|
+
function createStaticCatalog(models) {
|
|
21
|
+
return { models };
|
|
22
|
+
}
|
|
23
|
+
/** Resolves a catalog source (static object or factory function) into a ModelCatalog. */
|
|
24
|
+
function resolveCatalogSource(source) {
|
|
25
|
+
return typeof source === "function" ? source() : source;
|
|
26
|
+
}
|
|
27
|
+
/** Merges multiple catalog sources into a single catalog, throwing on duplicate model IDs. */
|
|
28
|
+
function mergeCatalogs(...sources) {
|
|
29
|
+
const models = [];
|
|
30
|
+
const seen = /* @__PURE__ */ new Set();
|
|
31
|
+
for (const source of sources) {
|
|
32
|
+
const catalog = resolveCatalogSource(source);
|
|
33
|
+
for (const model of catalog.models) {
|
|
34
|
+
if (seen.has(model.id)) throw new Error(`Duplicate model id in catalog merge: ${model.id}`);
|
|
35
|
+
seen.add(model.id);
|
|
36
|
+
models.push(model);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { models };
|
|
40
|
+
}
|
|
41
|
+
/** Validates that every model in the catalog references a registered adapter. */
|
|
42
|
+
function validateCatalog(catalog, adapters) {
|
|
43
|
+
for (const model of catalog.models) if (!adapters.has(model.adapterId)) throw new Error(`Model "${model.id}" references unknown adapter "${model.adapterId}"`);
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/storage/memory-asset-store.ts
|
|
47
|
+
function bundleId(bundle) {
|
|
48
|
+
return `${bundle.adapterId}/${bundle.modelId}`;
|
|
49
|
+
}
|
|
50
|
+
function revisionPrefix(bundle) {
|
|
51
|
+
return `${bundle.adapterId}/${bundle.modelId}/${bundle.revision}`;
|
|
52
|
+
}
|
|
53
|
+
function cloneData(data) {
|
|
54
|
+
return data.slice(0);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* In-memory implementation of AssetStore, primarily useful for testing and environments without
|
|
58
|
+
* persistent storage.
|
|
59
|
+
*/
|
|
60
|
+
var MemoryAssetStore = class {
|
|
61
|
+
staging = /* @__PURE__ */ new Map();
|
|
62
|
+
active = /* @__PURE__ */ new Map();
|
|
63
|
+
meta = /* @__PURE__ */ new Map();
|
|
64
|
+
async stageAsset(bundle, assetName, data) {
|
|
65
|
+
this.staging.set(`${revisionPrefix(bundle)}/${assetName}`, cloneData(data));
|
|
66
|
+
}
|
|
67
|
+
async activateBundle(bundle, assetNames) {
|
|
68
|
+
const stagedAssets = /* @__PURE__ */ new Map();
|
|
69
|
+
for (const assetName of assetNames) {
|
|
70
|
+
const key = `${revisionPrefix(bundle)}/${assetName}`;
|
|
71
|
+
const data = this.staging.get(key);
|
|
72
|
+
if (!data) throw new Error(`Missing staged asset: ${assetName}`);
|
|
73
|
+
stagedAssets.set(assetName, data);
|
|
74
|
+
}
|
|
75
|
+
for (const [assetName, data] of stagedAssets) {
|
|
76
|
+
this.active.set(`${revisionPrefix(bundle)}/${assetName}`, cloneData(data));
|
|
77
|
+
this.staging.delete(`${revisionPrefix(bundle)}/${assetName}`);
|
|
78
|
+
}
|
|
79
|
+
this.meta.set(bundleId(bundle), {
|
|
80
|
+
adapterId: bundle.adapterId,
|
|
81
|
+
modelId: bundle.modelId,
|
|
82
|
+
revision: bundle.revision,
|
|
83
|
+
assetNames
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async isInstalled(bundle, requiredAssetNames) {
|
|
87
|
+
const meta = this.meta.get(bundleId(bundle));
|
|
88
|
+
if (!meta || meta.revision !== bundle.revision) return false;
|
|
89
|
+
if (!requiredAssetNames?.length) return true;
|
|
90
|
+
return requiredAssetNames.every((assetName) => meta.assetNames.includes(assetName));
|
|
91
|
+
}
|
|
92
|
+
async getAsset(bundle, assetName) {
|
|
93
|
+
return this.active.get(`${revisionPrefix(bundle)}/${assetName}`) ?? null;
|
|
94
|
+
}
|
|
95
|
+
async removeBundle(bundle) {
|
|
96
|
+
const meta = this.meta.get(bundleId(bundle));
|
|
97
|
+
if (!meta) return;
|
|
98
|
+
for (const assetName of meta.assetNames) this.active.delete(`${bundle.adapterId}/${bundle.modelId}/${meta.revision}/${assetName}`);
|
|
99
|
+
this.meta.delete(bundleId(bundle));
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region src/types.ts
|
|
104
|
+
/** Default speaking speed multiplier (1x). */
|
|
105
|
+
const DEFAULT_SPEAK_SPEED = 1;
|
|
106
|
+
/** Minimum allowed speaking speed multiplier (0.5x). */
|
|
107
|
+
const MIN_SPEAK_SPEED = .5;
|
|
108
|
+
/** Maximum allowed speaking speed multiplier (2x). */
|
|
109
|
+
const MAX_SPEAK_SPEED = 2;
|
|
110
|
+
/**
|
|
111
|
+
* Clamps a speed value to the valid range, returning the default if the input is not a finite
|
|
112
|
+
* number.
|
|
113
|
+
*/
|
|
114
|
+
function normalizeSpeakSpeed(speed) {
|
|
115
|
+
if (!Number.isFinite(speed)) return 1;
|
|
116
|
+
return Math.min(2, Math.max(MIN_SPEAK_SPEED, speed));
|
|
117
|
+
}
|
|
118
|
+
/** Resolves a model's distribution info, defaulting to `{ kind: "none" }` when unset. */
|
|
119
|
+
function resolveModelDistribution(model) {
|
|
120
|
+
if (model.distribution) return {
|
|
121
|
+
kind: model.distribution.kind,
|
|
122
|
+
sizeBytes: model.distribution.sizeBytes ?? 0,
|
|
123
|
+
assets: model.distribution.assets ?? []
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
kind: "none",
|
|
127
|
+
sizeBytes: 0,
|
|
128
|
+
assets: []
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Returns the list of downloadable assets for a model. */
|
|
132
|
+
function getModelAssets(model) {
|
|
133
|
+
return resolveModelDistribution(model).assets ?? [];
|
|
134
|
+
}
|
|
135
|
+
/** Returns the total download size in bytes for a model. */
|
|
136
|
+
function getModelSizeBytes(model) {
|
|
137
|
+
return resolveModelDistribution(model).sizeBytes ?? 0;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Returns the install state for a model, inferring a default from its distribution if none is
|
|
141
|
+
* provided.
|
|
142
|
+
*/
|
|
143
|
+
function resolveInstallState(model, installState) {
|
|
144
|
+
if (installState) return installState;
|
|
145
|
+
const distribution = resolveModelDistribution(model);
|
|
146
|
+
if (distribution.kind === "none") return {
|
|
147
|
+
modelId: model.id,
|
|
148
|
+
adapterId: model.adapterId,
|
|
149
|
+
revision: model.revision,
|
|
150
|
+
kind: "not-applicable",
|
|
151
|
+
installed: true,
|
|
152
|
+
status: "not-applicable",
|
|
153
|
+
progress: null,
|
|
154
|
+
error: null
|
|
155
|
+
};
|
|
156
|
+
if (distribution.kind === "adapter-managed") return {
|
|
157
|
+
modelId: model.id,
|
|
158
|
+
adapterId: model.adapterId,
|
|
159
|
+
revision: model.revision,
|
|
160
|
+
kind: "external",
|
|
161
|
+
installed: false,
|
|
162
|
+
status: "unknown",
|
|
163
|
+
progress: null,
|
|
164
|
+
error: null
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
modelId: model.id,
|
|
168
|
+
adapterId: model.adapterId,
|
|
169
|
+
revision: model.revision,
|
|
170
|
+
kind: "managed",
|
|
171
|
+
installed: false,
|
|
172
|
+
status: "idle",
|
|
173
|
+
progress: null,
|
|
174
|
+
error: null
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** Returns true if the model is ready to use (either installed or installation is not applicable). */
|
|
178
|
+
function isInstallStateAvailable(installState) {
|
|
179
|
+
if (!installState) return false;
|
|
180
|
+
return installState.kind === "not-applicable" || installState.installed;
|
|
181
|
+
}
|
|
182
|
+
/** Type guard that checks whether the install state is managed by the core asset store. */
|
|
183
|
+
function isManagedInstallState(installState) {
|
|
184
|
+
return installState?.kind === "managed";
|
|
185
|
+
}
|
|
186
|
+
/** Type guard that checks whether the install state is managed externally by the adapter. */
|
|
187
|
+
function isExternalInstallState(installState) {
|
|
188
|
+
return installState?.kind === "external";
|
|
189
|
+
}
|
|
190
|
+
/** Resolves an adapter's full capabilities, using sensible defaults for any unspecified fields. */
|
|
191
|
+
function resolveAdapterCapabilities(adapter) {
|
|
192
|
+
return {
|
|
193
|
+
install: adapter.capabilities?.install ?? Boolean(adapter.install),
|
|
194
|
+
speak: adapter.capabilities?.speak ?? false,
|
|
195
|
+
synthesize: adapter.capabilities?.synthesize ?? false,
|
|
196
|
+
stream: adapter.capabilities?.stream ?? false,
|
|
197
|
+
dynamicVoices: adapter.capabilities?.dynamicVoices ?? false
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/runtime.ts
|
|
202
|
+
function createAbortError() {
|
|
203
|
+
const error = /* @__PURE__ */ new Error("Aborted");
|
|
204
|
+
error.name = "AbortError";
|
|
205
|
+
return error;
|
|
206
|
+
}
|
|
207
|
+
function bundleFor(model) {
|
|
208
|
+
return {
|
|
209
|
+
adapterId: model.adapterId,
|
|
210
|
+
modelId: model.id,
|
|
211
|
+
revision: model.revision
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function createUnsupportedOperationError(operation, modelId) {
|
|
215
|
+
return /* @__PURE__ */ new Error(`Model "${modelId}" does not support ${operation}`);
|
|
216
|
+
}
|
|
217
|
+
async function fetchAssetWithProgress(asset, fetchImpl, signal, onProgress) {
|
|
218
|
+
const response = await fetchImpl(asset.url, { signal });
|
|
219
|
+
if (!response.ok) throw new Error(`Failed to fetch ${asset.name}: HTTP ${response.status}`);
|
|
220
|
+
if (!response.body) {
|
|
221
|
+
const data = await response.arrayBuffer();
|
|
222
|
+
onProgress?.(1);
|
|
223
|
+
return data;
|
|
224
|
+
}
|
|
225
|
+
const contentLength = Number(response.headers.get("content-length") ?? 0) || asset.size || 0;
|
|
226
|
+
const reader = response.body.getReader();
|
|
227
|
+
const chunks = [];
|
|
228
|
+
let received = 0;
|
|
229
|
+
while (true) {
|
|
230
|
+
const { done, value } = await reader.read();
|
|
231
|
+
if (done) break;
|
|
232
|
+
chunks.push(value);
|
|
233
|
+
received += value.length;
|
|
234
|
+
if (contentLength > 0) onProgress?.(received / contentLength);
|
|
235
|
+
}
|
|
236
|
+
const combined = new Uint8Array(received);
|
|
237
|
+
let offset = 0;
|
|
238
|
+
for (const chunk of chunks) {
|
|
239
|
+
combined.set(chunk, offset);
|
|
240
|
+
offset += chunk.length;
|
|
241
|
+
}
|
|
242
|
+
onProgress?.(1);
|
|
243
|
+
return combined.buffer;
|
|
244
|
+
}
|
|
245
|
+
var RuntimeImpl = class {
|
|
246
|
+
adapters;
|
|
247
|
+
models;
|
|
248
|
+
assetStore;
|
|
249
|
+
audioPlayer;
|
|
250
|
+
fetchImpl;
|
|
251
|
+
listeners = /* @__PURE__ */ new Set();
|
|
252
|
+
instances = /* @__PURE__ */ new Map();
|
|
253
|
+
instancePromises = /* @__PURE__ */ new Map();
|
|
254
|
+
loadedModels = /* @__PURE__ */ new Set();
|
|
255
|
+
preferredVoiceByModel = /* @__PURE__ */ new Map();
|
|
256
|
+
installOpByModel = /* @__PURE__ */ new Map();
|
|
257
|
+
state;
|
|
258
|
+
currentAbort = null;
|
|
259
|
+
currentAbortModelId = null;
|
|
260
|
+
disposed = false;
|
|
261
|
+
installOpSeq = 0;
|
|
262
|
+
phaseOpSeq = 0;
|
|
263
|
+
constructor(options) {
|
|
264
|
+
this.options = options;
|
|
265
|
+
this.adapters = new Map(options.adapters.map((adapter) => [adapter.id, adapter]));
|
|
266
|
+
const catalog = mergeCatalogs(...options.catalogs);
|
|
267
|
+
validateCatalog(catalog, this.adapters);
|
|
268
|
+
this.models = new Map(catalog.models.map((model) => [model.id, model]));
|
|
269
|
+
this.assetStore = options.assetStore ?? new MemoryAssetStore();
|
|
270
|
+
this.audioPlayer = options.audioPlayer ?? null;
|
|
271
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
272
|
+
const supportedModels = catalog.models.filter((model) => this.isModelSupported(model));
|
|
273
|
+
const initialModelId = options.initialModelId && this.models.has(options.initialModelId) && options.initialModelId || supportedModels[0]?.id || null;
|
|
274
|
+
const activeModel = initialModelId ? this.models.get(initialModelId) ?? null : null;
|
|
275
|
+
const activeVoiceId = options.initialVoiceId ?? activeModel?.defaultVoiceId ?? null;
|
|
276
|
+
if (initialModelId && activeVoiceId) this.preferredVoiceByModel.set(initialModelId, activeVoiceId);
|
|
277
|
+
this.state = {
|
|
278
|
+
models: catalog.models,
|
|
279
|
+
supportedModelIds: supportedModels.map((model) => model.id),
|
|
280
|
+
activeModelId: initialModelId,
|
|
281
|
+
activeVoiceId,
|
|
282
|
+
voices: activeModel?.voices ?? [],
|
|
283
|
+
isPreparing: false,
|
|
284
|
+
isSpeaking: false,
|
|
285
|
+
phase: "idle",
|
|
286
|
+
phaseModelId: null,
|
|
287
|
+
phaseProgress: null,
|
|
288
|
+
error: null,
|
|
289
|
+
installStates: Object.fromEntries(catalog.models.map((model) => [model.id, resolveInstallState(model)])),
|
|
290
|
+
installStateHydrated: false,
|
|
291
|
+
runtimeInfoByModel: Object.fromEntries(catalog.models.map((model) => [model.id, null]))
|
|
292
|
+
};
|
|
293
|
+
this.hydrateInstallStates();
|
|
294
|
+
}
|
|
295
|
+
getState() {
|
|
296
|
+
return this.state;
|
|
297
|
+
}
|
|
298
|
+
subscribe(listener) {
|
|
299
|
+
this.listeners.add(listener);
|
|
300
|
+
return () => {
|
|
301
|
+
this.listeners.delete(listener);
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
listModels() {
|
|
305
|
+
return [...this.state.models];
|
|
306
|
+
}
|
|
307
|
+
getModel(modelId) {
|
|
308
|
+
return this.models.get(modelId) ?? null;
|
|
309
|
+
}
|
|
310
|
+
async listVoices(modelId = this.state.activeModelId ?? void 0) {
|
|
311
|
+
if (!modelId) return [];
|
|
312
|
+
const model = this.getRequiredModel(modelId);
|
|
313
|
+
const voices = await (await this.getOrCreateInstance(model)).listVoices();
|
|
314
|
+
return voices.length ? voices : model.voices ?? [];
|
|
315
|
+
}
|
|
316
|
+
async install(modelId, onProgress) {
|
|
317
|
+
const model = this.getRequiredModel(modelId);
|
|
318
|
+
this.patchState({ error: null });
|
|
319
|
+
try {
|
|
320
|
+
await this.installInternal(model, new AbortController().signal, onProgress);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error.name !== "AbortError") this.patchState({
|
|
323
|
+
error: error.message,
|
|
324
|
+
phase: "error",
|
|
325
|
+
phaseModelId: model.id,
|
|
326
|
+
phaseProgress: null
|
|
327
|
+
});
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async uninstall(modelId) {
|
|
332
|
+
const model = this.getRequiredModel(modelId);
|
|
333
|
+
const adapter = this.getRequiredAdapter(model.adapterId);
|
|
334
|
+
const distribution = resolveModelDistribution(model);
|
|
335
|
+
if (!(adapter.uninstall != null || distribution.kind === "managed-assets" && getModelAssets(model).length > 0)) return;
|
|
336
|
+
const instance = this.instances.get(modelId);
|
|
337
|
+
if (instance) {
|
|
338
|
+
instance.dispose();
|
|
339
|
+
this.instances.delete(modelId);
|
|
340
|
+
this.loadedModels.delete(modelId);
|
|
341
|
+
this.updateRuntimeInfo(modelId, null);
|
|
342
|
+
}
|
|
343
|
+
if (adapter.uninstall) await adapter.uninstall(model, this.createAdapterContext());
|
|
344
|
+
else if (distribution.kind === "managed-assets" && getModelAssets(model).length > 0) await this.assetStore.removeBundle(bundleFor(model));
|
|
345
|
+
this.updateInstallState(model.id, {
|
|
346
|
+
installed: false,
|
|
347
|
+
status: distribution.kind === "adapter-managed" ? "unknown" : "idle",
|
|
348
|
+
progress: null,
|
|
349
|
+
error: null
|
|
350
|
+
});
|
|
351
|
+
if (this.state.activeModelId === model.id) this.patchState({
|
|
352
|
+
activeModelId: null,
|
|
353
|
+
activeVoiceId: null,
|
|
354
|
+
voices: []
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
async prepare(modelId, options) {
|
|
358
|
+
const abort = new AbortController();
|
|
359
|
+
await this.prepareInternal(this.getRequiredModel(modelId), options, abort.signal);
|
|
360
|
+
}
|
|
361
|
+
async speak(text, options) {
|
|
362
|
+
if (!text.trim()) return;
|
|
363
|
+
this.stop();
|
|
364
|
+
this.audioPlayer?.warmup?.();
|
|
365
|
+
const abort = new AbortController();
|
|
366
|
+
this.currentAbort = abort;
|
|
367
|
+
this.currentAbortModelId = null;
|
|
368
|
+
this.patchState({
|
|
369
|
+
isSpeaking: true,
|
|
370
|
+
error: null
|
|
371
|
+
});
|
|
372
|
+
try {
|
|
373
|
+
const candidateIds = this.buildCandidateList(options?.modelId, options?.fallbackModelIds);
|
|
374
|
+
let lastError = null;
|
|
375
|
+
for (const candidateId of candidateIds) try {
|
|
376
|
+
const model = this.getRequiredModel(candidateId);
|
|
377
|
+
this.currentAbortModelId = model.id;
|
|
378
|
+
await this.prepareInternal(model, { voiceId: options?.voiceId }, abort.signal);
|
|
379
|
+
const adapter = this.getRequiredAdapter(model.adapterId);
|
|
380
|
+
const instance = await this.getOrCreateInstance(model);
|
|
381
|
+
const voiceId = await this.resolveVoice(model, options?.voiceId);
|
|
382
|
+
const speed = options?.speed;
|
|
383
|
+
const speakingPhaseId = this.startPhase("speaking", model.id, null);
|
|
384
|
+
if (!supportsSpeak(adapter, instance, this.audioPlayer)) throw createUnsupportedOperationError("playback", model.id);
|
|
385
|
+
if (isSpeakingInstance(instance)) await instance.speak(text, voiceId, abort.signal, speed);
|
|
386
|
+
else if (supportsStream(adapter, instance) && this.audioPlayer?.playStream) await this.audioPlayer.playStream(instance.stream(text, voiceId, abort.signal, speed), abort.signal, speed);
|
|
387
|
+
else {
|
|
388
|
+
if (!this.audioPlayer) throw new Error(`No audio player configured for model "${model.id}"`);
|
|
389
|
+
if (!supportsSynthesize(adapter, instance)) throw createUnsupportedOperationError("audio synthesis", model.id);
|
|
390
|
+
const buffer = await instance.generate(text, voiceId, abort.signal, speed);
|
|
391
|
+
await this.audioPlayer.play(buffer, abort.signal, speed);
|
|
392
|
+
}
|
|
393
|
+
this.completePhaseIfCurrent(speakingPhaseId);
|
|
394
|
+
this.patchState({
|
|
395
|
+
isSpeaking: false,
|
|
396
|
+
error: null
|
|
397
|
+
});
|
|
398
|
+
return;
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (error.name === "AbortError") throw error;
|
|
401
|
+
lastError = error;
|
|
402
|
+
}
|
|
403
|
+
throw lastError ?? /* @__PURE__ */ new Error("Unable to speak with any configured model");
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (error.name !== "AbortError") {
|
|
406
|
+
this.patchState({ error: error.message });
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
} finally {
|
|
410
|
+
if (this.currentAbort === abort) {
|
|
411
|
+
this.currentAbort = null;
|
|
412
|
+
this.currentAbortModelId = null;
|
|
413
|
+
}
|
|
414
|
+
this.patchState({
|
|
415
|
+
isSpeaking: false,
|
|
416
|
+
phase: this.state.phase === "speaking" ? "idle" : this.state.phase,
|
|
417
|
+
phaseModelId: this.state.phase === "speaking" ? null : this.state.phaseModelId,
|
|
418
|
+
phaseProgress: this.state.phase === "speaking" ? null : this.state.phaseProgress
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
async synthesize(text, options) {
|
|
423
|
+
if (!text.trim()) throw new Error("Cannot synthesize empty text");
|
|
424
|
+
this.stop();
|
|
425
|
+
const abort = new AbortController();
|
|
426
|
+
this.currentAbort = abort;
|
|
427
|
+
this.currentAbortModelId = null;
|
|
428
|
+
this.patchState({ error: null });
|
|
429
|
+
try {
|
|
430
|
+
const candidateIds = this.buildCandidateList(options?.modelId, options?.fallbackModelIds);
|
|
431
|
+
let lastError = null;
|
|
432
|
+
for (const candidateId of candidateIds) try {
|
|
433
|
+
const model = this.getRequiredModel(candidateId);
|
|
434
|
+
this.currentAbortModelId = model.id;
|
|
435
|
+
await this.prepareInternal(model, { voiceId: options?.voiceId }, abort.signal);
|
|
436
|
+
const adapter = this.getRequiredAdapter(model.adapterId);
|
|
437
|
+
const instance = await this.getOrCreateInstance(model);
|
|
438
|
+
if (!supportsSynthesize(adapter, instance)) throw createUnsupportedOperationError("audio synthesis", model.id);
|
|
439
|
+
const voiceId = await this.resolveVoice(model, options?.voiceId);
|
|
440
|
+
return await instance.generate(text, voiceId, abort.signal, options?.speed);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (error.name === "AbortError") throw error;
|
|
443
|
+
lastError = error;
|
|
444
|
+
}
|
|
445
|
+
throw lastError ?? /* @__PURE__ */ new Error("Unable to synthesize with any configured model");
|
|
446
|
+
} finally {
|
|
447
|
+
if (this.currentAbort === abort) {
|
|
448
|
+
this.currentAbort = null;
|
|
449
|
+
this.currentAbortModelId = null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
synthesizeStream(text, options) {
|
|
454
|
+
if (!text.trim()) throw new Error("Cannot synthesize empty text");
|
|
455
|
+
this.stop();
|
|
456
|
+
const abort = new AbortController();
|
|
457
|
+
this.currentAbort = abort;
|
|
458
|
+
this.currentAbortModelId = null;
|
|
459
|
+
const candidateIds = this.buildCandidateList(options?.modelId, options?.fallbackModelIds);
|
|
460
|
+
return async function* () {
|
|
461
|
+
let lastError = null;
|
|
462
|
+
try {
|
|
463
|
+
for (const candidateId of candidateIds) try {
|
|
464
|
+
const model = this.getRequiredModel(candidateId);
|
|
465
|
+
this.currentAbortModelId = model.id;
|
|
466
|
+
await this.prepareInternal(model, { voiceId: options?.voiceId }, abort.signal);
|
|
467
|
+
const adapter = this.getRequiredAdapter(model.adapterId);
|
|
468
|
+
const instance = await this.getOrCreateInstance(model);
|
|
469
|
+
const voiceId = await this.resolveVoice(model, options?.voiceId);
|
|
470
|
+
if (supportsStream(adapter, instance)) for await (const chunk of instance.stream(text, voiceId, abort.signal, options?.speed)) {
|
|
471
|
+
if (abort.signal.aborted) throw createAbortError();
|
|
472
|
+
yield chunk;
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
if (!supportsSynthesize(adapter, instance)) throw createUnsupportedOperationError("streaming audio synthesis", model.id);
|
|
476
|
+
yield await instance.generate(text, voiceId, abort.signal, options?.speed);
|
|
477
|
+
}
|
|
478
|
+
return;
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (error.name === "AbortError") throw error;
|
|
481
|
+
lastError = error;
|
|
482
|
+
}
|
|
483
|
+
throw lastError ?? /* @__PURE__ */ new Error("Unable to synthesize with any configured model");
|
|
484
|
+
} finally {
|
|
485
|
+
if (this.currentAbort === abort) {
|
|
486
|
+
this.currentAbort = null;
|
|
487
|
+
this.currentAbortModelId = null;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}.bind(this)();
|
|
491
|
+
}
|
|
492
|
+
stop() {
|
|
493
|
+
const abortedModelId = this.currentAbortModelId;
|
|
494
|
+
this.currentAbort?.abort();
|
|
495
|
+
this.currentAbort = null;
|
|
496
|
+
this.currentAbortModelId = null;
|
|
497
|
+
if (abortedModelId) this.resetAbortableInstance(abortedModelId);
|
|
498
|
+
this.audioPlayer?.stop();
|
|
499
|
+
this.patchState({
|
|
500
|
+
isSpeaking: false,
|
|
501
|
+
phase: this.state.phase === "speaking" ? "idle" : this.state.phase,
|
|
502
|
+
phaseModelId: this.state.phase === "speaking" ? null : this.state.phaseModelId,
|
|
503
|
+
phaseProgress: this.state.phase === "speaking" ? null : this.state.phaseProgress
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
resetAbortableInstance(modelId) {
|
|
507
|
+
const instance = this.instances.get(modelId);
|
|
508
|
+
if (!instance?.abortActiveGeneration) return;
|
|
509
|
+
instance.abortActiveGeneration();
|
|
510
|
+
this.loadedModels.delete(modelId);
|
|
511
|
+
}
|
|
512
|
+
dispose() {
|
|
513
|
+
if (this.disposed) return;
|
|
514
|
+
this.disposed = true;
|
|
515
|
+
this.stop();
|
|
516
|
+
this.audioPlayer?.dispose();
|
|
517
|
+
for (const instance of this.instances.values()) instance.dispose();
|
|
518
|
+
this.instances.clear();
|
|
519
|
+
this.instancePromises.clear();
|
|
520
|
+
this.loadedModels.clear();
|
|
521
|
+
this.listeners.clear();
|
|
522
|
+
}
|
|
523
|
+
async hydrateInstallStates() {
|
|
524
|
+
try {
|
|
525
|
+
for (const model of this.state.models) {
|
|
526
|
+
const distribution = resolveModelDistribution(model);
|
|
527
|
+
const assets = getModelAssets(model);
|
|
528
|
+
if (distribution.kind !== "managed-assets" || assets.length === 0) continue;
|
|
529
|
+
const installed = await this.assetStore.isInstalled(bundleFor(model), assets.map((asset) => asset.name));
|
|
530
|
+
this.updateInstallState(model.id, {
|
|
531
|
+
installed,
|
|
532
|
+
status: installed ? "installed" : "idle",
|
|
533
|
+
progress: installed ? 1 : null
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
} finally {
|
|
537
|
+
this.patchState({ installStateHydrated: true });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
createAdapterContext() {
|
|
541
|
+
return {
|
|
542
|
+
assetStore: this.assetStore,
|
|
543
|
+
fetch: this.fetchImpl,
|
|
544
|
+
createAbortError
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
async getOrCreateInstance(model) {
|
|
548
|
+
const existing = this.instances.get(model.id);
|
|
549
|
+
if (existing) return existing;
|
|
550
|
+
const inFlight = this.instancePromises.get(model.id);
|
|
551
|
+
if (inFlight) return inFlight;
|
|
552
|
+
const promise = (async () => {
|
|
553
|
+
const instance = await this.getRequiredAdapter(model.adapterId).createModel(model, this.createAdapterContext());
|
|
554
|
+
if (this.disposed) {
|
|
555
|
+
instance.dispose();
|
|
556
|
+
throw new Error("TTS runtime has been disposed");
|
|
557
|
+
}
|
|
558
|
+
this.instances.set(model.id, instance);
|
|
559
|
+
return instance;
|
|
560
|
+
})();
|
|
561
|
+
this.instancePromises.set(model.id, promise);
|
|
562
|
+
try {
|
|
563
|
+
return await promise;
|
|
564
|
+
} finally {
|
|
565
|
+
if (this.instancePromises.get(model.id) === promise) this.instancePromises.delete(model.id);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
async prepareInternal(model, options, signal) {
|
|
569
|
+
if (!this.isModelSupported(model)) throw new Error(`Model "${model.id}" is not supported in this runtime`);
|
|
570
|
+
this.patchState({
|
|
571
|
+
isPreparing: true,
|
|
572
|
+
error: null
|
|
573
|
+
});
|
|
574
|
+
try {
|
|
575
|
+
if (resolveModelDistribution(model).kind === "managed-assets") {
|
|
576
|
+
const installState = this.state.installStates[model.id];
|
|
577
|
+
if (isManagedInstallState(installState) && !installState.installed) await this.installInternal(model, signal, options?.onProgress);
|
|
578
|
+
}
|
|
579
|
+
const instance = await this.getOrCreateInstance(model);
|
|
580
|
+
if (!this.loadedModels.has(model.id)) {
|
|
581
|
+
const phaseId = this.startPhase("loading", model.id, null);
|
|
582
|
+
await instance.load(signal, (progress) => {
|
|
583
|
+
this.updatePhaseIfCurrent(phaseId, { phaseProgress: progress });
|
|
584
|
+
options?.onProgress?.(progress);
|
|
585
|
+
});
|
|
586
|
+
this.loadedModels.add(model.id);
|
|
587
|
+
this.completePhaseIfCurrent(phaseId);
|
|
588
|
+
}
|
|
589
|
+
this.updateRuntimeInfo(model.id, instance.getRuntimeInfo?.() ?? null);
|
|
590
|
+
const voices = await instance.listVoices();
|
|
591
|
+
const resolvedVoices = voices.length ? voices : model.voices ?? [];
|
|
592
|
+
const activeVoiceId = this.pickVoiceId(model, resolvedVoices, options?.voiceId);
|
|
593
|
+
if (activeVoiceId) this.preferredVoiceByModel.set(model.id, activeVoiceId);
|
|
594
|
+
if (isExternalInstallState(this.state.installStates[model.id])) this.updateInstallState(model.id, {
|
|
595
|
+
installed: true,
|
|
596
|
+
status: "ready",
|
|
597
|
+
progress: null,
|
|
598
|
+
error: null
|
|
599
|
+
});
|
|
600
|
+
this.patchState({
|
|
601
|
+
activeModelId: model.id,
|
|
602
|
+
activeVoiceId,
|
|
603
|
+
voices: resolvedVoices,
|
|
604
|
+
isPreparing: false,
|
|
605
|
+
error: null
|
|
606
|
+
});
|
|
607
|
+
} catch (error) {
|
|
608
|
+
if (isExternalInstallState(this.state.installStates[model.id])) this.updateInstallState(model.id, {
|
|
609
|
+
installed: false,
|
|
610
|
+
status: error.name === "AbortError" ? "unknown" : "error",
|
|
611
|
+
progress: null,
|
|
612
|
+
error: error.name === "AbortError" ? null : error.message
|
|
613
|
+
});
|
|
614
|
+
this.patchState({
|
|
615
|
+
isPreparing: false,
|
|
616
|
+
phase: error.name === "AbortError" ? "idle" : "error",
|
|
617
|
+
phaseModelId: error.name === "AbortError" ? null : model.id,
|
|
618
|
+
phaseProgress: null,
|
|
619
|
+
error: error.name === "AbortError" ? null : error.message
|
|
620
|
+
});
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async resolveVoice(model, requestedVoiceId) {
|
|
625
|
+
const voices = await this.listVoices(model.id);
|
|
626
|
+
return this.pickVoiceId(model, voices, requestedVoiceId) ?? "default";
|
|
627
|
+
}
|
|
628
|
+
buildCandidateList(preferredModelId, fallbackModelIds = []) {
|
|
629
|
+
const resolvedCandidates = (preferredModelId != null ? [preferredModelId, ...fallbackModelIds] : fallbackModelIds.length > 0 ? [this.state.activeModelId, ...fallbackModelIds] : [this.state.activeModelId, ...this.state.supportedModelIds]).filter((value) => Boolean(value));
|
|
630
|
+
return [...new Set(resolvedCandidates)];
|
|
631
|
+
}
|
|
632
|
+
isModelSupported(model) {
|
|
633
|
+
const adapter = this.adapters.get(model.adapterId);
|
|
634
|
+
if (!adapter) return false;
|
|
635
|
+
return adapter.isSupported ? adapter.isSupported(model) : true;
|
|
636
|
+
}
|
|
637
|
+
getRequiredModel(modelId) {
|
|
638
|
+
const model = this.models.get(modelId);
|
|
639
|
+
if (!model) throw new Error(`Unknown model: ${modelId}`);
|
|
640
|
+
return model;
|
|
641
|
+
}
|
|
642
|
+
getRequiredAdapter(adapterId) {
|
|
643
|
+
const adapter = this.adapters.get(adapterId);
|
|
644
|
+
if (!adapter) throw new Error(`Unknown adapter: ${adapterId}`);
|
|
645
|
+
return adapter;
|
|
646
|
+
}
|
|
647
|
+
updateInstallState(modelId, patch) {
|
|
648
|
+
const current = this.state.installStates[modelId];
|
|
649
|
+
if (!current) return;
|
|
650
|
+
this.patchState({ installStates: {
|
|
651
|
+
...this.state.installStates,
|
|
652
|
+
[modelId]: {
|
|
653
|
+
...current,
|
|
654
|
+
...patch
|
|
655
|
+
}
|
|
656
|
+
} });
|
|
657
|
+
}
|
|
658
|
+
updateRuntimeInfo(modelId, runtimeInfo) {
|
|
659
|
+
if ((this.state.runtimeInfoByModel[modelId] ?? null) === runtimeInfo) return;
|
|
660
|
+
this.patchState({ runtimeInfoByModel: {
|
|
661
|
+
...this.state.runtimeInfoByModel,
|
|
662
|
+
[modelId]: runtimeInfo
|
|
663
|
+
} });
|
|
664
|
+
}
|
|
665
|
+
async installInternal(model, signal, onProgress) {
|
|
666
|
+
const adapter = this.getRequiredAdapter(model.adapterId);
|
|
667
|
+
const adapterCapabilities = resolveAdapterCapabilities(adapter);
|
|
668
|
+
const distribution = resolveModelDistribution(model);
|
|
669
|
+
const context = this.createAdapterContext();
|
|
670
|
+
const bundle = bundleFor(model);
|
|
671
|
+
const assets = getModelAssets(model);
|
|
672
|
+
const assetNames = assets.map((asset) => asset.name);
|
|
673
|
+
if (!adapterCapabilities.install && distribution.kind !== "managed-assets") return;
|
|
674
|
+
const opId = ++this.installOpSeq;
|
|
675
|
+
this.installOpByModel.set(model.id, opId);
|
|
676
|
+
const phaseId = this.startPhase("installing", model.id, 0);
|
|
677
|
+
if (distribution.kind === "managed-assets" && assetNames.length > 0 && await this.assetStore.isInstalled(bundle, assetNames)) {
|
|
678
|
+
this.updateInstallStateIfCurrent(model.id, opId, {
|
|
679
|
+
installed: true,
|
|
680
|
+
status: "installed",
|
|
681
|
+
progress: 1,
|
|
682
|
+
error: null
|
|
683
|
+
});
|
|
684
|
+
this.updatePhaseIfCurrent(phaseId, { phaseProgress: 1 });
|
|
685
|
+
this.completePhaseIfCurrent(phaseId);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
this.updateInstallStateIfCurrent(model.id, opId, {
|
|
689
|
+
installed: false,
|
|
690
|
+
status: "installing",
|
|
691
|
+
progress: 0,
|
|
692
|
+
error: null
|
|
693
|
+
});
|
|
694
|
+
try {
|
|
695
|
+
if (adapter.install) await adapter.install(model, context, signal, (progress) => {
|
|
696
|
+
this.updateInstallStateIfCurrent(model.id, opId, { progress });
|
|
697
|
+
this.updatePhaseIfCurrent(phaseId, { phaseProgress: progress });
|
|
698
|
+
onProgress?.(progress);
|
|
699
|
+
});
|
|
700
|
+
else if (distribution.kind === "managed-assets" && assets.length > 0) {
|
|
701
|
+
const totalBytes = assets.reduce((sum, asset) => sum + Math.max(asset.size, 1), 0) || assets.length;
|
|
702
|
+
let completedBytes = 0;
|
|
703
|
+
for (const asset of assets) {
|
|
704
|
+
const data = await fetchAssetWithProgress(asset, this.fetchImpl, signal, (progress) => {
|
|
705
|
+
const totalProgress = (completedBytes + Math.max(asset.size, 1) * progress) / totalBytes;
|
|
706
|
+
this.updateInstallStateIfCurrent(model.id, opId, { progress: totalProgress });
|
|
707
|
+
this.updatePhaseIfCurrent(phaseId, { phaseProgress: totalProgress });
|
|
708
|
+
onProgress?.(totalProgress);
|
|
709
|
+
});
|
|
710
|
+
await this.assetStore.stageAsset(bundle, asset.name, data);
|
|
711
|
+
completedBytes += Math.max(asset.size, 1);
|
|
712
|
+
}
|
|
713
|
+
await this.assetStore.activateBundle(bundle, assetNames);
|
|
714
|
+
}
|
|
715
|
+
this.updateInstallStateIfCurrent(model.id, opId, {
|
|
716
|
+
installed: true,
|
|
717
|
+
status: distribution.kind === "adapter-managed" ? "ready" : "installed",
|
|
718
|
+
progress: distribution.kind === "adapter-managed" ? null : 1,
|
|
719
|
+
error: null
|
|
720
|
+
});
|
|
721
|
+
this.updatePhaseIfCurrent(phaseId, { phaseProgress: distribution.kind === "adapter-managed" ? null : 1 });
|
|
722
|
+
this.completePhaseIfCurrent(phaseId);
|
|
723
|
+
onProgress?.(1);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (error.name === "AbortError") {
|
|
726
|
+
this.updateInstallStateIfCurrent(model.id, opId, {
|
|
727
|
+
installed: false,
|
|
728
|
+
status: distribution.kind === "adapter-managed" ? "unknown" : "idle",
|
|
729
|
+
progress: null,
|
|
730
|
+
error: null
|
|
731
|
+
});
|
|
732
|
+
this.completePhaseIfCurrent(phaseId);
|
|
733
|
+
} else {
|
|
734
|
+
this.updateInstallStateIfCurrent(model.id, opId, {
|
|
735
|
+
installed: false,
|
|
736
|
+
status: "error",
|
|
737
|
+
progress: null,
|
|
738
|
+
error: error.message
|
|
739
|
+
});
|
|
740
|
+
this.patchState({
|
|
741
|
+
phase: "error",
|
|
742
|
+
phaseModelId: model.id,
|
|
743
|
+
phaseProgress: null
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
throw error;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
pickVoiceId(model, voices, requestedVoiceId) {
|
|
750
|
+
const configuredVoices = voices.length ? voices : model.voices ?? [];
|
|
751
|
+
const availableVoiceIds = new Set(configuredVoices.map((voice) => voice.id));
|
|
752
|
+
const candidates = [
|
|
753
|
+
requestedVoiceId,
|
|
754
|
+
this.preferredVoiceByModel.get(model.id),
|
|
755
|
+
model.defaultVoiceId,
|
|
756
|
+
configuredVoices[0]?.id
|
|
757
|
+
];
|
|
758
|
+
if (availableVoiceIds.size === 0) return candidates.find((candidate) => Boolean(candidate)) ?? null;
|
|
759
|
+
for (const candidate of candidates) if (candidate && availableVoiceIds.has(candidate)) return candidate;
|
|
760
|
+
return configuredVoices[0]?.id ?? null;
|
|
761
|
+
}
|
|
762
|
+
updateInstallStateIfCurrent(modelId, opId, patch) {
|
|
763
|
+
if (this.installOpByModel.get(modelId) !== opId) return;
|
|
764
|
+
this.updateInstallState(modelId, patch);
|
|
765
|
+
}
|
|
766
|
+
startPhase(phase, modelId, progress) {
|
|
767
|
+
const opId = ++this.phaseOpSeq;
|
|
768
|
+
this.patchState({
|
|
769
|
+
phase,
|
|
770
|
+
phaseModelId: modelId,
|
|
771
|
+
phaseProgress: progress
|
|
772
|
+
});
|
|
773
|
+
return opId;
|
|
774
|
+
}
|
|
775
|
+
updatePhaseIfCurrent(opId, patch) {
|
|
776
|
+
if (this.phaseOpSeq !== opId) return;
|
|
777
|
+
this.patchState(patch);
|
|
778
|
+
}
|
|
779
|
+
completePhaseIfCurrent(opId) {
|
|
780
|
+
if (this.phaseOpSeq !== opId) return;
|
|
781
|
+
this.patchState({
|
|
782
|
+
phase: this.state.error ? "error" : this.state.isSpeaking ? "speaking" : "idle",
|
|
783
|
+
phaseModelId: this.state.isSpeaking ? this.state.activeModelId : null,
|
|
784
|
+
phaseProgress: null
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
patchState(patch) {
|
|
788
|
+
this.state = {
|
|
789
|
+
...this.state,
|
|
790
|
+
...patch
|
|
791
|
+
};
|
|
792
|
+
for (const listener of this.listeners) listener(this.state);
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
function isSpeakingInstance(instance) {
|
|
796
|
+
return typeof instance.speak === "function";
|
|
797
|
+
}
|
|
798
|
+
function isSynthesizingInstance(instance) {
|
|
799
|
+
return typeof instance.generate === "function";
|
|
800
|
+
}
|
|
801
|
+
function supportsSpeak(adapter, instance, audioPlayer) {
|
|
802
|
+
if (isSpeakingInstance(instance)) return adapter.capabilities?.speak ?? true;
|
|
803
|
+
if (audioPlayer?.playStream && supportsStream(adapter, instance)) return true;
|
|
804
|
+
return Boolean(audioPlayer && isSynthesizingInstance(instance) && (adapter.capabilities?.synthesize ?? true));
|
|
805
|
+
}
|
|
806
|
+
function supportsSynthesize(adapter, instance) {
|
|
807
|
+
return isSynthesizingInstance(instance) && (adapter.capabilities?.synthesize ?? true);
|
|
808
|
+
}
|
|
809
|
+
function supportsStream(adapter, instance) {
|
|
810
|
+
return isSynthesizingInstance(instance) && typeof instance.stream === "function" && (adapter.capabilities?.stream ?? true);
|
|
811
|
+
}
|
|
812
|
+
/** Creates a new TTS runtime instance, the main entry point for text-to-speech functionality. */
|
|
813
|
+
function createTTSRuntime(options) {
|
|
814
|
+
return new RuntimeImpl(options);
|
|
815
|
+
}
|
|
816
|
+
//#endregion
|
|
817
|
+
export { DEFAULT_SPEAK_SPEED, MAX_SPEAK_SPEED, MIN_SPEAK_SPEED, MemoryAssetStore, createAudioData, createSilentAudioData, createStaticCatalog, createTTSRuntime, getModelAssets, getModelSizeBytes, isExternalInstallState, isInstallStateAvailable, isManagedInstallState, mergeCatalogs, normalizeSpeakSpeed, pcmToAudioData, resolveAdapterCapabilities, resolveCatalogSource, resolveInstallState, resolveModelDistribution, validateCatalog };
|