@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.
@@ -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>