@pie-players/pie-section-player-tools-tts-settings 0.3.43 → 0.3.44
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/dist/index.d.ts +0 -1
- package/dist/vite.config.d.ts +0 -1
- package/package.json +4 -7
- package/TtsSettingsPanel.svelte +0 -2173
- package/dist/index.d.ts.map +0 -1
- package/dist/vite.config.d.ts.map +0 -1
package/TtsSettingsPanel.svelte
DELETED
|
@@ -1,2173 +0,0 @@
|
|
|
1
|
-
<svelte:options
|
|
2
|
-
customElement={{
|
|
3
|
-
tag: "pie-section-player-tools-tts-settings",
|
|
4
|
-
shadow: "none",
|
|
5
|
-
props: {
|
|
6
|
-
toolkitCoordinator: { type: "Object", attribute: "toolkit-coordinator" },
|
|
7
|
-
apiEndpoint: { type: "String", attribute: "api-endpoint" },
|
|
8
|
-
storageKey: { type: "String", attribute: "storage-key" },
|
|
9
|
-
adapters: { type: "Object", attribute: "adapters" },
|
|
10
|
-
customProviders: { type: "Object", attribute: "custom-providers" }
|
|
11
|
-
}
|
|
12
|
-
}}
|
|
13
|
-
/>
|
|
14
|
-
|
|
15
|
-
<script lang="ts">
|
|
16
|
-
import "@pie-players/pie-theme/components.css";
|
|
17
|
-
import {
|
|
18
|
-
DEFAULT_TTS_SPEED_OPTIONS,
|
|
19
|
-
formatTTSSpeedOptionsAsText,
|
|
20
|
-
normalizeTTSSpeedOptions,
|
|
21
|
-
parseTTSSpeedOptionsFromText,
|
|
22
|
-
resolveTTSRuntimeSettings,
|
|
23
|
-
type TTSLayoutMode,
|
|
24
|
-
} from "@pie-players/pie-assessment-toolkit";
|
|
25
|
-
import { createEventDispatcher, onDestroy, onMount, untrack } from "svelte";
|
|
26
|
-
|
|
27
|
-
type BuiltInBackendTab = "browser" | "polly" | "google";
|
|
28
|
-
type BackendTab = BuiltInBackendTab | string;
|
|
29
|
-
type PreviewMode = "plain" | "ssml";
|
|
30
|
-
type PollyFormat = "mp3" | "ogg" | "pcm";
|
|
31
|
-
type PollySpeechMarksMode = "word" | "word+sentence";
|
|
32
|
-
|
|
33
|
-
type DemoVoice = {
|
|
34
|
-
id?: string;
|
|
35
|
-
name?: string;
|
|
36
|
-
languageCode?: string;
|
|
37
|
-
gender?: string;
|
|
38
|
-
quality?: string;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
type AvailabilityState = {
|
|
42
|
-
checked: boolean;
|
|
43
|
-
loading: boolean;
|
|
44
|
-
available: boolean;
|
|
45
|
-
message: string | null;
|
|
46
|
-
detail: string | null;
|
|
47
|
-
voices: DemoVoice[];
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
type SynthesizeProbeResponse = {
|
|
51
|
-
audio: string;
|
|
52
|
-
contentType?: string;
|
|
53
|
-
speechMarks?: Array<{ time: number; start: number; end: number }>;
|
|
54
|
-
};
|
|
55
|
-
type PreviewSpeechMark = { time: number; start: number; end: number; value?: string };
|
|
56
|
-
type CustomProviderPreviewResult = {
|
|
57
|
-
note?: string;
|
|
58
|
-
audioUrl?: string;
|
|
59
|
-
speechMarks?: PreviewSpeechMark[];
|
|
60
|
-
trackingText?: string;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
type TtsSettingsAdapters = {
|
|
64
|
-
fetchPollyVoices?: (args: {
|
|
65
|
-
endpoint: string;
|
|
66
|
-
language: string;
|
|
67
|
-
gender: string;
|
|
68
|
-
engine: "standard" | "neural";
|
|
69
|
-
url: URL;
|
|
70
|
-
}) => Promise<DemoVoice[]>;
|
|
71
|
-
fetchGoogleVoices?: (args: {
|
|
72
|
-
endpoint: string;
|
|
73
|
-
language: string;
|
|
74
|
-
gender: string;
|
|
75
|
-
voiceType: string;
|
|
76
|
-
url: URL;
|
|
77
|
-
}) => Promise<DemoVoice[]>;
|
|
78
|
-
synthesizeProbe?: (args: {
|
|
79
|
-
endpoint: string;
|
|
80
|
-
provider: "polly" | "google";
|
|
81
|
-
body: Record<string, unknown>;
|
|
82
|
-
}) => Promise<SynthesizeProbeResponse>;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
type ProviderAvailabilityResult = {
|
|
86
|
-
available: boolean;
|
|
87
|
-
message?: string | null;
|
|
88
|
-
detail?: string | null;
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
type ProviderApplyResult = {
|
|
92
|
-
config: Record<string, unknown>;
|
|
93
|
-
message?: string;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
type CustomProviderContext = {
|
|
97
|
-
id: string;
|
|
98
|
-
toolkitCoordinator: any;
|
|
99
|
-
apiEndpoint: string;
|
|
100
|
-
state: Record<string, unknown>;
|
|
101
|
-
previewText?: string;
|
|
102
|
-
previewMode?: PreviewMode;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
type CustomProviderAdapter = {
|
|
106
|
-
id: string;
|
|
107
|
-
label: string;
|
|
108
|
-
description?: string;
|
|
109
|
-
mode: "adapter";
|
|
110
|
-
checkAvailability?: (
|
|
111
|
-
context: CustomProviderContext
|
|
112
|
-
) => ProviderAvailabilityResult | Promise<ProviderAvailabilityResult>;
|
|
113
|
-
buildApplyConfig: (
|
|
114
|
-
context: CustomProviderContext
|
|
115
|
-
) => ProviderApplyResult | Promise<ProviderApplyResult>;
|
|
116
|
-
preview?: (context: CustomProviderContext) => Promise<void | CustomProviderPreviewResult>;
|
|
117
|
-
initialState?: Record<string, unknown>;
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
type CustomProviderComponent = {
|
|
121
|
-
id: string;
|
|
122
|
-
label: string;
|
|
123
|
-
description?: string;
|
|
124
|
-
mode: "component";
|
|
125
|
-
tagName: string;
|
|
126
|
-
componentProps?: Record<string, unknown>;
|
|
127
|
-
checkAvailability?: (
|
|
128
|
-
context: CustomProviderContext
|
|
129
|
-
) => ProviderAvailabilityResult | Promise<ProviderAvailabilityResult>;
|
|
130
|
-
buildApplyConfig?: (
|
|
131
|
-
context: CustomProviderContext
|
|
132
|
-
) => ProviderApplyResult | Promise<ProviderApplyResult>;
|
|
133
|
-
preview?: (context: CustomProviderContext) => Promise<void | CustomProviderPreviewResult>;
|
|
134
|
-
initialState?: Record<string, unknown>;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
type CustomProviderDescriptor = CustomProviderAdapter | CustomProviderComponent;
|
|
138
|
-
|
|
139
|
-
const dispatch = createEventDispatcher<{ close: undefined }>();
|
|
140
|
-
const DEFAULT_API_ENDPOINT = "/api/tts";
|
|
141
|
-
const DEFAULT_STORAGE_KEY = "pie:section-player-tools:tts-settings";
|
|
142
|
-
const TTS_MODAL_Z_INDEX = 200000;
|
|
143
|
-
|
|
144
|
-
let {
|
|
145
|
-
toolkitCoordinator = null,
|
|
146
|
-
apiEndpoint = "/api/tts",
|
|
147
|
-
storageKey = DEFAULT_STORAGE_KEY,
|
|
148
|
-
adapters = {},
|
|
149
|
-
customProviders = []
|
|
150
|
-
}: {
|
|
151
|
-
toolkitCoordinator?: any;
|
|
152
|
-
apiEndpoint?: string;
|
|
153
|
-
storageKey?: string;
|
|
154
|
-
adapters?: TtsSettingsAdapters;
|
|
155
|
-
customProviders?: CustomProviderDescriptor[];
|
|
156
|
-
} = $props();
|
|
157
|
-
|
|
158
|
-
type PersistedTTSSettings = {
|
|
159
|
-
backend?: string;
|
|
160
|
-
apiEndpoint?: string;
|
|
161
|
-
defaultVoice?: string;
|
|
162
|
-
rate?: number;
|
|
163
|
-
pitch?: number;
|
|
164
|
-
language?: string;
|
|
165
|
-
transportMode?: "pie" | "custom";
|
|
166
|
-
endpointMode?: "synthesizePath" | "rootPost";
|
|
167
|
-
endpointValidationMode?: "voices" | "endpoint" | "none";
|
|
168
|
-
engine?: "standard" | "neural";
|
|
169
|
-
sampleRate?: number;
|
|
170
|
-
format?: PollyFormat;
|
|
171
|
-
speechMarksMode?: PollySpeechMarksMode;
|
|
172
|
-
googleVoiceType?: string;
|
|
173
|
-
googleGender?: string;
|
|
174
|
-
providerOptions?: Record<string, unknown>;
|
|
175
|
-
layoutMode?: TTSLayoutMode;
|
|
176
|
-
/** Inline toolbar speed multipliers; `[]` hides speed buttons. */
|
|
177
|
-
speedOptions?: number[];
|
|
178
|
-
[key: string]: unknown;
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
let activeTab = $state<BackendTab>("browser");
|
|
182
|
-
let applyMessage = $state<string | null>(null);
|
|
183
|
-
let applyError = $state<string | null>(null);
|
|
184
|
-
let isApplying = $state(false);
|
|
185
|
-
let isPreviewing = $state(false);
|
|
186
|
-
let previewError = $state<string | null>(null);
|
|
187
|
-
let previewBackend = $state<BackendTab | null>(null);
|
|
188
|
-
let previewMode = $state<PreviewMode>("plain");
|
|
189
|
-
let previewText = $state("");
|
|
190
|
-
let previewNote = $state<string | null>(null);
|
|
191
|
-
let previewTrackIndex = $state<number>(-1);
|
|
192
|
-
let previewTrackLength = $state<number>(0);
|
|
193
|
-
let customProviderStateById = $state<Record<string, Record<string, unknown>>>({});
|
|
194
|
-
let customProviderApplyRequestById = $state<Record<string, ProviderApplyResult | undefined>>({});
|
|
195
|
-
let customProviderAvailabilityById = $state<Record<string, AvailabilityState>>({});
|
|
196
|
-
|
|
197
|
-
let browserVoice = $state("");
|
|
198
|
-
let browserRate = $state(1);
|
|
199
|
-
let browserPitch = $state(1);
|
|
200
|
-
let layoutMode = $state<TTSLayoutMode>("left-aligned");
|
|
201
|
-
let speedOptionsText = $state("");
|
|
202
|
-
|
|
203
|
-
let pollyApiEndpoint = $state("");
|
|
204
|
-
let pollyLanguage = $state("en-US");
|
|
205
|
-
let pollyGender = $state("");
|
|
206
|
-
let pollyEngine = $state<"standard" | "neural">("neural");
|
|
207
|
-
let pollySampleRate = $state(24000);
|
|
208
|
-
let pollyFormat = $state<PollyFormat>("mp3");
|
|
209
|
-
let pollySpeechMarksMode = $state<PollySpeechMarksMode>("word");
|
|
210
|
-
let pollyVoice = $state("");
|
|
211
|
-
let pollyRate = $state(1);
|
|
212
|
-
|
|
213
|
-
let googleApiEndpoint = $state("");
|
|
214
|
-
let googleLanguage = $state("en-US");
|
|
215
|
-
let googleGender = $state("");
|
|
216
|
-
let googleVoiceType = $state("wavenet");
|
|
217
|
-
let googleVoice = $state("");
|
|
218
|
-
let googleRate = $state(1);
|
|
219
|
-
|
|
220
|
-
let browserState = $state<AvailabilityState>({
|
|
221
|
-
checked: false,
|
|
222
|
-
loading: false,
|
|
223
|
-
available: false,
|
|
224
|
-
message: null,
|
|
225
|
-
detail: null,
|
|
226
|
-
voices: []
|
|
227
|
-
});
|
|
228
|
-
let pollyState = $state<AvailabilityState>({
|
|
229
|
-
checked: false,
|
|
230
|
-
loading: false,
|
|
231
|
-
available: false,
|
|
232
|
-
message: null,
|
|
233
|
-
detail: null,
|
|
234
|
-
voices: []
|
|
235
|
-
});
|
|
236
|
-
let googleState = $state<AvailabilityState>({
|
|
237
|
-
checked: false,
|
|
238
|
-
loading: false,
|
|
239
|
-
available: false,
|
|
240
|
-
message: null,
|
|
241
|
-
detail: null,
|
|
242
|
-
voices: []
|
|
243
|
-
});
|
|
244
|
-
let currentPreviewAudio: HTMLAudioElement | null = null;
|
|
245
|
-
let previewPollingTimer: number | null = null;
|
|
246
|
-
let previewRunId = 0;
|
|
247
|
-
let activeCustomProviderElement = $state<Element | null>(null);
|
|
248
|
-
let dialogEl = $state<HTMLElement | null>(null);
|
|
249
|
-
let closeButtonEl = $state<HTMLButtonElement | null>(null);
|
|
250
|
-
let cleanupFocusTrap: (() => void) | null = null;
|
|
251
|
-
|
|
252
|
-
function createLocalFocusTrap(
|
|
253
|
-
container: HTMLElement,
|
|
254
|
-
options: { initialFocus?: HTMLElement | null; onEscape?: () => void } = {}
|
|
255
|
-
): () => void {
|
|
256
|
-
const focusable = () =>
|
|
257
|
-
Array.from(
|
|
258
|
-
container.querySelectorAll<HTMLElement>(
|
|
259
|
-
"button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
|
|
260
|
-
),
|
|
261
|
-
).filter((el) => el.offsetParent !== null || el.getClientRects().length > 0);
|
|
262
|
-
const previous = document.activeElement as HTMLElement | null;
|
|
263
|
-
const onKeyDown = (event: KeyboardEvent) => {
|
|
264
|
-
if (event.key === "Escape") {
|
|
265
|
-
options.onEscape?.();
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
if (event.key !== "Tab") return;
|
|
269
|
-
const nodes = focusable();
|
|
270
|
-
if (!nodes.length) return;
|
|
271
|
-
const current = document.activeElement as HTMLElement | null;
|
|
272
|
-
const currentIndex = nodes.indexOf(current || nodes[0]);
|
|
273
|
-
if (event.shiftKey && currentIndex <= 0) {
|
|
274
|
-
event.preventDefault();
|
|
275
|
-
nodes[nodes.length - 1].focus();
|
|
276
|
-
} else if (!event.shiftKey && currentIndex === nodes.length - 1) {
|
|
277
|
-
event.preventDefault();
|
|
278
|
-
nodes[0].focus();
|
|
279
|
-
}
|
|
280
|
-
};
|
|
281
|
-
container.addEventListener("keydown", onKeyDown);
|
|
282
|
-
queueMicrotask(() => {
|
|
283
|
-
(options.initialFocus || focusable()[0] || container)?.focus?.();
|
|
284
|
-
});
|
|
285
|
-
return () => {
|
|
286
|
-
container.removeEventListener("keydown", onKeyDown);
|
|
287
|
-
previous?.focus?.();
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const DEFAULT_PREVIEW_TEXT: Record<BackendTab, string> = {
|
|
292
|
-
browser:
|
|
293
|
-
"This is a browser voice sample. You should hear clear playback and see tracking updates.",
|
|
294
|
-
polly: "This is an AWS Polly voice sample. You should hear this text and see tracking updates.",
|
|
295
|
-
google:
|
|
296
|
-
"This is a Google Cloud TTS voice sample. You should hear this text and see tracking updates."
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
const DEFAULT_PREVIEW_SSML: Record<Exclude<BackendTab, "browser">, string> = {
|
|
300
|
-
polly:
|
|
301
|
-
'<speak>This is an AWS Polly SSML sample. <break time="300ms"/> The voice should honor this markup.</speak>',
|
|
302
|
-
google:
|
|
303
|
-
'<speak>This is a <prosody rate="95%">Google Cloud SSML sample</prosody>. <break time="250ms"/> The preview preserves authored SSML.</speak>'
|
|
304
|
-
};
|
|
305
|
-
const BUILT_IN_TABS: BuiltInBackendTab[] = ["browser", "polly", "google"];
|
|
306
|
-
const PREVIEW_DEBUG_PREFIX = "[pie-tts-preview]";
|
|
307
|
-
const TTS_LAYOUT_MODES: readonly TTSLayoutMode[] = [
|
|
308
|
-
"reserved-row",
|
|
309
|
-
"expanding-row",
|
|
310
|
-
"floating-overlay",
|
|
311
|
-
"left-aligned",
|
|
312
|
-
];
|
|
313
|
-
|
|
314
|
-
function debugPreview(event: string, payload?: Record<string, unknown>): void {
|
|
315
|
-
if (typeof console === "undefined") return;
|
|
316
|
-
if (payload) {
|
|
317
|
-
console.debug(`${PREVIEW_DEBUG_PREFIX} ${event}`, payload);
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
console.debug(`${PREVIEW_DEBUG_PREFIX} ${event}`);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function normalizeLayoutMode(value: unknown): TTSLayoutMode {
|
|
324
|
-
return TTS_LAYOUT_MODES.includes(value as TTSLayoutMode)
|
|
325
|
-
? (value as TTSLayoutMode)
|
|
326
|
-
: "left-aligned";
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function resetInlineSpeedOptionsToDefaults(): void {
|
|
330
|
-
speedOptionsText = formatTTSSpeedOptionsAsText([...DEFAULT_TTS_SPEED_OPTIONS]);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const normalizedCustomProviders = $derived.by(() => {
|
|
334
|
-
const reserved = new Set<string>(BUILT_IN_TABS);
|
|
335
|
-
const deduped: CustomProviderDescriptor[] = [];
|
|
336
|
-
const seen = new Set<string>();
|
|
337
|
-
for (const provider of customProviders || []) {
|
|
338
|
-
if (!provider || typeof provider !== "object") continue;
|
|
339
|
-
const id = String(provider.id || "").trim();
|
|
340
|
-
if (!id || reserved.has(id) || seen.has(id)) continue;
|
|
341
|
-
seen.add(id);
|
|
342
|
-
deduped.push(provider);
|
|
343
|
-
}
|
|
344
|
-
return deduped;
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
const providerTabs = $derived.by(() => [
|
|
348
|
-
{ id: "browser", label: "Browser" },
|
|
349
|
-
{ id: "polly", label: "Polly" },
|
|
350
|
-
{ id: "google", label: "Google" },
|
|
351
|
-
...normalizedCustomProviders.map((provider) => ({
|
|
352
|
-
id: provider.id,
|
|
353
|
-
label: provider.label
|
|
354
|
-
}))
|
|
355
|
-
]);
|
|
356
|
-
|
|
357
|
-
const activeCustomProvider = $derived.by(
|
|
358
|
-
() => normalizedCustomProviders.find((provider) => provider.id === activeTab) || null
|
|
359
|
-
);
|
|
360
|
-
|
|
361
|
-
function requestClose(): void {
|
|
362
|
-
dispatch("close");
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function createProviderContext(
|
|
366
|
-
providerId: string,
|
|
367
|
-
overrides: Partial<CustomProviderContext> = {}
|
|
368
|
-
): CustomProviderContext {
|
|
369
|
-
return {
|
|
370
|
-
id: providerId,
|
|
371
|
-
toolkitCoordinator,
|
|
372
|
-
apiEndpoint: getDefaultApiEndpoint(),
|
|
373
|
-
state: customProviderStateById[providerId] || {},
|
|
374
|
-
...overrides
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function isBuiltInTab(tab: BackendTab): tab is BuiltInBackendTab {
|
|
379
|
-
return tab === "browser" || tab === "polly" || tab === "google";
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function buildAvailabilityState(result?: ProviderAvailabilityResult | null): AvailabilityState {
|
|
383
|
-
const available = result?.available === true;
|
|
384
|
-
return {
|
|
385
|
-
checked: true,
|
|
386
|
-
loading: false,
|
|
387
|
-
available,
|
|
388
|
-
message: result?.message || (available ? "Provider available." : "Provider unavailable."),
|
|
389
|
-
detail: result?.detail || null,
|
|
390
|
-
voices: []
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function getCustomProviderOrThrow(providerId: string): CustomProviderDescriptor {
|
|
395
|
-
const provider = normalizedCustomProviders.find((entry) => entry.id === providerId);
|
|
396
|
-
if (!provider) {
|
|
397
|
-
throw new Error(`Custom provider '${providerId}' is not registered.`);
|
|
398
|
-
}
|
|
399
|
-
return provider;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
async function checkCustomProviderAvailability(providerId: string): Promise<void> {
|
|
403
|
-
const provider = getCustomProviderOrThrow(providerId);
|
|
404
|
-
const loading: AvailabilityState = {
|
|
405
|
-
checked: true,
|
|
406
|
-
loading: true,
|
|
407
|
-
available: false,
|
|
408
|
-
message: null,
|
|
409
|
-
detail: null,
|
|
410
|
-
voices: []
|
|
411
|
-
};
|
|
412
|
-
customProviderAvailabilityById = { ...customProviderAvailabilityById, [provider.id]: loading };
|
|
413
|
-
try {
|
|
414
|
-
const result = await provider.checkAvailability?.(createProviderContext(provider.id));
|
|
415
|
-
customProviderAvailabilityById = {
|
|
416
|
-
...customProviderAvailabilityById,
|
|
417
|
-
[provider.id]: buildAvailabilityState(result || { available: true, message: "Provider available." })
|
|
418
|
-
};
|
|
419
|
-
} catch (error) {
|
|
420
|
-
customProviderAvailabilityById = {
|
|
421
|
-
...customProviderAvailabilityById,
|
|
422
|
-
[provider.id]: {
|
|
423
|
-
checked: true,
|
|
424
|
-
loading: false,
|
|
425
|
-
available: false,
|
|
426
|
-
message: "Provider availability check failed.",
|
|
427
|
-
detail: error instanceof Error ? error.message : String(error),
|
|
428
|
-
voices: []
|
|
429
|
-
}
|
|
430
|
-
};
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function updateCustomProviderState(providerId: string, patch: Record<string, unknown>): void {
|
|
435
|
-
const previous = customProviderStateById[providerId] || {};
|
|
436
|
-
customProviderStateById = {
|
|
437
|
-
...customProviderStateById,
|
|
438
|
-
[providerId]: { ...previous, ...patch }
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
function onCustomProviderChange(providerId: string, event: CustomEvent): void {
|
|
443
|
-
const payload = (event?.detail || {}) as { state?: Record<string, unknown> };
|
|
444
|
-
if (!payload.state || typeof payload.state !== "object") return;
|
|
445
|
-
updateCustomProviderState(providerId, payload.state);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function onCustomProviderAvailability(providerId: string, event: CustomEvent): void {
|
|
449
|
-
const payload = (event?.detail || {}) as ProviderAvailabilityResult;
|
|
450
|
-
customProviderAvailabilityById = {
|
|
451
|
-
...customProviderAvailabilityById,
|
|
452
|
-
[providerId]: buildAvailabilityState(payload)
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function resolveComponentProps(provider: CustomProviderComponent): Record<string, unknown> {
|
|
457
|
-
return {
|
|
458
|
-
toolkitCoordinator,
|
|
459
|
-
apiEndpoint: getDefaultApiEndpoint(),
|
|
460
|
-
state: customProviderStateById[provider.id] || {},
|
|
461
|
-
...(provider.componentProps || {})
|
|
462
|
-
};
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
async function onCustomProviderApplyRequest(providerId: string, event: CustomEvent): Promise<void> {
|
|
466
|
-
const payload = (event?.detail || {}) as ProviderApplyResult;
|
|
467
|
-
if (!payload || typeof payload !== "object" || !payload.config) return;
|
|
468
|
-
customProviderApplyRequestById = {
|
|
469
|
-
...customProviderApplyRequestById,
|
|
470
|
-
[providerId]: payload
|
|
471
|
-
};
|
|
472
|
-
await applySettings();
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
async function onCustomProviderPreviewRequest(providerId: string): Promise<void> {
|
|
476
|
-
if (activeTab !== providerId) {
|
|
477
|
-
setActiveTab(providerId);
|
|
478
|
-
}
|
|
479
|
-
await previewSelectedVoice();
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
function normalizeRate(value: number): number {
|
|
483
|
-
const next = Number(value);
|
|
484
|
-
if (!Number.isFinite(next)) return 1;
|
|
485
|
-
return Math.max(0.25, Math.min(4, next));
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
function normalizePitch(value: number): number {
|
|
489
|
-
const next = Number(value);
|
|
490
|
-
if (!Number.isFinite(next)) return 1;
|
|
491
|
-
return Math.max(0, Math.min(2, next));
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function normalizePollySampleRate(value: number): number {
|
|
495
|
-
const allowed = [8000, 16000, 22050, 24000];
|
|
496
|
-
const next = Number(value);
|
|
497
|
-
if (!Number.isFinite(next)) return 24000;
|
|
498
|
-
return allowed.includes(next) ? next : 24000;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function getPollySpeechMarkTypes(): Array<"word" | "sentence"> {
|
|
502
|
-
return pollySpeechMarksMode === "word+sentence" ? ["word", "sentence"] : ["word"];
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
function getStorageKey(): string {
|
|
506
|
-
const trimmed = String(storageKey || "").trim();
|
|
507
|
-
return trimmed || DEFAULT_STORAGE_KEY;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
function initializeFromCoordinator() {
|
|
511
|
-
const existing = toolkitCoordinator?.getToolConfig?.("textToSpeech") || {};
|
|
512
|
-
const stored = readStoredSettings();
|
|
513
|
-
const source = stored ? { ...existing, ...stored } : existing;
|
|
514
|
-
const resolvedDefaultApiEndpoint = getDefaultApiEndpoint();
|
|
515
|
-
const backend = source?.backend;
|
|
516
|
-
if (typeof backend === "string" && backend.trim().length > 0) {
|
|
517
|
-
activeTab = backend;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
const defaultVoice = typeof source?.defaultVoice === "string" ? source.defaultVoice : "";
|
|
521
|
-
const defaultRate = normalizeRate(Number(source?.rate ?? 1));
|
|
522
|
-
const defaultPitch = normalizePitch(Number(source?.pitch ?? 1));
|
|
523
|
-
const defaultEndpoint =
|
|
524
|
-
typeof source?.apiEndpoint === "string" && source.apiEndpoint.trim().length > 0
|
|
525
|
-
? source.apiEndpoint
|
|
526
|
-
: resolvedDefaultApiEndpoint;
|
|
527
|
-
const defaultPollyEndpoint =
|
|
528
|
-
backend === "polly" ? defaultEndpoint : resolvedDefaultApiEndpoint;
|
|
529
|
-
const defaultGoogleEndpoint =
|
|
530
|
-
backend === "google" ? defaultEndpoint : resolvedDefaultApiEndpoint;
|
|
531
|
-
const defaultLanguage =
|
|
532
|
-
typeof source?.language === "string" && source.language.trim().length > 0
|
|
533
|
-
? source.language
|
|
534
|
-
: "en-US";
|
|
535
|
-
const defaultEngine =
|
|
536
|
-
source?.engine === "standard"
|
|
537
|
-
? "standard"
|
|
538
|
-
: source?.engine === "neural"
|
|
539
|
-
? "neural"
|
|
540
|
-
: source?.quality === "standard"
|
|
541
|
-
? "standard"
|
|
542
|
-
: "neural";
|
|
543
|
-
const sourceProviderOptions = (source?.providerOptions || {}) as Record<string, unknown>;
|
|
544
|
-
layoutMode = normalizeLayoutMode(source?.layoutMode);
|
|
545
|
-
const runtimeForSpeed = resolveTTSRuntimeSettings(
|
|
546
|
-
source && typeof source === "object" ? (source as Record<string, unknown>) : undefined,
|
|
547
|
-
);
|
|
548
|
-
if (runtimeForSpeed.speedOptions === undefined) {
|
|
549
|
-
speedOptionsText = formatTTSSpeedOptionsAsText([...DEFAULT_TTS_SPEED_OPTIONS]);
|
|
550
|
-
} else if (
|
|
551
|
-
Array.isArray(runtimeForSpeed.speedOptions) &&
|
|
552
|
-
runtimeForSpeed.speedOptions.length === 0
|
|
553
|
-
) {
|
|
554
|
-
speedOptionsText = "";
|
|
555
|
-
} else {
|
|
556
|
-
speedOptionsText = formatTTSSpeedOptionsAsText(
|
|
557
|
-
normalizeTTSSpeedOptions(runtimeForSpeed.speedOptions),
|
|
558
|
-
);
|
|
559
|
-
}
|
|
560
|
-
const defaultSampleRate = normalizePollySampleRate(
|
|
561
|
-
Number(source?.sampleRate ?? sourceProviderOptions.sampleRate ?? 24000)
|
|
562
|
-
);
|
|
563
|
-
const defaultFormat =
|
|
564
|
-
source?.format === "ogg" || source?.format === "pcm" || source?.format === "mp3"
|
|
565
|
-
? source.format
|
|
566
|
-
: sourceProviderOptions.format === "ogg" ||
|
|
567
|
-
sourceProviderOptions.format === "pcm" ||
|
|
568
|
-
sourceProviderOptions.format === "mp3"
|
|
569
|
-
? (sourceProviderOptions.format as PollyFormat)
|
|
570
|
-
: "mp3";
|
|
571
|
-
const sourceSpeechMarkTypes = Array.isArray(sourceProviderOptions.speechMarkTypes)
|
|
572
|
-
? sourceProviderOptions.speechMarkTypes
|
|
573
|
-
: [];
|
|
574
|
-
const defaultSpeechMarksMode: PollySpeechMarksMode = sourceSpeechMarkTypes.includes(
|
|
575
|
-
"sentence"
|
|
576
|
-
)
|
|
577
|
-
? "word+sentence"
|
|
578
|
-
: source?.speechMarksMode === "word+sentence"
|
|
579
|
-
? "word+sentence"
|
|
580
|
-
: "word";
|
|
581
|
-
|
|
582
|
-
browserVoice = backend === "browser" ? defaultVoice : "";
|
|
583
|
-
browserRate = defaultRate;
|
|
584
|
-
browserPitch = defaultPitch;
|
|
585
|
-
|
|
586
|
-
pollyApiEndpoint = defaultPollyEndpoint;
|
|
587
|
-
pollyLanguage = defaultLanguage;
|
|
588
|
-
pollyEngine = defaultEngine;
|
|
589
|
-
pollySampleRate = defaultSampleRate;
|
|
590
|
-
pollyFormat = defaultFormat;
|
|
591
|
-
pollySpeechMarksMode = defaultSpeechMarksMode;
|
|
592
|
-
pollyVoice = backend === "polly" ? defaultVoice : "";
|
|
593
|
-
pollyRate = defaultRate;
|
|
594
|
-
|
|
595
|
-
googleApiEndpoint = defaultGoogleEndpoint;
|
|
596
|
-
googleLanguage = defaultLanguage;
|
|
597
|
-
googleGender = typeof source?.googleGender === "string" ? source.googleGender : "";
|
|
598
|
-
googleVoiceType =
|
|
599
|
-
source?.googleVoiceType === "standard" ||
|
|
600
|
-
source?.googleVoiceType === "studio" ||
|
|
601
|
-
source?.googleVoiceType === "wavenet"
|
|
602
|
-
? source.googleVoiceType
|
|
603
|
-
: "wavenet";
|
|
604
|
-
googleVoice = backend === "google" ? defaultVoice : "";
|
|
605
|
-
googleRate = defaultRate;
|
|
606
|
-
if (!isBuiltInTab(activeTab)) {
|
|
607
|
-
const persistedCustomState =
|
|
608
|
-
sourceProviderOptions && typeof sourceProviderOptions === "object"
|
|
609
|
-
? sourceProviderOptions
|
|
610
|
-
: {};
|
|
611
|
-
if (Object.keys(persistedCustomState).length > 0) {
|
|
612
|
-
updateCustomProviderState(activeTab, persistedCustomState);
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
setPreviewTextForCurrentTab();
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
const layoutModeReservesRow = $derived(layoutMode === "reserved-row");
|
|
619
|
-
|
|
620
|
-
function sameRecordEntries<T>(left: Record<string, T>, right: Record<string, T>): boolean {
|
|
621
|
-
const leftKeys = Object.keys(left);
|
|
622
|
-
const rightKeys = Object.keys(right);
|
|
623
|
-
if (leftKeys.length !== rightKeys.length) return false;
|
|
624
|
-
for (const key of leftKeys) {
|
|
625
|
-
if (!(key in right)) return false;
|
|
626
|
-
if (left[key] !== right[key]) return false;
|
|
627
|
-
}
|
|
628
|
-
return true;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
function syncCustomProvidersState(): void {
|
|
632
|
-
const nextState: Record<string, Record<string, unknown>> = {};
|
|
633
|
-
const nextAvailability: Record<string, AvailabilityState> = {};
|
|
634
|
-
for (const provider of normalizedCustomProviders) {
|
|
635
|
-
nextState[provider.id] = customProviderStateById[provider.id] || provider.initialState || {};
|
|
636
|
-
nextAvailability[provider.id] =
|
|
637
|
-
customProviderAvailabilityById[provider.id] || {
|
|
638
|
-
checked: false,
|
|
639
|
-
loading: false,
|
|
640
|
-
available: false,
|
|
641
|
-
message: null,
|
|
642
|
-
detail: null,
|
|
643
|
-
voices: []
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
const nextApplyRequests = Object.fromEntries(
|
|
647
|
-
Object.entries(customProviderApplyRequestById).filter(([id]) => Boolean(nextState[id]))
|
|
648
|
-
);
|
|
649
|
-
if (!sameRecordEntries(customProviderStateById, nextState)) {
|
|
650
|
-
customProviderStateById = nextState;
|
|
651
|
-
}
|
|
652
|
-
if (!sameRecordEntries(customProviderAvailabilityById, nextAvailability)) {
|
|
653
|
-
customProviderAvailabilityById = nextAvailability;
|
|
654
|
-
}
|
|
655
|
-
if (!sameRecordEntries(customProviderApplyRequestById, nextApplyRequests)) {
|
|
656
|
-
customProviderApplyRequestById = nextApplyRequests;
|
|
657
|
-
}
|
|
658
|
-
if (!isBuiltInTab(activeTab) && !nextState[activeTab]) {
|
|
659
|
-
activeTab = "browser";
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
function readStoredSettings(): PersistedTTSSettings | null {
|
|
664
|
-
if (typeof window === "undefined") return null;
|
|
665
|
-
try {
|
|
666
|
-
const raw = window.localStorage.getItem(getStorageKey());
|
|
667
|
-
if (!raw) return null;
|
|
668
|
-
const parsed = JSON.parse(raw) as PersistedTTSSettings;
|
|
669
|
-
if (!parsed || typeof parsed !== "object") return null;
|
|
670
|
-
return parsed;
|
|
671
|
-
} catch {
|
|
672
|
-
return null;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
function persistSettings(settings: PersistedTTSSettings): void {
|
|
677
|
-
if (typeof window === "undefined") return;
|
|
678
|
-
try {
|
|
679
|
-
window.localStorage.setItem(getStorageKey(), JSON.stringify(settings));
|
|
680
|
-
} catch {
|
|
681
|
-
// Ignore persistence errors (e.g., private mode or storage quota).
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
async function checkBrowserAvailability() {
|
|
686
|
-
browserState = { ...browserState, checked: true, loading: true, message: null, detail: null };
|
|
687
|
-
try {
|
|
688
|
-
if (typeof window === "undefined" || !("speechSynthesis" in window)) {
|
|
689
|
-
throw new Error("Web Speech API is not available in this browser.");
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
const synth = window.speechSynthesis;
|
|
693
|
-
let voices = synth.getVoices();
|
|
694
|
-
if (!voices.length) {
|
|
695
|
-
await new Promise<void>((resolve) => {
|
|
696
|
-
let settled = false;
|
|
697
|
-
const finish = () => {
|
|
698
|
-
if (settled) return;
|
|
699
|
-
settled = true;
|
|
700
|
-
resolve();
|
|
701
|
-
};
|
|
702
|
-
const timeout = window.setTimeout(finish, 1200);
|
|
703
|
-
synth.addEventListener(
|
|
704
|
-
"voiceschanged",
|
|
705
|
-
() => {
|
|
706
|
-
window.clearTimeout(timeout);
|
|
707
|
-
finish();
|
|
708
|
-
},
|
|
709
|
-
{ once: true }
|
|
710
|
-
);
|
|
711
|
-
});
|
|
712
|
-
voices = synth.getVoices();
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
const mappedVoices = voices.map((voice) => ({
|
|
716
|
-
id: voice.voiceURI || voice.name,
|
|
717
|
-
name: voice.name,
|
|
718
|
-
languageCode: voice.lang
|
|
719
|
-
}));
|
|
720
|
-
if (browserVoice) {
|
|
721
|
-
const hasVoice = mappedVoices.some((voice) => (voice.name || "") === browserVoice);
|
|
722
|
-
if (!hasVoice) {
|
|
723
|
-
browserVoice = "";
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
browserState = {
|
|
728
|
-
checked: true,
|
|
729
|
-
loading: false,
|
|
730
|
-
available: true,
|
|
731
|
-
message: voices.length
|
|
732
|
-
? `Browser TTS available (${voices.length} voices detected).`
|
|
733
|
-
: "Browser TTS is available, but no voices were returned yet.",
|
|
734
|
-
detail: null,
|
|
735
|
-
voices: mappedVoices
|
|
736
|
-
};
|
|
737
|
-
} catch (error) {
|
|
738
|
-
browserState = {
|
|
739
|
-
checked: true,
|
|
740
|
-
loading: false,
|
|
741
|
-
available: false,
|
|
742
|
-
message: "Browser TTS is not available.",
|
|
743
|
-
detail: error instanceof Error ? error.message : String(error),
|
|
744
|
-
voices: []
|
|
745
|
-
};
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
async function readJsonSafe(response: Response): Promise<any> {
|
|
750
|
-
try {
|
|
751
|
-
return await response.json();
|
|
752
|
-
} catch {
|
|
753
|
-
return {};
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
function buildPollyVoicesUrl() {
|
|
758
|
-
const baseUrl = new URL(
|
|
759
|
-
normalizeApiEndpoint(pollyApiEndpoint, getDefaultApiEndpoint()),
|
|
760
|
-
window.location.origin
|
|
761
|
-
);
|
|
762
|
-
baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/, "")}/polly/voices`;
|
|
763
|
-
const url = baseUrl;
|
|
764
|
-
if (pollyLanguage) url.searchParams.set("language", pollyLanguage);
|
|
765
|
-
if (pollyGender) url.searchParams.set("gender", pollyGender);
|
|
766
|
-
if (pollyEngine) url.searchParams.set("engine", pollyEngine);
|
|
767
|
-
return url;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
function buildGoogleVoicesUrl() {
|
|
771
|
-
const baseUrl = new URL(
|
|
772
|
-
normalizeApiEndpoint(googleApiEndpoint, getDefaultApiEndpoint()),
|
|
773
|
-
window.location.origin
|
|
774
|
-
);
|
|
775
|
-
baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/, "")}/google/voices`;
|
|
776
|
-
const url = baseUrl;
|
|
777
|
-
if (googleLanguage) url.searchParams.set("language", googleLanguage);
|
|
778
|
-
if (googleGender) url.searchParams.set("gender", googleGender);
|
|
779
|
-
if (googleVoiceType) url.searchParams.set("voiceType", googleVoiceType);
|
|
780
|
-
return url;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
async function checkPollyAvailability() {
|
|
784
|
-
pollyState = { ...pollyState, checked: true, loading: true, message: null, detail: null };
|
|
785
|
-
try {
|
|
786
|
-
const url = buildPollyVoicesUrl();
|
|
787
|
-
const adapterVoices = await adapters.fetchPollyVoices?.({
|
|
788
|
-
endpoint: normalizeApiEndpoint(pollyApiEndpoint, getDefaultApiEndpoint()),
|
|
789
|
-
language: pollyLanguage,
|
|
790
|
-
gender: pollyGender,
|
|
791
|
-
engine: pollyEngine,
|
|
792
|
-
url
|
|
793
|
-
});
|
|
794
|
-
const voices = Array.isArray(adapterVoices)
|
|
795
|
-
? adapterVoices
|
|
796
|
-
: await (async () => {
|
|
797
|
-
const response = await fetch(url.toString());
|
|
798
|
-
const payload = await readJsonSafe(response);
|
|
799
|
-
if (!response.ok) {
|
|
800
|
-
throw new Error(
|
|
801
|
-
`HTTP ${response.status}: ${payload?.error || payload?.message || "Unknown error"}`
|
|
802
|
-
);
|
|
803
|
-
}
|
|
804
|
-
return Array.isArray(payload?.voices) ? payload.voices : [];
|
|
805
|
-
})();
|
|
806
|
-
|
|
807
|
-
if (pollyVoice) {
|
|
808
|
-
const hasVoice = voices.some(
|
|
809
|
-
(voice: DemoVoice) => (voice.id || voice.name || "") === pollyVoice
|
|
810
|
-
);
|
|
811
|
-
if (!hasVoice) {
|
|
812
|
-
pollyVoice = "";
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
pollyState = {
|
|
816
|
-
checked: true,
|
|
817
|
-
loading: false,
|
|
818
|
-
available: true,
|
|
819
|
-
message: `AWS Polly available (${voices.length} matching voices, ${pollyEngine} engine).`,
|
|
820
|
-
detail: null,
|
|
821
|
-
voices
|
|
822
|
-
};
|
|
823
|
-
} catch (error) {
|
|
824
|
-
pollyState = {
|
|
825
|
-
checked: true,
|
|
826
|
-
loading: false,
|
|
827
|
-
available: false,
|
|
828
|
-
message: "AWS Polly is not available from the configured API.",
|
|
829
|
-
detail: error instanceof Error ? error.message : String(error),
|
|
830
|
-
voices: []
|
|
831
|
-
};
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
async function checkGoogleAvailability() {
|
|
836
|
-
googleState = { ...googleState, checked: true, loading: true, message: null, detail: null };
|
|
837
|
-
try {
|
|
838
|
-
const url = buildGoogleVoicesUrl();
|
|
839
|
-
const adapterVoices = await adapters.fetchGoogleVoices?.({
|
|
840
|
-
endpoint: normalizeApiEndpoint(googleApiEndpoint, getDefaultApiEndpoint()),
|
|
841
|
-
language: googleLanguage,
|
|
842
|
-
gender: googleGender,
|
|
843
|
-
voiceType: googleVoiceType,
|
|
844
|
-
url
|
|
845
|
-
});
|
|
846
|
-
const voices = Array.isArray(adapterVoices)
|
|
847
|
-
? adapterVoices
|
|
848
|
-
: await (async () => {
|
|
849
|
-
const response = await fetch(url.toString());
|
|
850
|
-
const payload = await readJsonSafe(response);
|
|
851
|
-
if (!response.ok) {
|
|
852
|
-
throw new Error(
|
|
853
|
-
`HTTP ${response.status}: ${payload?.error || payload?.message || "Unknown error"}`
|
|
854
|
-
);
|
|
855
|
-
}
|
|
856
|
-
return Array.isArray(payload?.voices) ? payload.voices : [];
|
|
857
|
-
})();
|
|
858
|
-
|
|
859
|
-
if (googleVoice) {
|
|
860
|
-
const hasVoice = voices.some(
|
|
861
|
-
(voice: DemoVoice) => (voice.id || voice.name || "") === googleVoice
|
|
862
|
-
);
|
|
863
|
-
if (!hasVoice) {
|
|
864
|
-
googleVoice = "";
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
googleState = {
|
|
868
|
-
checked: true,
|
|
869
|
-
loading: false,
|
|
870
|
-
available: true,
|
|
871
|
-
message: `Google Cloud TTS available (${voices.length} matching voices).`,
|
|
872
|
-
detail: null,
|
|
873
|
-
voices
|
|
874
|
-
};
|
|
875
|
-
} catch (error) {
|
|
876
|
-
googleState = {
|
|
877
|
-
checked: true,
|
|
878
|
-
loading: false,
|
|
879
|
-
available: false,
|
|
880
|
-
message: "Google Cloud TTS is not available from the configured API.",
|
|
881
|
-
detail: error instanceof Error ? error.message : String(error),
|
|
882
|
-
voices: []
|
|
883
|
-
};
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
async function checkActiveTabAvailability() {
|
|
888
|
-
if (activeTab === "browser") {
|
|
889
|
-
await checkBrowserAvailability();
|
|
890
|
-
return;
|
|
891
|
-
}
|
|
892
|
-
if (activeTab === "polly") {
|
|
893
|
-
await checkPollyAvailability();
|
|
894
|
-
return;
|
|
895
|
-
}
|
|
896
|
-
if (activeTab === "google") {
|
|
897
|
-
await checkGoogleAvailability();
|
|
898
|
-
return;
|
|
899
|
-
}
|
|
900
|
-
await checkCustomProviderAvailability(activeTab);
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
function refreshPollyVoices() {
|
|
904
|
-
if (activeTab !== "polly") return;
|
|
905
|
-
void checkPollyAvailability();
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
function refreshGoogleVoices() {
|
|
909
|
-
if (activeTab !== "google") return;
|
|
910
|
-
void checkGoogleAvailability();
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
function setActiveTab(nextTab: BackendTab) {
|
|
914
|
-
if (activeTab === nextTab) return;
|
|
915
|
-
stopPreview();
|
|
916
|
-
activeTab = nextTab;
|
|
917
|
-
setPreviewTextForCurrentTab();
|
|
918
|
-
void checkActiveTabAvailability();
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
function getActiveState(): AvailabilityState {
|
|
922
|
-
if (activeTab === "browser") return browserState;
|
|
923
|
-
if (activeTab === "polly") return pollyState;
|
|
924
|
-
if (activeTab === "google") return googleState;
|
|
925
|
-
return (
|
|
926
|
-
customProviderAvailabilityById[activeTab] || {
|
|
927
|
-
checked: false,
|
|
928
|
-
loading: false,
|
|
929
|
-
available: false,
|
|
930
|
-
message: "Provider availability has not been checked yet.",
|
|
931
|
-
detail: null,
|
|
932
|
-
voices: []
|
|
933
|
-
}
|
|
934
|
-
);
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
function canPreviewActiveTab(): boolean {
|
|
938
|
-
if (isBuiltInTab(activeTab)) return true;
|
|
939
|
-
if (!activeCustomProvider) return false;
|
|
940
|
-
return typeof activeCustomProvider.preview === "function";
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
function resolveVoiceForBackend(backend: BackendTab): string | undefined {
|
|
944
|
-
if (backend === "browser") {
|
|
945
|
-
return browserVoice || undefined;
|
|
946
|
-
}
|
|
947
|
-
if (backend === "polly") {
|
|
948
|
-
if (!pollyVoice) return undefined;
|
|
949
|
-
const isKnown = pollyState.voices.some(
|
|
950
|
-
(voice) => (voice.id || voice.name || "") === pollyVoice
|
|
951
|
-
);
|
|
952
|
-
return isKnown ? pollyVoice : undefined;
|
|
953
|
-
}
|
|
954
|
-
if (googleVoice) {
|
|
955
|
-
const isKnown = googleState.voices.some(
|
|
956
|
-
(voice) => (voice.id || voice.name || "") === googleVoice
|
|
957
|
-
);
|
|
958
|
-
if (isKnown) return googleVoice;
|
|
959
|
-
}
|
|
960
|
-
const firstMatching = googleState.voices[0];
|
|
961
|
-
return firstMatching ? firstMatching.id || firstMatching.name || undefined : undefined;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
function normalizeApiEndpoint(endpoint: string, fallback = DEFAULT_API_ENDPOINT): string {
|
|
965
|
-
const trimmed = endpoint.trim();
|
|
966
|
-
if (!trimmed) return fallback;
|
|
967
|
-
return trimmed.replace(/\/synthesize\/?$/i, "");
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
function getDefaultApiEndpoint(): string {
|
|
971
|
-
return normalizeApiEndpoint(apiEndpoint || DEFAULT_API_ENDPOINT);
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
function getSampleText(tab: BackendTab): string {
|
|
975
|
-
if (tab === "browser") return "This is a browser voice sample.";
|
|
976
|
-
if (tab === "polly") return "This is an AWS Polly voice sample.";
|
|
977
|
-
return "This is a Google Cloud TTS voice sample.";
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
function clearPreviewTracking() {
|
|
981
|
-
if (previewPollingTimer !== null && typeof window !== "undefined") {
|
|
982
|
-
window.clearInterval(previewPollingTimer);
|
|
983
|
-
}
|
|
984
|
-
previewPollingTimer = null;
|
|
985
|
-
previewTrackIndex = -1;
|
|
986
|
-
previewTrackLength = 0;
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
function tokenizePreviewText(text: string): Array<{ start: number; end: number }> {
|
|
990
|
-
const tokens: Array<{ start: number; end: number }> = [];
|
|
991
|
-
const matcher = /\S+/g;
|
|
992
|
-
let match: RegExpExecArray | null;
|
|
993
|
-
while ((match = matcher.exec(text)) !== null) {
|
|
994
|
-
tokens.push({
|
|
995
|
-
start: match.index,
|
|
996
|
-
end: match.index + match[0].length
|
|
997
|
-
});
|
|
998
|
-
}
|
|
999
|
-
return tokens;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
function getTrackingSegments(text: string): Array<{ text: string; active: boolean }> {
|
|
1003
|
-
const safeText = typeof text === "string" ? text : "";
|
|
1004
|
-
if (!safeText.length) return [{ text: "", active: false }];
|
|
1005
|
-
if (previewTrackIndex < 0 || previewTrackLength <= 0) {
|
|
1006
|
-
return [{ text: safeText, active: false }];
|
|
1007
|
-
}
|
|
1008
|
-
const start = Math.max(0, Math.min(previewTrackIndex, safeText.length));
|
|
1009
|
-
const end = Math.max(start, Math.min(start + previewTrackLength, safeText.length));
|
|
1010
|
-
return [
|
|
1011
|
-
{ text: safeText.slice(0, start), active: false },
|
|
1012
|
-
{ text: safeText.slice(start, end), active: true },
|
|
1013
|
-
{ text: safeText.slice(end), active: false }
|
|
1014
|
-
].filter((segment) => segment.text.length > 0);
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
function setPreviewTextForCurrentTab() {
|
|
1018
|
-
if (!isBuiltInTab(activeTab)) {
|
|
1019
|
-
const customLabel = activeCustomProvider?.label || "custom";
|
|
1020
|
-
const customSample = `This is a ${customLabel} TTS provider sample.`;
|
|
1021
|
-
if (previewMode === "ssml") {
|
|
1022
|
-
previewText = `<speak>${customSample}</speak>`;
|
|
1023
|
-
} else {
|
|
1024
|
-
previewText = customSample;
|
|
1025
|
-
}
|
|
1026
|
-
return;
|
|
1027
|
-
}
|
|
1028
|
-
if (previewMode === "ssml") {
|
|
1029
|
-
if (activeTab === "browser") {
|
|
1030
|
-
previewText = DEFAULT_PREVIEW_TEXT.browser;
|
|
1031
|
-
return;
|
|
1032
|
-
}
|
|
1033
|
-
previewText = DEFAULT_PREVIEW_SSML[activeTab];
|
|
1034
|
-
return;
|
|
1035
|
-
}
|
|
1036
|
-
previewText = DEFAULT_PREVIEW_TEXT[activeTab];
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
function onPreviewModeChange(mode: PreviewMode) {
|
|
1040
|
-
if (previewMode === mode) return;
|
|
1041
|
-
stopPreview();
|
|
1042
|
-
previewMode = mode;
|
|
1043
|
-
previewError = null;
|
|
1044
|
-
previewNote = null;
|
|
1045
|
-
setPreviewTextForCurrentTab();
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
function updateTrackingFromSpeechMarks(
|
|
1049
|
-
audio: HTMLAudioElement,
|
|
1050
|
-
speechMarks: Array<{ time: number; start: number; end: number }>
|
|
1051
|
-
) {
|
|
1052
|
-
if (previewPollingTimer !== null) {
|
|
1053
|
-
window.clearInterval(previewPollingTimer);
|
|
1054
|
-
}
|
|
1055
|
-
const orderedMarks = [...speechMarks].sort((left, right) => {
|
|
1056
|
-
if (left.time !== right.time) return left.time - right.time;
|
|
1057
|
-
if (left.start !== right.start) return left.start - right.start;
|
|
1058
|
-
return left.end - right.end;
|
|
1059
|
-
});
|
|
1060
|
-
if (orderedMarks.length === 0) return;
|
|
1061
|
-
const firstMark = orderedMarks[0];
|
|
1062
|
-
debugPreview("tracking:init", {
|
|
1063
|
-
audioCurrentMs: Math.round(audio.currentTime * 1000),
|
|
1064
|
-
marks: orderedMarks.length,
|
|
1065
|
-
firstMark,
|
|
1066
|
-
firstSlice: previewText.slice(
|
|
1067
|
-
Math.max(0, firstMark.start),
|
|
1068
|
-
Math.max(firstMark.start, firstMark.end)
|
|
1069
|
-
)
|
|
1070
|
-
});
|
|
1071
|
-
// Ensure preview starts on the first spoken word instead of waiting for the first poll tick.
|
|
1072
|
-
previewTrackIndex = firstMark.start;
|
|
1073
|
-
previewTrackLength = Math.max(1, firstMark.end - firstMark.start);
|
|
1074
|
-
let lastIndex = 0;
|
|
1075
|
-
let emittedTransitions = 0;
|
|
1076
|
-
previewPollingTimer = window.setInterval(() => {
|
|
1077
|
-
const currentMs = audio.currentTime * 1000;
|
|
1078
|
-
let nextIndex = lastIndex;
|
|
1079
|
-
while (
|
|
1080
|
-
nextIndex + 1 < orderedMarks.length &&
|
|
1081
|
-
currentMs >= orderedMarks[nextIndex + 1].time
|
|
1082
|
-
) {
|
|
1083
|
-
nextIndex += 1;
|
|
1084
|
-
}
|
|
1085
|
-
if (nextIndex !== lastIndex) {
|
|
1086
|
-
lastIndex = nextIndex;
|
|
1087
|
-
const mark = orderedMarks[lastIndex];
|
|
1088
|
-
previewTrackIndex = mark.start;
|
|
1089
|
-
previewTrackLength = Math.max(1, mark.end - mark.start);
|
|
1090
|
-
if (emittedTransitions < 12) {
|
|
1091
|
-
emittedTransitions += 1;
|
|
1092
|
-
debugPreview("tracking:step", {
|
|
1093
|
-
audioCurrentMs: Math.round(currentMs),
|
|
1094
|
-
index: lastIndex,
|
|
1095
|
-
mark,
|
|
1096
|
-
slice: previewText.slice(Math.max(0, mark.start), Math.max(mark.start, mark.end))
|
|
1097
|
-
});
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
}, 40);
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
function stopPreview() {
|
|
1104
|
-
clearPreviewTracking();
|
|
1105
|
-
previewError = null;
|
|
1106
|
-
previewNote = null;
|
|
1107
|
-
if (typeof window !== "undefined" && "speechSynthesis" in window) {
|
|
1108
|
-
window.speechSynthesis.cancel();
|
|
1109
|
-
}
|
|
1110
|
-
if (currentPreviewAudio) {
|
|
1111
|
-
currentPreviewAudio.pause();
|
|
1112
|
-
currentPreviewAudio.src = "";
|
|
1113
|
-
currentPreviewAudio = null;
|
|
1114
|
-
}
|
|
1115
|
-
isPreviewing = false;
|
|
1116
|
-
previewBackend = null;
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
function normalizePreviewSpeechMarks(
|
|
1120
|
-
speechMarks: PreviewSpeechMark[],
|
|
1121
|
-
audioDurationSeconds: number
|
|
1122
|
-
): PreviewSpeechMark[] {
|
|
1123
|
-
if (speechMarks.length === 0) return speechMarks;
|
|
1124
|
-
const ordered = [...speechMarks].sort((left, right) => {
|
|
1125
|
-
if (left.time !== right.time) return left.time - right.time;
|
|
1126
|
-
if (left.start !== right.start) return left.start - right.start;
|
|
1127
|
-
return left.end - right.end;
|
|
1128
|
-
});
|
|
1129
|
-
const times = ordered.map((mark) => Number(mark.time) || 0).filter((value) => value >= 0);
|
|
1130
|
-
const maxTime = times.length ? Math.max(...times) : 0;
|
|
1131
|
-
const deltas: number[] = [];
|
|
1132
|
-
for (let index = 1; index < times.length; index += 1) {
|
|
1133
|
-
const delta = times[index] - times[index - 1];
|
|
1134
|
-
if (Number.isFinite(delta) && delta > 0) deltas.push(delta);
|
|
1135
|
-
}
|
|
1136
|
-
const medianDelta =
|
|
1137
|
-
deltas.length > 0
|
|
1138
|
-
? [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]
|
|
1139
|
-
: 0;
|
|
1140
|
-
const durationSuggestsSeconds =
|
|
1141
|
-
Number.isFinite(audioDurationSeconds) &&
|
|
1142
|
-
audioDurationSeconds > 0 &&
|
|
1143
|
-
maxTime > 0 &&
|
|
1144
|
-
maxTime <= audioDurationSeconds * 1.5;
|
|
1145
|
-
// SC marks are commonly seconds; when metadata is unavailable, use time-shape heuristics.
|
|
1146
|
-
const shapeSuggestsSeconds =
|
|
1147
|
-
(maxTime > 0 && maxTime < 100 && ordered.length > 3) || (medianDelta > 0 && medianDelta < 10);
|
|
1148
|
-
const shouldConvertSecondsToMs = durationSuggestsSeconds || shapeSuggestsSeconds;
|
|
1149
|
-
if (!shouldConvertSecondsToMs) return ordered;
|
|
1150
|
-
return ordered.map((mark) => ({ ...mark, time: Number(mark.time) * 1000 }));
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
function normalizePreviewSpeechMarkOffsets(
|
|
1154
|
-
speechMarks: PreviewSpeechMark[],
|
|
1155
|
-
trackingText: string
|
|
1156
|
-
): PreviewSpeechMark[] {
|
|
1157
|
-
if (speechMarks.length === 0) return speechMarks;
|
|
1158
|
-
const ordered = [...speechMarks].sort((left, right) => {
|
|
1159
|
-
if (left.time !== right.time) return left.time - right.time;
|
|
1160
|
-
if (left.start !== right.start) return left.start - right.start;
|
|
1161
|
-
return left.end - right.end;
|
|
1162
|
-
});
|
|
1163
|
-
const safeText = typeof trackingText === "string" ? trackingText : "";
|
|
1164
|
-
const textLength = safeText.length;
|
|
1165
|
-
if (!textLength) return ordered;
|
|
1166
|
-
const maxEnd = Math.max(...ordered.map((mark) => Number(mark.end) || 0));
|
|
1167
|
-
const firstMark = ordered[0];
|
|
1168
|
-
const firstWord =
|
|
1169
|
-
typeof firstMark.value === "string" ? firstMark.value.trim() : "";
|
|
1170
|
-
const firstWordIndex = firstWord
|
|
1171
|
-
? safeText.toLowerCase().indexOf(firstWord.toLowerCase())
|
|
1172
|
-
: -1;
|
|
1173
|
-
const anchoredShift =
|
|
1174
|
-
firstWordIndex >= 0 ? Number(firstMark.start || 0) - firstWordIndex : 0;
|
|
1175
|
-
const fallbackShift = Number(firstMark.start || 0);
|
|
1176
|
-
const shouldRebase = maxEnd > textLength + 2 && (anchoredShift > 0 || fallbackShift > 0);
|
|
1177
|
-
if (!shouldRebase) return ordered;
|
|
1178
|
-
const shift = anchoredShift > 0 ? anchoredShift : fallbackShift;
|
|
1179
|
-
const rebased = ordered.map((mark) => {
|
|
1180
|
-
const start = Math.max(0, Number(mark.start || 0) - shift);
|
|
1181
|
-
const end = Math.max(start + 1, Number(mark.end || 0) - shift);
|
|
1182
|
-
return { ...mark, start, end };
|
|
1183
|
-
});
|
|
1184
|
-
debugPreview("marks:offset-rebase", {
|
|
1185
|
-
textLength,
|
|
1186
|
-
firstWord,
|
|
1187
|
-
firstWordIndex,
|
|
1188
|
-
shift,
|
|
1189
|
-
firstBefore: firstMark,
|
|
1190
|
-
firstAfter: rebased[0],
|
|
1191
|
-
lastAfter: rebased[rebased.length - 1]
|
|
1192
|
-
});
|
|
1193
|
-
return rebased;
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
async function playPreviewAudioFromUrl(
|
|
1197
|
-
audioUrl: string,
|
|
1198
|
-
speechMarks: PreviewSpeechMark[] = []
|
|
1199
|
-
): Promise<void> {
|
|
1200
|
-
const audio = new Audio(audioUrl);
|
|
1201
|
-
currentPreviewAudio = audio;
|
|
1202
|
-
if (speechMarks.length > 0) {
|
|
1203
|
-
await new Promise<void>((resolve) => {
|
|
1204
|
-
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
|
1205
|
-
resolve();
|
|
1206
|
-
return;
|
|
1207
|
-
}
|
|
1208
|
-
let settled = false;
|
|
1209
|
-
const finish = () => {
|
|
1210
|
-
if (settled) return;
|
|
1211
|
-
settled = true;
|
|
1212
|
-
audio.removeEventListener("loadedmetadata", finish);
|
|
1213
|
-
audio.removeEventListener("error", finish);
|
|
1214
|
-
resolve();
|
|
1215
|
-
};
|
|
1216
|
-
audio.addEventListener("loadedmetadata", finish, { once: true });
|
|
1217
|
-
audio.addEventListener("error", finish, { once: true });
|
|
1218
|
-
if (typeof window !== "undefined") {
|
|
1219
|
-
window.setTimeout(finish, 1200);
|
|
1220
|
-
}
|
|
1221
|
-
});
|
|
1222
|
-
const audioDurationSeconds = Number(audio.duration);
|
|
1223
|
-
const normalizedMarks = normalizePreviewSpeechMarks(speechMarks, audioDurationSeconds);
|
|
1224
|
-
const offsetNormalizedMarks = normalizePreviewSpeechMarkOffsets(normalizedMarks, previewText);
|
|
1225
|
-
const rawMaxTime = Math.max(...speechMarks.map((mark) => Number(mark.time) || 0));
|
|
1226
|
-
const normalizedMaxTime = Math.max(...offsetNormalizedMarks.map((mark) => Number(mark.time) || 0));
|
|
1227
|
-
debugPreview("marks:normalize", {
|
|
1228
|
-
audioDurationSeconds,
|
|
1229
|
-
rawCount: speechMarks.length,
|
|
1230
|
-
normalizedCount: offsetNormalizedMarks.length,
|
|
1231
|
-
rawMaxTime,
|
|
1232
|
-
normalizedMaxTime,
|
|
1233
|
-
rawFirst: speechMarks[0],
|
|
1234
|
-
normalizedFirst: offsetNormalizedMarks[0],
|
|
1235
|
-
rawLast: speechMarks[speechMarks.length - 1],
|
|
1236
|
-
normalizedLast: offsetNormalizedMarks[offsetNormalizedMarks.length - 1]
|
|
1237
|
-
});
|
|
1238
|
-
updateTrackingFromSpeechMarks(audio, offsetNormalizedMarks);
|
|
1239
|
-
} else {
|
|
1240
|
-
debugPreview("marks:missing", { audioUrl });
|
|
1241
|
-
}
|
|
1242
|
-
await new Promise<void>((resolve, reject) => {
|
|
1243
|
-
audio.onended = () => {
|
|
1244
|
-
if (previewPollingTimer !== null) {
|
|
1245
|
-
window.clearInterval(previewPollingTimer);
|
|
1246
|
-
previewPollingTimer = null;
|
|
1247
|
-
}
|
|
1248
|
-
resolve();
|
|
1249
|
-
};
|
|
1250
|
-
audio.onerror = () => {
|
|
1251
|
-
if (previewPollingTimer !== null) {
|
|
1252
|
-
window.clearInterval(previewPollingTimer);
|
|
1253
|
-
previewPollingTimer = null;
|
|
1254
|
-
}
|
|
1255
|
-
reject(new Error("Failed to play preview audio."));
|
|
1256
|
-
};
|
|
1257
|
-
void audio.play().catch(reject);
|
|
1258
|
-
});
|
|
1259
|
-
currentPreviewAudio = null;
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
async function previewServerVoice(provider: "polly" | "google") {
|
|
1263
|
-
const endpoint = normalizeApiEndpoint(
|
|
1264
|
-
provider === "polly" ? pollyApiEndpoint : googleApiEndpoint,
|
|
1265
|
-
getDefaultApiEndpoint()
|
|
1266
|
-
);
|
|
1267
|
-
const includeSpeechMarks = !(provider === "google" && previewMode === "ssml");
|
|
1268
|
-
const requestBody: Record<string, unknown> = {
|
|
1269
|
-
text: previewText.trim() || getSampleText(provider),
|
|
1270
|
-
provider,
|
|
1271
|
-
rate: normalizeRate(provider === "polly" ? pollyRate : googleRate),
|
|
1272
|
-
language: provider === "polly" ? pollyLanguage || undefined : googleLanguage || undefined,
|
|
1273
|
-
voice:
|
|
1274
|
-
provider === "polly"
|
|
1275
|
-
? resolveVoiceForBackend("polly")
|
|
1276
|
-
: resolveVoiceForBackend("google"),
|
|
1277
|
-
includeSpeechMarks
|
|
1278
|
-
};
|
|
1279
|
-
if (provider === "polly") {
|
|
1280
|
-
requestBody.engine = pollyEngine;
|
|
1281
|
-
requestBody.sampleRate = normalizePollySampleRate(pollySampleRate);
|
|
1282
|
-
requestBody.format = pollyFormat;
|
|
1283
|
-
requestBody.speechMarkTypes = getPollySpeechMarkTypes();
|
|
1284
|
-
}
|
|
1285
|
-
const payload = adapters.synthesizeProbe
|
|
1286
|
-
? await adapters.synthesizeProbe({ endpoint, provider, body: requestBody })
|
|
1287
|
-
: await (async () => {
|
|
1288
|
-
const response = await fetch(`${endpoint}/synthesize`, {
|
|
1289
|
-
method: "POST",
|
|
1290
|
-
headers: { "Content-Type": "application/json" },
|
|
1291
|
-
body: JSON.stringify(requestBody)
|
|
1292
|
-
});
|
|
1293
|
-
const nextPayload = await readJsonSafe(response);
|
|
1294
|
-
if (!response.ok) {
|
|
1295
|
-
throw new Error(
|
|
1296
|
-
nextPayload?.message ||
|
|
1297
|
-
nextPayload?.error ||
|
|
1298
|
-
`Preview request failed (${response.status})`
|
|
1299
|
-
);
|
|
1300
|
-
}
|
|
1301
|
-
return nextPayload as SynthesizeProbeResponse;
|
|
1302
|
-
})();
|
|
1303
|
-
|
|
1304
|
-
const audioBase64 = payload?.audio;
|
|
1305
|
-
const contentType = payload?.contentType || "audio/mpeg";
|
|
1306
|
-
if (!audioBase64 || typeof audioBase64 !== "string") {
|
|
1307
|
-
throw new Error("Preview response did not include audio content.");
|
|
1308
|
-
}
|
|
1309
|
-
const byteChars = atob(audioBase64);
|
|
1310
|
-
const byteNumbers = new Array(byteChars.length);
|
|
1311
|
-
for (let i = 0; i < byteChars.length; i += 1) {
|
|
1312
|
-
byteNumbers[i] = byteChars.charCodeAt(i);
|
|
1313
|
-
}
|
|
1314
|
-
const blob = new Blob([new Uint8Array(byteNumbers)], { type: contentType });
|
|
1315
|
-
const objectUrl = URL.createObjectURL(blob);
|
|
1316
|
-
const audio = new Audio(objectUrl);
|
|
1317
|
-
currentPreviewAudio = audio;
|
|
1318
|
-
const speechMarks = Array.isArray(payload?.speechMarks) ? payload.speechMarks : [];
|
|
1319
|
-
if (includeSpeechMarks && speechMarks.length > 0) {
|
|
1320
|
-
updateTrackingFromSpeechMarks(audio, speechMarks);
|
|
1321
|
-
}
|
|
1322
|
-
await new Promise<void>((resolve, reject) => {
|
|
1323
|
-
audio.onended = () => {
|
|
1324
|
-
if (previewPollingTimer !== null) {
|
|
1325
|
-
window.clearInterval(previewPollingTimer);
|
|
1326
|
-
previewPollingTimer = null;
|
|
1327
|
-
}
|
|
1328
|
-
URL.revokeObjectURL(objectUrl);
|
|
1329
|
-
resolve();
|
|
1330
|
-
};
|
|
1331
|
-
audio.onerror = () => {
|
|
1332
|
-
if (previewPollingTimer !== null) {
|
|
1333
|
-
window.clearInterval(previewPollingTimer);
|
|
1334
|
-
previewPollingTimer = null;
|
|
1335
|
-
}
|
|
1336
|
-
URL.revokeObjectURL(objectUrl);
|
|
1337
|
-
reject(new Error("Failed to play preview audio."));
|
|
1338
|
-
};
|
|
1339
|
-
void audio.play().catch(reject);
|
|
1340
|
-
});
|
|
1341
|
-
currentPreviewAudio = null;
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
async function previewBrowserVoice() {
|
|
1345
|
-
if (typeof window === "undefined" || !("speechSynthesis" in window)) {
|
|
1346
|
-
throw new Error("Browser speech synthesis is unavailable.");
|
|
1347
|
-
}
|
|
1348
|
-
if (previewMode === "ssml") {
|
|
1349
|
-
throw new Error("SSML preview is not supported in the Browser backend.");
|
|
1350
|
-
}
|
|
1351
|
-
const synth = window.speechSynthesis;
|
|
1352
|
-
const utterance = new SpeechSynthesisUtterance(previewText.trim() || getSampleText("browser"));
|
|
1353
|
-
utterance.rate = normalizeRate(browserRate);
|
|
1354
|
-
utterance.pitch = normalizePitch(browserPitch);
|
|
1355
|
-
if (browserVoice) {
|
|
1356
|
-
const voice = synth.getVoices().find((entry) => entry.name === browserVoice);
|
|
1357
|
-
if (voice) utterance.voice = voice;
|
|
1358
|
-
}
|
|
1359
|
-
utterance.onboundary = (event) => {
|
|
1360
|
-
if ((event as any).name !== "word") return;
|
|
1361
|
-
const charIndex = Number((event as any).charIndex || 0);
|
|
1362
|
-
const token = tokenizePreviewText(previewText).find(
|
|
1363
|
-
(entry) => charIndex >= entry.start && charIndex < entry.end
|
|
1364
|
-
);
|
|
1365
|
-
if (token) {
|
|
1366
|
-
previewTrackIndex = token.start;
|
|
1367
|
-
previewTrackLength = Math.max(1, token.end - token.start);
|
|
1368
|
-
}
|
|
1369
|
-
};
|
|
1370
|
-
await new Promise<void>((resolve, reject) => {
|
|
1371
|
-
utterance.onend = () => resolve();
|
|
1372
|
-
utterance.onerror = () => reject(new Error("Failed to play browser voice preview."));
|
|
1373
|
-
synth.speak(utterance);
|
|
1374
|
-
});
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
async function previewSelectedVoice() {
|
|
1378
|
-
previewError = null;
|
|
1379
|
-
previewNote = null;
|
|
1380
|
-
const activeState = getActiveState();
|
|
1381
|
-
if (!activeState.available) {
|
|
1382
|
-
previewError = "Cannot preview while this TTS service is unavailable.";
|
|
1383
|
-
return;
|
|
1384
|
-
}
|
|
1385
|
-
if (isPreviewing && previewBackend === activeTab) {
|
|
1386
|
-
previewRunId += 1;
|
|
1387
|
-
stopPreview();
|
|
1388
|
-
return;
|
|
1389
|
-
}
|
|
1390
|
-
const activePreviewText = typeof previewText === "string" ? previewText : "";
|
|
1391
|
-
if (!activePreviewText.trim()) {
|
|
1392
|
-
if (isBuiltInTab(activeTab)) {
|
|
1393
|
-
previewError = "Enter preview text before starting playback.";
|
|
1394
|
-
return;
|
|
1395
|
-
}
|
|
1396
|
-
}
|
|
1397
|
-
if (activeTab === "browser" && previewMode === "ssml") {
|
|
1398
|
-
previewError = "SSML preview is not supported in the Browser backend.";
|
|
1399
|
-
return;
|
|
1400
|
-
}
|
|
1401
|
-
stopPreview();
|
|
1402
|
-
const runId = ++previewRunId;
|
|
1403
|
-
isPreviewing = true;
|
|
1404
|
-
previewBackend = activeTab;
|
|
1405
|
-
try {
|
|
1406
|
-
if (!isBuiltInTab(activeTab)) {
|
|
1407
|
-
const provider = getCustomProviderOrThrow(activeTab);
|
|
1408
|
-
const result = await provider.preview?.(
|
|
1409
|
-
createProviderContext(provider.id, {
|
|
1410
|
-
previewText,
|
|
1411
|
-
previewMode
|
|
1412
|
-
})
|
|
1413
|
-
);
|
|
1414
|
-
const customResult = (result || null) as CustomProviderPreviewResult | null;
|
|
1415
|
-
if (customResult?.audioUrl && typeof customResult.audioUrl === "string") {
|
|
1416
|
-
const speechMarks = Array.isArray(customResult.speechMarks)
|
|
1417
|
-
? customResult.speechMarks
|
|
1418
|
-
: [];
|
|
1419
|
-
const trackingText =
|
|
1420
|
-
typeof customResult.trackingText === "string"
|
|
1421
|
-
? customResult.trackingText
|
|
1422
|
-
: null;
|
|
1423
|
-
if (trackingText) {
|
|
1424
|
-
previewText = trackingText;
|
|
1425
|
-
}
|
|
1426
|
-
await playPreviewAudioFromUrl(customResult.audioUrl, speechMarks);
|
|
1427
|
-
}
|
|
1428
|
-
previewNote = result?.note || null;
|
|
1429
|
-
} else if (activeTab === "browser") {
|
|
1430
|
-
await previewBrowserVoice();
|
|
1431
|
-
} else if (activeTab === "polly") {
|
|
1432
|
-
if (previewMode === "ssml") {
|
|
1433
|
-
previewNote =
|
|
1434
|
-
"Some Polly neural voices reject certain SSML tags (for example emphasis). Use basic SSML tags if preview fails.";
|
|
1435
|
-
}
|
|
1436
|
-
await previewServerVoice("polly");
|
|
1437
|
-
} else {
|
|
1438
|
-
if (previewMode === "ssml") {
|
|
1439
|
-
previewNote =
|
|
1440
|
-
"Google SSML preview preserves authored SSML, so word tracking is disabled.";
|
|
1441
|
-
}
|
|
1442
|
-
await previewServerVoice("google");
|
|
1443
|
-
}
|
|
1444
|
-
} catch (error) {
|
|
1445
|
-
if (runId === previewRunId) {
|
|
1446
|
-
previewError = error instanceof Error ? error.message : String(error);
|
|
1447
|
-
}
|
|
1448
|
-
} finally {
|
|
1449
|
-
if (runId === previewRunId) {
|
|
1450
|
-
clearPreviewTracking();
|
|
1451
|
-
isPreviewing = false;
|
|
1452
|
-
previewBackend = null;
|
|
1453
|
-
}
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
async function applySettings() {
|
|
1458
|
-
applyMessage = null;
|
|
1459
|
-
applyError = null;
|
|
1460
|
-
const activeState = getActiveState();
|
|
1461
|
-
if (!activeState.available) {
|
|
1462
|
-
applyError = "Cannot apply settings while this TTS service is unavailable.";
|
|
1463
|
-
return;
|
|
1464
|
-
}
|
|
1465
|
-
if (!toolkitCoordinator?.updateToolConfig) {
|
|
1466
|
-
applyError = "Toolkit coordinator is not available for TTS updates.";
|
|
1467
|
-
return;
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
isApplying = true;
|
|
1471
|
-
try {
|
|
1472
|
-
const appliedSpeedOptions = parseTTSSpeedOptionsFromText(speedOptionsText);
|
|
1473
|
-
if (!isBuiltInTab(activeTab)) {
|
|
1474
|
-
const provider = getCustomProviderOrThrow(activeTab);
|
|
1475
|
-
let next: ProviderApplyResult | undefined;
|
|
1476
|
-
if (provider.buildApplyConfig) {
|
|
1477
|
-
next = await provider.buildApplyConfig(createProviderContext(provider.id));
|
|
1478
|
-
}
|
|
1479
|
-
if (!next?.config) {
|
|
1480
|
-
next = customProviderApplyRequestById[provider.id];
|
|
1481
|
-
}
|
|
1482
|
-
if (!next?.config) {
|
|
1483
|
-
throw new Error(
|
|
1484
|
-
`Custom provider '${provider.id}' did not return apply config.`
|
|
1485
|
-
);
|
|
1486
|
-
}
|
|
1487
|
-
toolkitCoordinator.updateToolConfig("textToSpeech", {
|
|
1488
|
-
enabled: true,
|
|
1489
|
-
...next.config,
|
|
1490
|
-
layoutMode,
|
|
1491
|
-
speedOptions: appliedSpeedOptions,
|
|
1492
|
-
});
|
|
1493
|
-
persistSettings({
|
|
1494
|
-
backend: provider.id,
|
|
1495
|
-
...(next.config || {}),
|
|
1496
|
-
layoutMode,
|
|
1497
|
-
speedOptions: appliedSpeedOptions,
|
|
1498
|
-
});
|
|
1499
|
-
applyMessage = next.message || `Applied ${provider.label} TTS settings.`;
|
|
1500
|
-
} else if (activeTab === "browser") {
|
|
1501
|
-
const next = {
|
|
1502
|
-
backend: "browser" as const,
|
|
1503
|
-
serverProvider: undefined,
|
|
1504
|
-
provider: undefined,
|
|
1505
|
-
defaultVoice: resolveVoiceForBackend("browser"),
|
|
1506
|
-
rate: normalizeRate(browserRate),
|
|
1507
|
-
pitch: normalizePitch(browserPitch),
|
|
1508
|
-
transportMode: "pie" as const,
|
|
1509
|
-
layoutMode,
|
|
1510
|
-
speedOptions: appliedSpeedOptions,
|
|
1511
|
-
};
|
|
1512
|
-
toolkitCoordinator.updateToolConfig("textToSpeech", {
|
|
1513
|
-
enabled: true,
|
|
1514
|
-
...next
|
|
1515
|
-
});
|
|
1516
|
-
persistSettings(next);
|
|
1517
|
-
} else if (activeTab === "polly") {
|
|
1518
|
-
const next = {
|
|
1519
|
-
backend: "polly" as const,
|
|
1520
|
-
serverProvider: "polly" as const,
|
|
1521
|
-
apiEndpoint: normalizeApiEndpoint(pollyApiEndpoint, getDefaultApiEndpoint()),
|
|
1522
|
-
transportMode: "pie" as const,
|
|
1523
|
-
endpointMode: "synthesizePath" as const,
|
|
1524
|
-
endpointValidationMode: "voices" as const,
|
|
1525
|
-
defaultVoice: resolveVoiceForBackend("polly"),
|
|
1526
|
-
rate: normalizeRate(pollyRate),
|
|
1527
|
-
language: pollyLanguage || undefined,
|
|
1528
|
-
engine: pollyEngine,
|
|
1529
|
-
sampleRate: normalizePollySampleRate(pollySampleRate),
|
|
1530
|
-
format: pollyFormat,
|
|
1531
|
-
speechMarksMode: pollySpeechMarksMode,
|
|
1532
|
-
providerOptions: {
|
|
1533
|
-
engine: pollyEngine,
|
|
1534
|
-
sampleRate: normalizePollySampleRate(pollySampleRate),
|
|
1535
|
-
format: pollyFormat,
|
|
1536
|
-
speechMarkTypes: getPollySpeechMarkTypes()
|
|
1537
|
-
},
|
|
1538
|
-
layoutMode,
|
|
1539
|
-
speedOptions: appliedSpeedOptions,
|
|
1540
|
-
};
|
|
1541
|
-
toolkitCoordinator.updateToolConfig("textToSpeech", {
|
|
1542
|
-
enabled: true,
|
|
1543
|
-
...next
|
|
1544
|
-
});
|
|
1545
|
-
persistSettings(next);
|
|
1546
|
-
} else {
|
|
1547
|
-
const next = {
|
|
1548
|
-
backend: "google" as const,
|
|
1549
|
-
serverProvider: "google" as const,
|
|
1550
|
-
apiEndpoint: normalizeApiEndpoint(googleApiEndpoint, getDefaultApiEndpoint()),
|
|
1551
|
-
transportMode: "pie" as const,
|
|
1552
|
-
endpointMode: "synthesizePath" as const,
|
|
1553
|
-
endpointValidationMode: "voices" as const,
|
|
1554
|
-
defaultVoice: resolveVoiceForBackend("google"),
|
|
1555
|
-
rate: normalizeRate(googleRate),
|
|
1556
|
-
language: googleLanguage || undefined,
|
|
1557
|
-
googleVoiceType,
|
|
1558
|
-
googleGender,
|
|
1559
|
-
layoutMode,
|
|
1560
|
-
speedOptions: appliedSpeedOptions,
|
|
1561
|
-
};
|
|
1562
|
-
toolkitCoordinator.updateToolConfig("textToSpeech", {
|
|
1563
|
-
enabled: true,
|
|
1564
|
-
...next
|
|
1565
|
-
});
|
|
1566
|
-
persistSettings(next);
|
|
1567
|
-
}
|
|
1568
|
-
await toolkitCoordinator?.ensureTTSReady?.(
|
|
1569
|
-
toolkitCoordinator?.getToolConfig?.("textToSpeech"),
|
|
1570
|
-
);
|
|
1571
|
-
if (isBuiltInTab(activeTab)) {
|
|
1572
|
-
applyMessage = `Applied ${activeTab} TTS settings.`;
|
|
1573
|
-
}
|
|
1574
|
-
requestClose();
|
|
1575
|
-
} catch (error) {
|
|
1576
|
-
applyError = error instanceof Error ? error.message : String(error);
|
|
1577
|
-
} finally {
|
|
1578
|
-
isApplying = false;
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
onMount(() => {
|
|
1583
|
-
initializeFromCoordinator();
|
|
1584
|
-
syncCustomProvidersState();
|
|
1585
|
-
void checkActiveTabAvailability();
|
|
1586
|
-
});
|
|
1587
|
-
|
|
1588
|
-
$effect(() => {
|
|
1589
|
-
if (!dialogEl) return;
|
|
1590
|
-
cleanupFocusTrap?.();
|
|
1591
|
-
cleanupFocusTrap = createLocalFocusTrap(dialogEl, {
|
|
1592
|
-
initialFocus: closeButtonEl,
|
|
1593
|
-
onEscape: requestClose
|
|
1594
|
-
});
|
|
1595
|
-
queueMicrotask(() => {
|
|
1596
|
-
closeButtonEl?.focus?.();
|
|
1597
|
-
});
|
|
1598
|
-
return () => {
|
|
1599
|
-
cleanupFocusTrap?.();
|
|
1600
|
-
cleanupFocusTrap = null;
|
|
1601
|
-
};
|
|
1602
|
-
});
|
|
1603
|
-
|
|
1604
|
-
$effect(() => {
|
|
1605
|
-
void normalizedCustomProviders;
|
|
1606
|
-
untrack(() => {
|
|
1607
|
-
syncCustomProvidersState();
|
|
1608
|
-
});
|
|
1609
|
-
});
|
|
1610
|
-
|
|
1611
|
-
$effect(() => {
|
|
1612
|
-
void activeCustomProvider;
|
|
1613
|
-
const provider = activeCustomProvider;
|
|
1614
|
-
const target = activeCustomProviderElement;
|
|
1615
|
-
if (!provider || provider.mode !== "component" || !target) return;
|
|
1616
|
-
const onAvailability = (event: Event) =>
|
|
1617
|
-
onCustomProviderAvailability(provider.id, event as CustomEvent);
|
|
1618
|
-
const onApplyRequest = (event: Event) =>
|
|
1619
|
-
void onCustomProviderApplyRequest(provider.id, event as CustomEvent);
|
|
1620
|
-
const onPreviewRequest = () => void onCustomProviderPreviewRequest(provider.id);
|
|
1621
|
-
|
|
1622
|
-
target.addEventListener("availability", onAvailability as EventListener);
|
|
1623
|
-
target.addEventListener("apply-request", onApplyRequest as EventListener);
|
|
1624
|
-
target.addEventListener("preview-request", onPreviewRequest as EventListener);
|
|
1625
|
-
return () => {
|
|
1626
|
-
target.removeEventListener("availability", onAvailability as EventListener);
|
|
1627
|
-
target.removeEventListener("apply-request", onApplyRequest as EventListener);
|
|
1628
|
-
target.removeEventListener("preview-request", onPreviewRequest as EventListener);
|
|
1629
|
-
};
|
|
1630
|
-
});
|
|
1631
|
-
|
|
1632
|
-
onDestroy(() => {
|
|
1633
|
-
cleanupFocusTrap?.();
|
|
1634
|
-
cleanupFocusTrap = null;
|
|
1635
|
-
stopPreview();
|
|
1636
|
-
});
|
|
1637
|
-
</script>
|
|
1638
|
-
|
|
1639
|
-
<div class="pie-tts-dialog-backdrop" style="z-index: {TTS_MODAL_Z_INDEX};">
|
|
1640
|
-
<div
|
|
1641
|
-
class="pie-tts-dialog"
|
|
1642
|
-
bind:this={dialogEl}
|
|
1643
|
-
role="dialog"
|
|
1644
|
-
aria-modal="true"
|
|
1645
|
-
aria-labelledby="pie-tts-dialog-title"
|
|
1646
|
-
tabindex="-1"
|
|
1647
|
-
>
|
|
1648
|
-
<div class="pie-tts-dialog-header">
|
|
1649
|
-
<h3 id="pie-tts-dialog-title" class="pie-tts-dialog-title">TTS settings</h3>
|
|
1650
|
-
<button
|
|
1651
|
-
class="btn btn-xs btn-ghost btn-circle"
|
|
1652
|
-
bind:this={closeButtonEl}
|
|
1653
|
-
type="button"
|
|
1654
|
-
onclick={requestClose}
|
|
1655
|
-
aria-label="Close TTS settings"
|
|
1656
|
-
>
|
|
1657
|
-
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
1658
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
1659
|
-
</svg>
|
|
1660
|
-
</button>
|
|
1661
|
-
</div>
|
|
1662
|
-
|
|
1663
|
-
<div class="pie-tts-fieldset fieldset bg-base-200 border border-base-300 rounded-box">
|
|
1664
|
-
<div class="pie-tts-field">
|
|
1665
|
-
<label class="pie-tts-label" for="tts-layout-mode">Toolbar layout mode</label>
|
|
1666
|
-
<select
|
|
1667
|
-
id="tts-layout-mode"
|
|
1668
|
-
class="select select-sm select-bordered w-full"
|
|
1669
|
-
bind:value={layoutMode}
|
|
1670
|
-
>
|
|
1671
|
-
<option value="reserved-row">Reserved row</option>
|
|
1672
|
-
<option value="expanding-row">Expanding row</option>
|
|
1673
|
-
<option value="floating-overlay">Floating overlay</option>
|
|
1674
|
-
<option value="left-aligned">Left-aligned controls</option>
|
|
1675
|
-
</select>
|
|
1676
|
-
<div class="text-xs opacity-75">
|
|
1677
|
-
Item header row reservation: {layoutModeReservesRow ? "Enabled" : "Disabled"}
|
|
1678
|
-
</div>
|
|
1679
|
-
</div>
|
|
1680
|
-
<div class="pie-tts-field">
|
|
1681
|
-
<label class="pie-tts-label" for="tts-inline-speed-options">Inline speed buttons</label>
|
|
1682
|
-
<input
|
|
1683
|
-
id="tts-inline-speed-options"
|
|
1684
|
-
class="input input-sm input-bordered w-full"
|
|
1685
|
-
bind:value={speedOptionsText}
|
|
1686
|
-
placeholder="0.8, 1.25"
|
|
1687
|
-
autocomplete="off"
|
|
1688
|
-
/>
|
|
1689
|
-
<div class="mt-1 flex flex-wrap items-center gap-2">
|
|
1690
|
-
<span class="text-xs opacity-75">
|
|
1691
|
-
Comma or semicolon-separated multipliers (1× is not shown as a button). Leave empty to
|
|
1692
|
-
hide speed buttons.
|
|
1693
|
-
</span>
|
|
1694
|
-
<button
|
|
1695
|
-
type="button"
|
|
1696
|
-
class="btn btn-xs btn-ghost"
|
|
1697
|
-
onclick={resetInlineSpeedOptionsToDefaults}
|
|
1698
|
-
>
|
|
1699
|
-
Reset to defaults
|
|
1700
|
-
</button>
|
|
1701
|
-
</div>
|
|
1702
|
-
</div>
|
|
1703
|
-
</div>
|
|
1704
|
-
|
|
1705
|
-
<div class="join pie-tts-tabs">
|
|
1706
|
-
{#each providerTabs as provider}
|
|
1707
|
-
<button
|
|
1708
|
-
class="btn btn-sm join-item"
|
|
1709
|
-
class:btn-active={activeTab === provider.id}
|
|
1710
|
-
onclick={() => setActiveTab(provider.id)}
|
|
1711
|
-
>
|
|
1712
|
-
{provider.label}
|
|
1713
|
-
</button>
|
|
1714
|
-
{/each}
|
|
1715
|
-
</div>
|
|
1716
|
-
|
|
1717
|
-
<div class="pie-tts-status">
|
|
1718
|
-
{#if getActiveState().loading}
|
|
1719
|
-
<span class="loading loading-spinner loading-xs"></span>
|
|
1720
|
-
<span>Checking availability...</span>
|
|
1721
|
-
{:else if getActiveState().checked}
|
|
1722
|
-
<span class={getActiveState().available ? "pie-tts-ok" : "pie-tts-error"}>
|
|
1723
|
-
{getActiveState().message}
|
|
1724
|
-
</span>
|
|
1725
|
-
{/if}
|
|
1726
|
-
<button class="btn btn-xs btn-outline" onclick={() => void checkActiveTabAvailability()}>
|
|
1727
|
-
Recheck
|
|
1728
|
-
</button>
|
|
1729
|
-
</div>
|
|
1730
|
-
|
|
1731
|
-
{#if getActiveState().detail}
|
|
1732
|
-
<div class="alert alert-warning text-xs">
|
|
1733
|
-
<span>{getActiveState().detail}</span>
|
|
1734
|
-
</div>
|
|
1735
|
-
{/if}
|
|
1736
|
-
|
|
1737
|
-
{#if activeTab === "browser"}
|
|
1738
|
-
<fieldset class="pie-tts-fieldset fieldset bg-base-200 border border-base-300 rounded-box" disabled={!browserState.available}>
|
|
1739
|
-
<div class="pie-tts-field">
|
|
1740
|
-
<label class="pie-tts-label" for="tts-browser-voice">Voice</label>
|
|
1741
|
-
<select id="tts-browser-voice" class="select select-sm select-bordered w-full" bind:value={browserVoice}>
|
|
1742
|
-
<option value="">Default browser voice</option>
|
|
1743
|
-
{#each browserState.voices as voice}
|
|
1744
|
-
<option value={voice.name || ""}>{voice.name} ({voice.languageCode || "n/a"})</option>
|
|
1745
|
-
{/each}
|
|
1746
|
-
</select>
|
|
1747
|
-
</div>
|
|
1748
|
-
</fieldset>
|
|
1749
|
-
{:else if activeTab === "polly"}
|
|
1750
|
-
<fieldset class="pie-tts-fieldset fieldset bg-base-200 border border-base-300 rounded-box" disabled={!pollyState.available}>
|
|
1751
|
-
<div class="pie-tts-field">
|
|
1752
|
-
<label class="pie-tts-label" for="tts-polly-endpoint">API endpoint</label>
|
|
1753
|
-
<input id="tts-polly-endpoint" class="input input-sm input-bordered w-full" bind:value={pollyApiEndpoint} placeholder="/api/tts" />
|
|
1754
|
-
</div>
|
|
1755
|
-
|
|
1756
|
-
<div class="pie-tts-grid-3">
|
|
1757
|
-
<div class="pie-tts-field">
|
|
1758
|
-
<label class="pie-tts-label" for="tts-polly-language">Language</label>
|
|
1759
|
-
<input
|
|
1760
|
-
id="tts-polly-language"
|
|
1761
|
-
class="input input-sm input-bordered w-full"
|
|
1762
|
-
bind:value={pollyLanguage}
|
|
1763
|
-
placeholder="en-US"
|
|
1764
|
-
onchange={refreshPollyVoices}
|
|
1765
|
-
/>
|
|
1766
|
-
</div>
|
|
1767
|
-
<div class="pie-tts-field">
|
|
1768
|
-
<label class="pie-tts-label" for="tts-polly-gender">Gender</label>
|
|
1769
|
-
<select
|
|
1770
|
-
id="tts-polly-gender"
|
|
1771
|
-
class="select select-sm select-bordered w-full"
|
|
1772
|
-
bind:value={pollyGender}
|
|
1773
|
-
onchange={refreshPollyVoices}
|
|
1774
|
-
>
|
|
1775
|
-
<option value="">Any</option>
|
|
1776
|
-
<option value="male">Male</option>
|
|
1777
|
-
<option value="female">Female</option>
|
|
1778
|
-
<option value="neutral">Neutral</option>
|
|
1779
|
-
</select>
|
|
1780
|
-
</div>
|
|
1781
|
-
<div class="pie-tts-field">
|
|
1782
|
-
<label class="pie-tts-label" for="tts-polly-engine">Engine</label>
|
|
1783
|
-
<select
|
|
1784
|
-
id="tts-polly-engine"
|
|
1785
|
-
class="select select-sm select-bordered w-full"
|
|
1786
|
-
bind:value={pollyEngine}
|
|
1787
|
-
onchange={refreshPollyVoices}
|
|
1788
|
-
>
|
|
1789
|
-
<option value="neural">Neural</option>
|
|
1790
|
-
<option value="standard">Standard</option>
|
|
1791
|
-
</select>
|
|
1792
|
-
</div>
|
|
1793
|
-
</div>
|
|
1794
|
-
|
|
1795
|
-
<div class="pie-tts-field">
|
|
1796
|
-
<label class="pie-tts-label" for="tts-polly-voice">Voice</label>
|
|
1797
|
-
<select id="tts-polly-voice" class="select select-sm select-bordered w-full" bind:value={pollyVoice}>
|
|
1798
|
-
<option value="">Provider default</option>
|
|
1799
|
-
{#each pollyState.voices as voice}
|
|
1800
|
-
<option value={voice.id || voice.name || ""}>{voice.name || voice.id} ({voice.languageCode || "n/a"})</option>
|
|
1801
|
-
{/each}
|
|
1802
|
-
</select>
|
|
1803
|
-
</div>
|
|
1804
|
-
|
|
1805
|
-
<div class="pie-tts-grid-3">
|
|
1806
|
-
<div class="pie-tts-field">
|
|
1807
|
-
<label class="pie-tts-label" for="tts-polly-format">Format</label>
|
|
1808
|
-
<select id="tts-polly-format" class="select select-sm select-bordered w-full" bind:value={pollyFormat}>
|
|
1809
|
-
<option value="mp3">MP3</option>
|
|
1810
|
-
<option value="ogg">OGG</option>
|
|
1811
|
-
<option value="pcm">PCM</option>
|
|
1812
|
-
</select>
|
|
1813
|
-
</div>
|
|
1814
|
-
<div class="pie-tts-field">
|
|
1815
|
-
<label class="pie-tts-label" for="tts-polly-sample-rate">Sample rate</label>
|
|
1816
|
-
<select id="tts-polly-sample-rate" class="select select-sm select-bordered w-full" bind:value={pollySampleRate}>
|
|
1817
|
-
<option value={8000}>8000 Hz</option>
|
|
1818
|
-
<option value={16000}>16000 Hz</option>
|
|
1819
|
-
<option value={22050}>22050 Hz</option>
|
|
1820
|
-
<option value={24000}>24000 Hz</option>
|
|
1821
|
-
</select>
|
|
1822
|
-
</div>
|
|
1823
|
-
<div class="pie-tts-field">
|
|
1824
|
-
<label class="pie-tts-label" for="tts-polly-speech-marks">Speech marks</label>
|
|
1825
|
-
<select id="tts-polly-speech-marks" class="select select-sm select-bordered w-full" bind:value={pollySpeechMarksMode}>
|
|
1826
|
-
<option value="word">Word</option>
|
|
1827
|
-
<option value="word+sentence">Word + sentence</option>
|
|
1828
|
-
</select>
|
|
1829
|
-
</div>
|
|
1830
|
-
</div>
|
|
1831
|
-
</fieldset>
|
|
1832
|
-
{:else if activeTab === "google"}
|
|
1833
|
-
<fieldset class="pie-tts-fieldset fieldset bg-base-200 border border-base-300 rounded-box" disabled={!googleState.available}>
|
|
1834
|
-
<div class="pie-tts-field">
|
|
1835
|
-
<label class="pie-tts-label" for="tts-google-endpoint">API endpoint</label>
|
|
1836
|
-
<input id="tts-google-endpoint" class="input input-sm input-bordered w-full" bind:value={googleApiEndpoint} placeholder="/api/tts" />
|
|
1837
|
-
</div>
|
|
1838
|
-
|
|
1839
|
-
<div class="pie-tts-grid-3">
|
|
1840
|
-
<div class="pie-tts-field">
|
|
1841
|
-
<label class="pie-tts-label" for="tts-google-language">Language</label>
|
|
1842
|
-
<input
|
|
1843
|
-
id="tts-google-language"
|
|
1844
|
-
class="input input-sm input-bordered w-full"
|
|
1845
|
-
bind:value={googleLanguage}
|
|
1846
|
-
placeholder="en-US"
|
|
1847
|
-
onchange={refreshGoogleVoices}
|
|
1848
|
-
/>
|
|
1849
|
-
</div>
|
|
1850
|
-
<div class="pie-tts-field">
|
|
1851
|
-
<label class="pie-tts-label" for="tts-google-gender">Gender</label>
|
|
1852
|
-
<select
|
|
1853
|
-
id="tts-google-gender"
|
|
1854
|
-
class="select select-sm select-bordered w-full"
|
|
1855
|
-
bind:value={googleGender}
|
|
1856
|
-
onchange={refreshGoogleVoices}
|
|
1857
|
-
>
|
|
1858
|
-
<option value="">Any</option>
|
|
1859
|
-
<option value="male">Male</option>
|
|
1860
|
-
<option value="female">Female</option>
|
|
1861
|
-
<option value="neutral">Neutral</option>
|
|
1862
|
-
</select>
|
|
1863
|
-
</div>
|
|
1864
|
-
<div class="pie-tts-field">
|
|
1865
|
-
<label class="pie-tts-label" for="tts-google-voice-type">Voice type</label>
|
|
1866
|
-
<select
|
|
1867
|
-
id="tts-google-voice-type"
|
|
1868
|
-
class="select select-sm select-bordered w-full"
|
|
1869
|
-
bind:value={googleVoiceType}
|
|
1870
|
-
onchange={refreshGoogleVoices}
|
|
1871
|
-
>
|
|
1872
|
-
<option value="wavenet">WaveNet</option>
|
|
1873
|
-
<option value="studio">Studio</option>
|
|
1874
|
-
<option value="standard">Standard</option>
|
|
1875
|
-
</select>
|
|
1876
|
-
</div>
|
|
1877
|
-
</div>
|
|
1878
|
-
|
|
1879
|
-
<div class="pie-tts-field">
|
|
1880
|
-
<label class="pie-tts-label" for="tts-google-voice">Voice</label>
|
|
1881
|
-
<select id="tts-google-voice" class="select select-sm select-bordered w-full" bind:value={googleVoice}>
|
|
1882
|
-
<option value="">Provider default</option>
|
|
1883
|
-
{#each googleState.voices as voice}
|
|
1884
|
-
<option value={voice.id || voice.name || ""}>{voice.name || voice.id} ({voice.languageCode || "n/a"})</option>
|
|
1885
|
-
{/each}
|
|
1886
|
-
</select>
|
|
1887
|
-
</div>
|
|
1888
|
-
</fieldset>
|
|
1889
|
-
{:else if activeCustomProvider}
|
|
1890
|
-
<fieldset class="pie-tts-fieldset fieldset bg-base-200 border border-base-300 rounded-box" disabled={!getActiveState().available}>
|
|
1891
|
-
<div class="pie-tts-custom-provider-header">
|
|
1892
|
-
<div class="text-sm font-semibold">{activeCustomProvider.label}</div>
|
|
1893
|
-
{#if activeCustomProvider.description}
|
|
1894
|
-
<div class="text-xs opacity-75">{activeCustomProvider.description}</div>
|
|
1895
|
-
{/if}
|
|
1896
|
-
</div>
|
|
1897
|
-
{#if activeCustomProvider.mode === "component"}
|
|
1898
|
-
<svelte:element
|
|
1899
|
-
this={activeCustomProvider.tagName}
|
|
1900
|
-
class="pie-tts-custom-provider-element"
|
|
1901
|
-
bind:this={activeCustomProviderElement}
|
|
1902
|
-
{...resolveComponentProps(activeCustomProvider)}
|
|
1903
|
-
onchange={(event: Event) =>
|
|
1904
|
-
onCustomProviderChange(activeCustomProvider.id, event as unknown as CustomEvent)}
|
|
1905
|
-
/>
|
|
1906
|
-
{:else}
|
|
1907
|
-
<div class="text-xs opacity-75">
|
|
1908
|
-
This provider uses adapter mode. Configure provider state from your host app via
|
|
1909
|
-
`customProviders`.
|
|
1910
|
-
</div>
|
|
1911
|
-
{/if}
|
|
1912
|
-
</fieldset>
|
|
1913
|
-
{/if}
|
|
1914
|
-
|
|
1915
|
-
<div class="pie-tts-fieldset pie-tts-preview-block fieldset bg-base-200 border border-base-300 rounded-box">
|
|
1916
|
-
<div class="pie-tts-preview-header">
|
|
1917
|
-
<h4 class="pie-tts-preview-title">Preview</h4>
|
|
1918
|
-
<div class="join">
|
|
1919
|
-
<button
|
|
1920
|
-
type="button"
|
|
1921
|
-
class="btn btn-xs join-item"
|
|
1922
|
-
class:btn-active={previewMode === "plain"}
|
|
1923
|
-
onclick={() => onPreviewModeChange("plain")}
|
|
1924
|
-
>
|
|
1925
|
-
Plain text
|
|
1926
|
-
</button>
|
|
1927
|
-
<button
|
|
1928
|
-
type="button"
|
|
1929
|
-
class="btn btn-xs join-item"
|
|
1930
|
-
class:btn-active={previewMode === "ssml"}
|
|
1931
|
-
onclick={() => onPreviewModeChange("ssml")}
|
|
1932
|
-
>
|
|
1933
|
-
SSML
|
|
1934
|
-
</button>
|
|
1935
|
-
</div>
|
|
1936
|
-
</div>
|
|
1937
|
-
<label class="pie-tts-label" for="tts-preview-text">Sample text</label>
|
|
1938
|
-
<textarea
|
|
1939
|
-
id="tts-preview-text"
|
|
1940
|
-
class="textarea textarea-sm textarea-bordered w-full pie-tts-preview-input"
|
|
1941
|
-
bind:value={previewText}
|
|
1942
|
-
></textarea>
|
|
1943
|
-
<div class="pie-tts-preview-row">
|
|
1944
|
-
<button type="button" class="btn btn-xs btn-outline" onclick={setPreviewTextForCurrentTab}>
|
|
1945
|
-
Reset sample
|
|
1946
|
-
</button>
|
|
1947
|
-
<span class="text-xs opacity-70">
|
|
1948
|
-
{#if activeTab === "browser" && previewMode === "ssml"}
|
|
1949
|
-
SSML is unsupported in Browser preview.
|
|
1950
|
-
{:else if activeTab === "google" && previewMode === "ssml"}
|
|
1951
|
-
SSML preserved. Tracking disabled.
|
|
1952
|
-
{:else if !isBuiltInTab(activeTab)}
|
|
1953
|
-
Tracking enabled when provider returns speech marks.
|
|
1954
|
-
{:else}
|
|
1955
|
-
Tracking enabled while preview plays.
|
|
1956
|
-
{/if}
|
|
1957
|
-
</span>
|
|
1958
|
-
</div>
|
|
1959
|
-
<div class="pie-tts-preview-track" aria-live="polite">
|
|
1960
|
-
{#each getTrackingSegments(previewText) as segment}
|
|
1961
|
-
<span class:pie-tts-preview-active={segment.active}>{segment.text}</span>
|
|
1962
|
-
{/each}
|
|
1963
|
-
</div>
|
|
1964
|
-
</div>
|
|
1965
|
-
|
|
1966
|
-
{#if applyMessage}
|
|
1967
|
-
<div class="alert alert-success text-xs"><span>{applyMessage}</span></div>
|
|
1968
|
-
{/if}
|
|
1969
|
-
{#if applyError}
|
|
1970
|
-
<div class="alert alert-error text-xs"><span>{applyError}</span></div>
|
|
1971
|
-
{/if}
|
|
1972
|
-
{#if previewError}
|
|
1973
|
-
<div class="alert alert-error text-xs"><span>{previewError}</span></div>
|
|
1974
|
-
{/if}
|
|
1975
|
-
{#if previewNote}
|
|
1976
|
-
<div class="alert alert-info text-xs"><span>{previewNote}</span></div>
|
|
1977
|
-
{/if}
|
|
1978
|
-
|
|
1979
|
-
<div class="pie-tts-actions">
|
|
1980
|
-
<button class="btn btn-sm btn-outline" onclick={requestClose}>Close</button>
|
|
1981
|
-
<button
|
|
1982
|
-
class="btn btn-sm btn-outline"
|
|
1983
|
-
disabled={!getActiveState().available || isApplying || !canPreviewActiveTab()}
|
|
1984
|
-
onclick={() => void previewSelectedVoice()}
|
|
1985
|
-
>
|
|
1986
|
-
{isPreviewing && previewBackend === activeTab ? "Stop preview" : "Preview voice"}
|
|
1987
|
-
</button>
|
|
1988
|
-
<button class="btn btn-sm btn-primary" disabled={isApplying || !getActiveState().available} onclick={() => void applySettings()}>
|
|
1989
|
-
{isApplying ? "Applying..." : "Apply"}
|
|
1990
|
-
</button>
|
|
1991
|
-
</div>
|
|
1992
|
-
</div>
|
|
1993
|
-
</div>
|
|
1994
|
-
|
|
1995
|
-
<style>
|
|
1996
|
-
.pie-tts-dialog-backdrop {
|
|
1997
|
-
position: fixed;
|
|
1998
|
-
inset: 0;
|
|
1999
|
-
background: color-mix(in srgb, #000 30%, transparent);
|
|
2000
|
-
display: flex;
|
|
2001
|
-
align-items: center;
|
|
2002
|
-
justify-content: center;
|
|
2003
|
-
padding: 1rem;
|
|
2004
|
-
}
|
|
2005
|
-
|
|
2006
|
-
.pie-tts-dialog {
|
|
2007
|
-
width: min(720px, calc(100vw - 2rem));
|
|
2008
|
-
max-height: calc(100vh - 2rem);
|
|
2009
|
-
overflow: auto;
|
|
2010
|
-
background: var(--color-base-100);
|
|
2011
|
-
border: 1px solid var(--color-base-300);
|
|
2012
|
-
border-radius: 0.75rem;
|
|
2013
|
-
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.22);
|
|
2014
|
-
padding: 0.75rem;
|
|
2015
|
-
display: flex;
|
|
2016
|
-
flex-direction: column;
|
|
2017
|
-
gap: 0.5rem;
|
|
2018
|
-
}
|
|
2019
|
-
|
|
2020
|
-
.pie-tts-dialog-header {
|
|
2021
|
-
display: flex;
|
|
2022
|
-
align-items: center;
|
|
2023
|
-
justify-content: space-between;
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
.pie-tts-dialog-title {
|
|
2027
|
-
margin: 0;
|
|
2028
|
-
font-size: 0.95rem;
|
|
2029
|
-
font-weight: 700;
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
.pie-tts-tabs {
|
|
2033
|
-
width: 100%;
|
|
2034
|
-
max-width: 100%;
|
|
2035
|
-
flex-wrap: wrap;
|
|
2036
|
-
row-gap: 0.25rem;
|
|
2037
|
-
}
|
|
2038
|
-
|
|
2039
|
-
.pie-tts-status {
|
|
2040
|
-
display: flex;
|
|
2041
|
-
align-items: flex-start;
|
|
2042
|
-
gap: 0.5rem;
|
|
2043
|
-
justify-content: space-between;
|
|
2044
|
-
}
|
|
2045
|
-
|
|
2046
|
-
.pie-tts-status > span:not(.loading) {
|
|
2047
|
-
flex: 1;
|
|
2048
|
-
min-width: 0;
|
|
2049
|
-
font-size: 0.75rem;
|
|
2050
|
-
line-height: 1.35;
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
.pie-tts-ok {
|
|
2054
|
-
color: var(--color-success);
|
|
2055
|
-
}
|
|
2056
|
-
|
|
2057
|
-
.pie-tts-error {
|
|
2058
|
-
color: var(--color-error);
|
|
2059
|
-
}
|
|
2060
|
-
|
|
2061
|
-
.pie-tts-actions {
|
|
2062
|
-
display: flex;
|
|
2063
|
-
justify-content: flex-end;
|
|
2064
|
-
gap: 0.5rem;
|
|
2065
|
-
}
|
|
2066
|
-
|
|
2067
|
-
.pie-tts-fieldset {
|
|
2068
|
-
padding: 0.5rem 0.65rem;
|
|
2069
|
-
display: flex;
|
|
2070
|
-
flex-direction: column;
|
|
2071
|
-
gap: 0.45rem;
|
|
2072
|
-
}
|
|
2073
|
-
|
|
2074
|
-
.pie-tts-field {
|
|
2075
|
-
display: flex;
|
|
2076
|
-
flex-direction: column;
|
|
2077
|
-
gap: 0.15rem;
|
|
2078
|
-
min-width: 0;
|
|
2079
|
-
}
|
|
2080
|
-
|
|
2081
|
-
.pie-tts-label {
|
|
2082
|
-
display: block;
|
|
2083
|
-
font-size: 0.7rem;
|
|
2084
|
-
font-weight: 600;
|
|
2085
|
-
line-height: 1.2;
|
|
2086
|
-
opacity: 0.85;
|
|
2087
|
-
padding: 0;
|
|
2088
|
-
}
|
|
2089
|
-
|
|
2090
|
-
.pie-tts-grid-3 {
|
|
2091
|
-
display: grid;
|
|
2092
|
-
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
2093
|
-
gap: 0.45rem 0.65rem;
|
|
2094
|
-
align-items: end;
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
@media (max-width: 32rem) {
|
|
2098
|
-
.pie-tts-grid-3 {
|
|
2099
|
-
grid-template-columns: 1fr;
|
|
2100
|
-
}
|
|
2101
|
-
}
|
|
2102
|
-
|
|
2103
|
-
.pie-tts-range {
|
|
2104
|
-
--range-thumb-size: 0.85rem;
|
|
2105
|
-
height: 1.35rem;
|
|
2106
|
-
min-height: 1.35rem;
|
|
2107
|
-
}
|
|
2108
|
-
|
|
2109
|
-
.pie-tts-range-value {
|
|
2110
|
-
font-size: 0.65rem;
|
|
2111
|
-
line-height: 1.2;
|
|
2112
|
-
opacity: 0.7;
|
|
2113
|
-
margin-top: -0.1rem;
|
|
2114
|
-
}
|
|
2115
|
-
|
|
2116
|
-
.pie-tts-preview-block {
|
|
2117
|
-
gap: 0.35rem;
|
|
2118
|
-
}
|
|
2119
|
-
|
|
2120
|
-
.pie-tts-custom-provider-header {
|
|
2121
|
-
display: flex;
|
|
2122
|
-
flex-direction: column;
|
|
2123
|
-
gap: 0.2rem;
|
|
2124
|
-
margin-bottom: 0.25rem;
|
|
2125
|
-
}
|
|
2126
|
-
|
|
2127
|
-
.pie-tts-custom-provider-element {
|
|
2128
|
-
display: block;
|
|
2129
|
-
width: 100%;
|
|
2130
|
-
}
|
|
2131
|
-
|
|
2132
|
-
.pie-tts-preview-header {
|
|
2133
|
-
display: flex;
|
|
2134
|
-
align-items: center;
|
|
2135
|
-
justify-content: space-between;
|
|
2136
|
-
gap: 0.5rem;
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
.pie-tts-preview-title {
|
|
2140
|
-
margin: 0;
|
|
2141
|
-
font-size: 0.85rem;
|
|
2142
|
-
font-weight: 700;
|
|
2143
|
-
}
|
|
2144
|
-
|
|
2145
|
-
.pie-tts-preview-input {
|
|
2146
|
-
min-height: 4.25rem;
|
|
2147
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
|
2148
|
-
"Courier New", monospace;
|
|
2149
|
-
}
|
|
2150
|
-
|
|
2151
|
-
.pie-tts-preview-row {
|
|
2152
|
-
display: flex;
|
|
2153
|
-
align-items: center;
|
|
2154
|
-
justify-content: space-between;
|
|
2155
|
-
gap: 0.5rem;
|
|
2156
|
-
}
|
|
2157
|
-
|
|
2158
|
-
.pie-tts-preview-track {
|
|
2159
|
-
margin-top: 0.15rem;
|
|
2160
|
-
border: 1px dashed var(--color-base-300);
|
|
2161
|
-
border-radius: 0.5rem;
|
|
2162
|
-
padding: 0.4rem 0.5rem;
|
|
2163
|
-
min-height: 2.6rem;
|
|
2164
|
-
white-space: pre-wrap;
|
|
2165
|
-
font-size: 0.75rem;
|
|
2166
|
-
line-height: 1.4;
|
|
2167
|
-
}
|
|
2168
|
-
|
|
2169
|
-
.pie-tts-preview-active {
|
|
2170
|
-
background: color-mix(in srgb, var(--color-warning) 40%, transparent);
|
|
2171
|
-
border-radius: 0.15rem;
|
|
2172
|
-
}
|
|
2173
|
-
</style>
|