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