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

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") {
@@ -1258,6 +1520,22 @@
1258
1520
  void checkActiveTabAvailability();
1259
1521
  });
1260
1522
 
1523
+ $effect(() => {
1524
+ if (!dialogEl) return;
1525
+ cleanupFocusTrap?.();
1526
+ cleanupFocusTrap = createLocalFocusTrap(dialogEl, {
1527
+ initialFocus: closeButtonEl,
1528
+ onEscape: requestClose
1529
+ });
1530
+ queueMicrotask(() => {
1531
+ closeButtonEl?.focus?.();
1532
+ });
1533
+ return () => {
1534
+ cleanupFocusTrap?.();
1535
+ cleanupFocusTrap = null;
1536
+ };
1537
+ });
1538
+
1261
1539
  $effect(() => {
1262
1540
  void normalizedCustomProviders;
1263
1541
  untrack(() => {
@@ -1287,15 +1565,30 @@
1287
1565
  });
1288
1566
 
1289
1567
  onDestroy(() => {
1568
+ cleanupFocusTrap?.();
1569
+ cleanupFocusTrap = null;
1290
1570
  stopPreview();
1291
1571
  });
1292
1572
  </script>
1293
1573
 
1294
1574
  <div class="pie-tts-dialog-backdrop" style="z-index: {TTS_MODAL_Z_INDEX};">
1295
- <div class="pie-tts-dialog">
1575
+ <div
1576
+ class="pie-tts-dialog"
1577
+ bind:this={dialogEl}
1578
+ role="dialog"
1579
+ aria-modal="true"
1580
+ aria-labelledby="pie-tts-dialog-title"
1581
+ tabindex="-1"
1582
+ >
1296
1583
  <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">
1584
+ <h3 id="pie-tts-dialog-title" class="pie-tts-dialog-title">TTS settings</h3>
1585
+ <button
1586
+ class="btn btn-xs btn-ghost btn-circle"
1587
+ bind:this={closeButtonEl}
1588
+ type="button"
1589
+ onclick={requestClose}
1590
+ aria-label="Close TTS settings"
1591
+ >
1299
1592
  <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1300
1593
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
1301
1594
  </svg>
@@ -1345,18 +1638,6 @@
1345
1638
  {/each}
1346
1639
  </select>
1347
1640
  </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
1641
  </fieldset>
1361
1642
  {:else if activeTab === "polly"}
1362
1643
  <fieldset class="pie-tts-fieldset fieldset bg-base-200 border border-base-300 rounded-box" disabled={!pollyState.available}>
@@ -1404,21 +1685,14 @@
1404
1685
  </div>
1405
1686
  </div>
1406
1687
 
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>
1688
+ <div class="pie-tts-field">
1689
+ <label class="pie-tts-label" for="tts-polly-voice">Voice</label>
1690
+ <select id="tts-polly-voice" class="select select-sm select-bordered w-full" bind:value={pollyVoice}>
1691
+ <option value="">Provider default</option>
1692
+ {#each pollyState.voices as voice}
1693
+ <option value={voice.id || voice.name || ""}>{voice.name || voice.id} ({voice.languageCode || "n/a"})</option>
1694
+ {/each}
1695
+ </select>
1422
1696
  </div>
1423
1697
 
1424
1698
  <div class="pie-tts-grid-3">
@@ -1495,21 +1769,14 @@
1495
1769
  </div>
1496
1770
  </div>
1497
1771
 
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>
1772
+ <div class="pie-tts-field">
1773
+ <label class="pie-tts-label" for="tts-google-voice">Voice</label>
1774
+ <select id="tts-google-voice" class="select select-sm select-bordered w-full" bind:value={googleVoice}>
1775
+ <option value="">Provider default</option>
1776
+ {#each googleState.voices as voice}
1777
+ <option value={voice.id || voice.name || ""}>{voice.name || voice.id} ({voice.languageCode || "n/a"})</option>
1778
+ {/each}
1779
+ </select>
1513
1780
  </div>
1514
1781
  </fieldset>
1515
1782
  {:else if activeCustomProvider}
@@ -1575,6 +1842,8 @@
1575
1842
  SSML is unsupported in Browser preview.
1576
1843
  {:else if activeTab === "google" && previewMode === "ssml"}
1577
1844
  SSML preserved. Tracking disabled.
1845
+ {:else if !isBuiltInTab(activeTab)}
1846
+ Tracking enabled when provider returns speech marks.
1578
1847
  {:else}
1579
1848
  Tracking enabled while preview plays.
1580
1849
  {/if}
@@ -1711,13 +1980,6 @@
1711
1980
  padding: 0;
1712
1981
  }
1713
1982
 
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
1983
  .pie-tts-grid-3 {
1722
1984
  display: grid;
1723
1985
  grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1725,17 +1987,8 @@
1725
1987
  align-items: end;
1726
1988
  }
1727
1989
 
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
1990
  @media (max-width: 32rem) {
1736
- .pie-tts-grid-2,
1737
- .pie-tts-grid-3,
1738
- .pie-tts-grid-voice-rate {
1991
+ .pie-tts-grid-3 {
1739
1992
  grid-template-columns: 1fr;
1740
1993
  }
1741
1994
  }