@pie-players/pie-section-player-tools-tts-settings 0.3.20 → 0.3.22

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.
@@ -44,6 +44,13 @@
44
44
  contentType?: string;
45
45
  speechMarks?: Array<{ time: number; start: number; end: number }>;
46
46
  };
47
+ type PreviewSpeechMark = { time: number; start: number; end: number; value?: string };
48
+ type CustomProviderPreviewResult = {
49
+ note?: string;
50
+ audioUrl?: string;
51
+ speechMarks?: PreviewSpeechMark[];
52
+ trackingText?: string;
53
+ };
47
54
 
48
55
  type TtsSettingsAdapters = {
49
56
  fetchPollyVoices?: (args: {
@@ -83,6 +90,8 @@
83
90
  toolkitCoordinator: any;
84
91
  apiEndpoint: string;
85
92
  state: Record<string, unknown>;
93
+ previewText?: string;
94
+ previewMode?: PreviewMode;
86
95
  };
87
96
 
88
97
  type CustomProviderAdapter = {
@@ -96,7 +105,7 @@
96
105
  buildApplyConfig: (
97
106
  context: CustomProviderContext
98
107
  ) => ProviderApplyResult | Promise<ProviderApplyResult>;
99
- preview?: (context: CustomProviderContext) => Promise<void | { note?: string }>;
108
+ preview?: (context: CustomProviderContext) => Promise<void | CustomProviderPreviewResult>;
100
109
  initialState?: Record<string, unknown>;
101
110
  };
102
111
 
@@ -113,7 +122,7 @@
113
122
  buildApplyConfig?: (
114
123
  context: CustomProviderContext
115
124
  ) => ProviderApplyResult | Promise<ProviderApplyResult>;
116
- preview?: (context: CustomProviderContext) => Promise<void | { note?: string }>;
125
+ preview?: (context: CustomProviderContext) => Promise<void | CustomProviderPreviewResult>;
117
126
  initialState?: Record<string, unknown>;
118
127
  };
119
128
 
@@ -223,6 +232,48 @@
223
232
  let previewPollingTimer: number | null = null;
224
233
  let previewRunId = 0;
225
234
  let activeCustomProviderElement = $state<Element | null>(null);
235
+ let dialogEl = $state<HTMLElement | null>(null);
236
+ let closeButtonEl = $state<HTMLButtonElement | null>(null);
237
+ let cleanupFocusTrap: (() => void) | null = null;
238
+
239
+ function createLocalFocusTrap(
240
+ container: HTMLElement,
241
+ options: { initialFocus?: HTMLElement | null; onEscape?: () => void } = {}
242
+ ): () => void {
243
+ const focusable = () =>
244
+ Array.from(
245
+ container.querySelectorAll<HTMLElement>(
246
+ "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
247
+ ),
248
+ ).filter((el) => el.offsetParent !== null || el.getClientRects().length > 0);
249
+ const previous = document.activeElement as HTMLElement | null;
250
+ const onKeyDown = (event: KeyboardEvent) => {
251
+ if (event.key === "Escape") {
252
+ options.onEscape?.();
253
+ return;
254
+ }
255
+ if (event.key !== "Tab") return;
256
+ const nodes = focusable();
257
+ if (!nodes.length) return;
258
+ const current = document.activeElement as HTMLElement | null;
259
+ const currentIndex = nodes.indexOf(current || nodes[0]);
260
+ if (event.shiftKey && currentIndex <= 0) {
261
+ event.preventDefault();
262
+ nodes[nodes.length - 1].focus();
263
+ } else if (!event.shiftKey && currentIndex === nodes.length - 1) {
264
+ event.preventDefault();
265
+ nodes[0].focus();
266
+ }
267
+ };
268
+ container.addEventListener("keydown", onKeyDown);
269
+ queueMicrotask(() => {
270
+ (options.initialFocus || focusable()[0] || container)?.focus?.();
271
+ });
272
+ return () => {
273
+ container.removeEventListener("keydown", onKeyDown);
274
+ previous?.focus?.();
275
+ };
276
+ }
226
277
 
227
278
  const DEFAULT_PREVIEW_TEXT: Record<BackendTab, string> = {
228
279
  browser:
@@ -239,6 +290,16 @@
239
290
  '<speak>This is a <prosody rate="95%">Google Cloud SSML sample</prosody>. <break time="250ms"/> The preview preserves authored SSML.</speak>'
240
291
  };
241
292
  const BUILT_IN_TABS: BuiltInBackendTab[] = ["browser", "polly", "google"];
293
+ const PREVIEW_DEBUG_PREFIX = "[pie-tts-preview]";
294
+
295
+ function debugPreview(event: string, payload?: Record<string, unknown>): void {
296
+ if (typeof console === "undefined") return;
297
+ if (payload) {
298
+ console.debug(`${PREVIEW_DEBUG_PREFIX} ${event}`, payload);
299
+ return;
300
+ }
301
+ console.debug(`${PREVIEW_DEBUG_PREFIX} ${event}`);
302
+ }
242
303
 
243
304
  const normalizedCustomProviders = $derived.by(() => {
244
305
  const reserved = new Set<string>(BUILT_IN_TABS);
@@ -272,12 +333,16 @@
272
333
  dispatch("close");
273
334
  }
274
335
 
275
- function createProviderContext(providerId: string): CustomProviderContext {
336
+ function createProviderContext(
337
+ providerId: string,
338
+ overrides: Partial<CustomProviderContext> = {}
339
+ ): CustomProviderContext {
276
340
  return {
277
341
  id: providerId,
278
342
  toolkitCoordinator,
279
343
  apiEndpoint: getDefaultApiEndpoint(),
280
- state: customProviderStateById[providerId] || {}
344
+ state: customProviderStateById[providerId] || {},
345
+ ...overrides
281
346
  };
282
347
  }
283
348
 
@@ -430,6 +495,10 @@
430
495
  typeof source?.apiEndpoint === "string" && source.apiEndpoint.trim().length > 0
431
496
  ? source.apiEndpoint
432
497
  : resolvedDefaultApiEndpoint;
498
+ const defaultPollyEndpoint =
499
+ backend === "polly" ? defaultEndpoint : resolvedDefaultApiEndpoint;
500
+ const defaultGoogleEndpoint =
501
+ backend === "google" ? defaultEndpoint : resolvedDefaultApiEndpoint;
433
502
  const defaultLanguage =
434
503
  typeof source?.language === "string" && source.language.trim().length > 0
435
504
  ? source.language
@@ -469,7 +538,7 @@
469
538
  browserRate = defaultRate;
470
539
  browserPitch = defaultPitch;
471
540
 
472
- pollyApiEndpoint = defaultEndpoint;
541
+ pollyApiEndpoint = defaultPollyEndpoint;
473
542
  pollyLanguage = defaultLanguage;
474
543
  pollyEngine = defaultEngine;
475
544
  pollySampleRate = defaultSampleRate;
@@ -478,7 +547,7 @@
478
547
  pollyVoice = backend === "polly" ? defaultVoice : "";
479
548
  pollyRate = defaultRate;
480
549
 
481
- googleApiEndpoint = defaultEndpoint;
550
+ googleApiEndpoint = defaultGoogleEndpoint;
482
551
  googleLanguage = defaultLanguage;
483
552
  googleGender = typeof source?.googleGender === "string" ? source.googleGender : "";
484
553
  googleVoiceType =
@@ -798,9 +867,7 @@
798
867
  if (activeTab === nextTab) return;
799
868
  stopPreview();
800
869
  activeTab = nextTab;
801
- if (isBuiltInTab(nextTab)) {
802
- setPreviewTextForCurrentTab();
803
- }
870
+ setPreviewTextForCurrentTab();
804
871
  void checkActiveTabAvailability();
805
872
  }
806
873
 
@@ -902,10 +969,12 @@
902
969
 
903
970
  function setPreviewTextForCurrentTab() {
904
971
  if (!isBuiltInTab(activeTab)) {
972
+ const customLabel = activeCustomProvider?.label || "custom";
973
+ const customSample = `This is a ${customLabel} TTS provider sample.`;
905
974
  if (previewMode === "ssml") {
906
- previewText = "";
907
- } else if (!previewText.trim()) {
908
- previewText = "This is a custom TTS provider voice sample.";
975
+ previewText = `<speak>${customSample}</speak>`;
976
+ } else {
977
+ previewText = customSample;
909
978
  }
910
979
  return;
911
980
  }
@@ -936,19 +1005,50 @@
936
1005
  if (previewPollingTimer !== null) {
937
1006
  window.clearInterval(previewPollingTimer);
938
1007
  }
939
- let lastIndex = -1;
1008
+ const orderedMarks = [...speechMarks].sort((left, right) => {
1009
+ if (left.time !== right.time) return left.time - right.time;
1010
+ if (left.start !== right.start) return left.start - right.start;
1011
+ return left.end - right.end;
1012
+ });
1013
+ if (orderedMarks.length === 0) return;
1014
+ const firstMark = orderedMarks[0];
1015
+ debugPreview("tracking:init", {
1016
+ audioCurrentMs: Math.round(audio.currentTime * 1000),
1017
+ marks: orderedMarks.length,
1018
+ firstMark,
1019
+ firstSlice: previewText.slice(
1020
+ Math.max(0, firstMark.start),
1021
+ Math.max(firstMark.start, firstMark.end)
1022
+ )
1023
+ });
1024
+ // Ensure preview starts on the first spoken word instead of waiting for the first poll tick.
1025
+ previewTrackIndex = firstMark.start;
1026
+ previewTrackLength = Math.max(1, firstMark.end - firstMark.start);
1027
+ let lastIndex = 0;
1028
+ let emittedTransitions = 0;
940
1029
  previewPollingTimer = window.setInterval(() => {
941
1030
  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
- }
1031
+ let nextIndex = lastIndex;
1032
+ while (
1033
+ nextIndex + 1 < orderedMarks.length &&
1034
+ currentMs >= orderedMarks[nextIndex + 1].time
1035
+ ) {
1036
+ nextIndex += 1;
1037
+ }
1038
+ if (nextIndex !== lastIndex) {
1039
+ lastIndex = nextIndex;
1040
+ const mark = orderedMarks[lastIndex];
1041
+ previewTrackIndex = mark.start;
1042
+ previewTrackLength = Math.max(1, mark.end - mark.start);
1043
+ if (emittedTransitions < 12) {
1044
+ emittedTransitions += 1;
1045
+ debugPreview("tracking:step", {
1046
+ audioCurrentMs: Math.round(currentMs),
1047
+ index: lastIndex,
1048
+ mark,
1049
+ slice: previewText.slice(Math.max(0, mark.start), Math.max(mark.start, mark.end))
1050
+ });
1051
+ }
952
1052
  }
953
1053
  }, 40);
954
1054
  }
@@ -969,6 +1069,149 @@
969
1069
  previewBackend = null;
970
1070
  }
971
1071
 
1072
+ function normalizePreviewSpeechMarks(
1073
+ speechMarks: PreviewSpeechMark[],
1074
+ audioDurationSeconds: number
1075
+ ): PreviewSpeechMark[] {
1076
+ if (speechMarks.length === 0) return speechMarks;
1077
+ const ordered = [...speechMarks].sort((left, right) => {
1078
+ if (left.time !== right.time) return left.time - right.time;
1079
+ if (left.start !== right.start) return left.start - right.start;
1080
+ return left.end - right.end;
1081
+ });
1082
+ const times = ordered.map((mark) => Number(mark.time) || 0).filter((value) => value >= 0);
1083
+ const maxTime = times.length ? Math.max(...times) : 0;
1084
+ const deltas: number[] = [];
1085
+ for (let index = 1; index < times.length; index += 1) {
1086
+ const delta = times[index] - times[index - 1];
1087
+ if (Number.isFinite(delta) && delta > 0) deltas.push(delta);
1088
+ }
1089
+ const medianDelta =
1090
+ deltas.length > 0
1091
+ ? [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]
1092
+ : 0;
1093
+ const durationSuggestsSeconds =
1094
+ Number.isFinite(audioDurationSeconds) &&
1095
+ audioDurationSeconds > 0 &&
1096
+ maxTime > 0 &&
1097
+ maxTime <= audioDurationSeconds * 1.5;
1098
+ // SC marks are commonly seconds; when metadata is unavailable, use time-shape heuristics.
1099
+ const shapeSuggestsSeconds =
1100
+ (maxTime > 0 && maxTime < 100 && ordered.length > 3) || (medianDelta > 0 && medianDelta < 10);
1101
+ const shouldConvertSecondsToMs = durationSuggestsSeconds || shapeSuggestsSeconds;
1102
+ if (!shouldConvertSecondsToMs) return ordered;
1103
+ return ordered.map((mark) => ({ ...mark, time: Number(mark.time) * 1000 }));
1104
+ }
1105
+
1106
+ function normalizePreviewSpeechMarkOffsets(
1107
+ speechMarks: PreviewSpeechMark[],
1108
+ trackingText: string
1109
+ ): PreviewSpeechMark[] {
1110
+ if (speechMarks.length === 0) return speechMarks;
1111
+ const ordered = [...speechMarks].sort((left, right) => {
1112
+ if (left.time !== right.time) return left.time - right.time;
1113
+ if (left.start !== right.start) return left.start - right.start;
1114
+ return left.end - right.end;
1115
+ });
1116
+ const safeText = typeof trackingText === "string" ? trackingText : "";
1117
+ const textLength = safeText.length;
1118
+ if (!textLength) return ordered;
1119
+ const maxEnd = Math.max(...ordered.map((mark) => Number(mark.end) || 0));
1120
+ const firstMark = ordered[0];
1121
+ const firstWord =
1122
+ typeof firstMark.value === "string" ? firstMark.value.trim() : "";
1123
+ const firstWordIndex = firstWord
1124
+ ? safeText.toLowerCase().indexOf(firstWord.toLowerCase())
1125
+ : -1;
1126
+ const anchoredShift =
1127
+ firstWordIndex >= 0 ? Number(firstMark.start || 0) - firstWordIndex : 0;
1128
+ const fallbackShift = Number(firstMark.start || 0);
1129
+ const shouldRebase = maxEnd > textLength + 2 && (anchoredShift > 0 || fallbackShift > 0);
1130
+ if (!shouldRebase) return ordered;
1131
+ const shift = anchoredShift > 0 ? anchoredShift : fallbackShift;
1132
+ const rebased = ordered.map((mark) => {
1133
+ const start = Math.max(0, Number(mark.start || 0) - shift);
1134
+ const end = Math.max(start + 1, Number(mark.end || 0) - shift);
1135
+ return { ...mark, start, end };
1136
+ });
1137
+ debugPreview("marks:offset-rebase", {
1138
+ textLength,
1139
+ firstWord,
1140
+ firstWordIndex,
1141
+ shift,
1142
+ firstBefore: firstMark,
1143
+ firstAfter: rebased[0],
1144
+ lastAfter: rebased[rebased.length - 1]
1145
+ });
1146
+ return rebased;
1147
+ }
1148
+
1149
+ async function playPreviewAudioFromUrl(
1150
+ audioUrl: string,
1151
+ speechMarks: PreviewSpeechMark[] = []
1152
+ ): Promise<void> {
1153
+ const audio = new Audio(audioUrl);
1154
+ currentPreviewAudio = audio;
1155
+ if (speechMarks.length > 0) {
1156
+ await new Promise<void>((resolve) => {
1157
+ if (Number.isFinite(audio.duration) && audio.duration > 0) {
1158
+ resolve();
1159
+ return;
1160
+ }
1161
+ let settled = false;
1162
+ const finish = () => {
1163
+ if (settled) return;
1164
+ settled = true;
1165
+ audio.removeEventListener("loadedmetadata", finish);
1166
+ audio.removeEventListener("error", finish);
1167
+ resolve();
1168
+ };
1169
+ audio.addEventListener("loadedmetadata", finish, { once: true });
1170
+ audio.addEventListener("error", finish, { once: true });
1171
+ if (typeof window !== "undefined") {
1172
+ window.setTimeout(finish, 1200);
1173
+ }
1174
+ });
1175
+ const audioDurationSeconds = Number(audio.duration);
1176
+ const normalizedMarks = normalizePreviewSpeechMarks(speechMarks, audioDurationSeconds);
1177
+ const offsetNormalizedMarks = normalizePreviewSpeechMarkOffsets(normalizedMarks, previewText);
1178
+ const rawMaxTime = Math.max(...speechMarks.map((mark) => Number(mark.time) || 0));
1179
+ const normalizedMaxTime = Math.max(...offsetNormalizedMarks.map((mark) => Number(mark.time) || 0));
1180
+ debugPreview("marks:normalize", {
1181
+ audioDurationSeconds,
1182
+ rawCount: speechMarks.length,
1183
+ normalizedCount: offsetNormalizedMarks.length,
1184
+ rawMaxTime,
1185
+ normalizedMaxTime,
1186
+ rawFirst: speechMarks[0],
1187
+ normalizedFirst: offsetNormalizedMarks[0],
1188
+ rawLast: speechMarks[speechMarks.length - 1],
1189
+ normalizedLast: offsetNormalizedMarks[offsetNormalizedMarks.length - 1]
1190
+ });
1191
+ updateTrackingFromSpeechMarks(audio, offsetNormalizedMarks);
1192
+ } else {
1193
+ debugPreview("marks:missing", { audioUrl });
1194
+ }
1195
+ await new Promise<void>((resolve, reject) => {
1196
+ audio.onended = () => {
1197
+ if (previewPollingTimer !== null) {
1198
+ window.clearInterval(previewPollingTimer);
1199
+ previewPollingTimer = null;
1200
+ }
1201
+ resolve();
1202
+ };
1203
+ audio.onerror = () => {
1204
+ if (previewPollingTimer !== null) {
1205
+ window.clearInterval(previewPollingTimer);
1206
+ previewPollingTimer = null;
1207
+ }
1208
+ reject(new Error("Failed to play preview audio."));
1209
+ };
1210
+ void audio.play().catch(reject);
1211
+ });
1212
+ currentPreviewAudio = null;
1213
+ }
1214
+
972
1215
  async function previewServerVoice(provider: "polly" | "google") {
973
1216
  const endpoint = normalizeApiEndpoint(
974
1217
  provider === "polly" ? pollyApiEndpoint : googleApiEndpoint,
@@ -1115,8 +1358,27 @@
1115
1358
  try {
1116
1359
  if (!isBuiltInTab(activeTab)) {
1117
1360
  const provider = getCustomProviderOrThrow(activeTab);
1118
- const result = await provider.preview?.(createProviderContext(provider.id));
1119
- previewNote = result?.note || "Custom provider preview completed.";
1361
+ const result = await provider.preview?.(
1362
+ createProviderContext(provider.id, {
1363
+ previewText,
1364
+ previewMode
1365
+ })
1366
+ );
1367
+ const customResult = (result || null) as CustomProviderPreviewResult | null;
1368
+ if (customResult?.audioUrl && typeof customResult.audioUrl === "string") {
1369
+ const speechMarks = Array.isArray(customResult.speechMarks)
1370
+ ? customResult.speechMarks
1371
+ : [];
1372
+ const trackingText =
1373
+ typeof customResult.trackingText === "string"
1374
+ ? customResult.trackingText
1375
+ : null;
1376
+ if (trackingText) {
1377
+ previewText = trackingText;
1378
+ }
1379
+ await playPreviewAudioFromUrl(customResult.audioUrl, speechMarks);
1380
+ }
1381
+ previewNote = result?.note || null;
1120
1382
  } else if (activeTab === "browser") {
1121
1383
  await previewBrowserVoice();
1122
1384
  } else if (activeTab === "polly") {
@@ -1138,6 +1400,7 @@
1138
1400
  }
1139
1401
  } finally {
1140
1402
  if (runId === previewRunId) {
1403
+ clearPreviewTracking();
1141
1404
  isPreviewing = false;
1142
1405
  previewBackend = null;
1143
1406
  }
@@ -1258,6 +1521,22 @@
1258
1521
  void checkActiveTabAvailability();
1259
1522
  });
1260
1523
 
1524
+ $effect(() => {
1525
+ if (!dialogEl) return;
1526
+ cleanupFocusTrap?.();
1527
+ cleanupFocusTrap = createLocalFocusTrap(dialogEl, {
1528
+ initialFocus: closeButtonEl,
1529
+ onEscape: requestClose
1530
+ });
1531
+ queueMicrotask(() => {
1532
+ closeButtonEl?.focus?.();
1533
+ });
1534
+ return () => {
1535
+ cleanupFocusTrap?.();
1536
+ cleanupFocusTrap = null;
1537
+ };
1538
+ });
1539
+
1261
1540
  $effect(() => {
1262
1541
  void normalizedCustomProviders;
1263
1542
  untrack(() => {
@@ -1287,15 +1566,30 @@
1287
1566
  });
1288
1567
 
1289
1568
  onDestroy(() => {
1569
+ cleanupFocusTrap?.();
1570
+ cleanupFocusTrap = null;
1290
1571
  stopPreview();
1291
1572
  });
1292
1573
  </script>
1293
1574
 
1294
1575
  <div class="pie-tts-dialog-backdrop" style="z-index: {TTS_MODAL_Z_INDEX};">
1295
- <div class="pie-tts-dialog">
1576
+ <div
1577
+ class="pie-tts-dialog"
1578
+ bind:this={dialogEl}
1579
+ role="dialog"
1580
+ aria-modal="true"
1581
+ aria-labelledby="pie-tts-dialog-title"
1582
+ tabindex="-1"
1583
+ >
1296
1584
  <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">
1585
+ <h3 id="pie-tts-dialog-title" class="pie-tts-dialog-title">TTS settings</h3>
1586
+ <button
1587
+ class="btn btn-xs btn-ghost btn-circle"
1588
+ bind:this={closeButtonEl}
1589
+ type="button"
1590
+ onclick={requestClose}
1591
+ aria-label="Close TTS settings"
1592
+ >
1299
1593
  <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1300
1594
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
1301
1595
  </svg>
@@ -1345,18 +1639,6 @@
1345
1639
  {/each}
1346
1640
  </select>
1347
1641
  </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
1642
  </fieldset>
1361
1643
  {:else if activeTab === "polly"}
1362
1644
  <fieldset class="pie-tts-fieldset fieldset bg-base-200 border border-base-300 rounded-box" disabled={!pollyState.available}>
@@ -1404,21 +1686,14 @@
1404
1686
  </div>
1405
1687
  </div>
1406
1688
 
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>
1689
+ <div class="pie-tts-field">
1690
+ <label class="pie-tts-label" for="tts-polly-voice">Voice</label>
1691
+ <select id="tts-polly-voice" class="select select-sm select-bordered w-full" bind:value={pollyVoice}>
1692
+ <option value="">Provider default</option>
1693
+ {#each pollyState.voices as voice}
1694
+ <option value={voice.id || voice.name || ""}>{voice.name || voice.id} ({voice.languageCode || "n/a"})</option>
1695
+ {/each}
1696
+ </select>
1422
1697
  </div>
1423
1698
 
1424
1699
  <div class="pie-tts-grid-3">
@@ -1495,21 +1770,14 @@
1495
1770
  </div>
1496
1771
  </div>
1497
1772
 
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>
1773
+ <div class="pie-tts-field">
1774
+ <label class="pie-tts-label" for="tts-google-voice">Voice</label>
1775
+ <select id="tts-google-voice" class="select select-sm select-bordered w-full" bind:value={googleVoice}>
1776
+ <option value="">Provider default</option>
1777
+ {#each googleState.voices as voice}
1778
+ <option value={voice.id || voice.name || ""}>{voice.name || voice.id} ({voice.languageCode || "n/a"})</option>
1779
+ {/each}
1780
+ </select>
1513
1781
  </div>
1514
1782
  </fieldset>
1515
1783
  {:else if activeCustomProvider}
@@ -1575,6 +1843,8 @@
1575
1843
  SSML is unsupported in Browser preview.
1576
1844
  {:else if activeTab === "google" && previewMode === "ssml"}
1577
1845
  SSML preserved. Tracking disabled.
1846
+ {:else if !isBuiltInTab(activeTab)}
1847
+ Tracking enabled when provider returns speech marks.
1578
1848
  {:else}
1579
1849
  Tracking enabled while preview plays.
1580
1850
  {/if}
@@ -1711,13 +1981,6 @@
1711
1981
  padding: 0;
1712
1982
  }
1713
1983
 
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
1984
  .pie-tts-grid-3 {
1722
1985
  display: grid;
1723
1986
  grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1725,17 +1988,8 @@
1725
1988
  align-items: end;
1726
1989
  }
1727
1990
 
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
1991
  @media (max-width: 32rem) {
1736
- .pie-tts-grid-2,
1737
- .pie-tts-grid-3,
1738
- .pie-tts-grid-voice-rate {
1992
+ .pie-tts-grid-3 {
1739
1993
  grid-template-columns: 1fr;
1740
1994
  }
1741
1995
  }