@pie-players/tts-client-server 0.2.0
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/.turbo/turbo-build.log +1 -0
- package/CHANGELOG.md +78 -0
- package/README.md +219 -0
- package/dist/ServerTTSProvider.d.ts +64 -0
- package/dist/ServerTTSProvider.d.ts.map +1 -0
- package/dist/ServerTTSProvider.js +363 -0
- package/dist/ServerTTSProvider.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
- package/src/ServerTTSProvider.ts +528 -0
- package/src/index.ts +7 -0
- package/tsconfig.json +24 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServerTTSProvider - Client-side TTS provider that calls server API
|
|
3
|
+
*
|
|
4
|
+
* Provides high-quality TTS by calling a server-side API that uses
|
|
5
|
+
* providers like AWS Polly, Google Cloud TTS, etc.
|
|
6
|
+
* Returns audio with precise word-level timing (speech marks).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
ITTSProvider,
|
|
11
|
+
ITTSProviderImplementation,
|
|
12
|
+
TTSConfig,
|
|
13
|
+
TTSFeature,
|
|
14
|
+
TTSProviderCapabilities,
|
|
15
|
+
} from "@pie-players/pie-tts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Configuration for ServerTTSProvider
|
|
19
|
+
*/
|
|
20
|
+
export interface ServerTTSProviderConfig extends TTSConfig {
|
|
21
|
+
/** API endpoint base URL (e.g., '/api/tts' or 'https://api.example.com/tts') */
|
|
22
|
+
apiEndpoint: string;
|
|
23
|
+
|
|
24
|
+
/** Provider to use on server ('polly', 'google', 'elevenlabs', etc.) */
|
|
25
|
+
provider?: string;
|
|
26
|
+
|
|
27
|
+
/** Authentication token or API key */
|
|
28
|
+
authToken?: string;
|
|
29
|
+
|
|
30
|
+
/** Custom headers for API requests */
|
|
31
|
+
headers?: Record<string, string>;
|
|
32
|
+
|
|
33
|
+
/** Language code */
|
|
34
|
+
language?: string;
|
|
35
|
+
|
|
36
|
+
/** Volume level 0-1 */
|
|
37
|
+
volume?: number;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Validate API endpoint availability during initialization (slower but safer)
|
|
41
|
+
*
|
|
42
|
+
* @extension Performance vs safety tradeoff
|
|
43
|
+
* @default false (fast initialization, fail on first synthesis if unavailable)
|
|
44
|
+
* @note When true, adds 100-500ms to initialization time
|
|
45
|
+
*/
|
|
46
|
+
validateEndpoint?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Word timing from speech marks
|
|
51
|
+
*/
|
|
52
|
+
interface WordTiming {
|
|
53
|
+
time: number; // Milliseconds from audio start
|
|
54
|
+
wordIndex: number;
|
|
55
|
+
charIndex: number; // Character position in text
|
|
56
|
+
length: number; // Word length in characters
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Server API response for synthesis
|
|
61
|
+
*/
|
|
62
|
+
interface SynthesizeAPIResponse {
|
|
63
|
+
audio: string; // Base64 encoded audio
|
|
64
|
+
contentType: string;
|
|
65
|
+
speechMarks: Array<{
|
|
66
|
+
time: number;
|
|
67
|
+
type: string;
|
|
68
|
+
start: number;
|
|
69
|
+
end: number;
|
|
70
|
+
value: string;
|
|
71
|
+
}>;
|
|
72
|
+
metadata: {
|
|
73
|
+
providerId: string;
|
|
74
|
+
voice: string;
|
|
75
|
+
duration: number;
|
|
76
|
+
charCount: number;
|
|
77
|
+
cached: boolean;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Provider implementation that handles audio playback
|
|
83
|
+
*/
|
|
84
|
+
class ServerTTSProviderImpl implements ITTSProviderImplementation {
|
|
85
|
+
private config: ServerTTSProviderConfig;
|
|
86
|
+
private currentAudio: HTMLAudioElement | null = null;
|
|
87
|
+
private pausedState = false;
|
|
88
|
+
private wordTimings: WordTiming[] = [];
|
|
89
|
+
private highlightInterval: number | null = null;
|
|
90
|
+
private intentionallyStopped = false;
|
|
91
|
+
|
|
92
|
+
public onWordBoundary?: (
|
|
93
|
+
word: string,
|
|
94
|
+
position: number,
|
|
95
|
+
length?: number,
|
|
96
|
+
) => void;
|
|
97
|
+
|
|
98
|
+
constructor(config: ServerTTSProviderConfig) {
|
|
99
|
+
this.config = config;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async speak(text: string): Promise<void> {
|
|
103
|
+
// Stop any current playback
|
|
104
|
+
this.stop();
|
|
105
|
+
|
|
106
|
+
// Reset intentionally stopped flag for new playback
|
|
107
|
+
this.intentionallyStopped = false;
|
|
108
|
+
|
|
109
|
+
// Call server API to synthesize speech
|
|
110
|
+
const { audioUrl, wordTimings } = await this.synthesizeSpeech(text);
|
|
111
|
+
|
|
112
|
+
// Adjust word timing for playback rate
|
|
113
|
+
// Speech marks are at 1.0x speed, so we need to scale them
|
|
114
|
+
const playbackRate = this.config.rate || 1.0;
|
|
115
|
+
this.wordTimings = wordTimings.map((timing) => ({
|
|
116
|
+
...timing,
|
|
117
|
+
time: timing.time / playbackRate,
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
// Create audio element
|
|
122
|
+
const audio = new Audio(audioUrl);
|
|
123
|
+
this.currentAudio = audio;
|
|
124
|
+
|
|
125
|
+
// Apply rate from config
|
|
126
|
+
if (this.config.rate) {
|
|
127
|
+
audio.playbackRate = Math.max(0.25, Math.min(4.0, this.config.rate));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Apply volume from config
|
|
131
|
+
if (this.config.volume !== undefined) {
|
|
132
|
+
audio.volume = Math.max(0, Math.min(1, this.config.volume));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Setup event handlers
|
|
136
|
+
audio.onplay = () => {
|
|
137
|
+
this.pausedState = false;
|
|
138
|
+
|
|
139
|
+
// Start word highlighting
|
|
140
|
+
if (this.onWordBoundary && this.wordTimings.length > 0) {
|
|
141
|
+
this.startWordHighlighting();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
audio.onended = () => {
|
|
146
|
+
this.stopWordHighlighting();
|
|
147
|
+
URL.revokeObjectURL(audioUrl);
|
|
148
|
+
this.currentAudio = null;
|
|
149
|
+
this.wordTimings = [];
|
|
150
|
+
resolve();
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
audio.onerror = (event) => {
|
|
154
|
+
this.stopWordHighlighting();
|
|
155
|
+
URL.revokeObjectURL(audioUrl);
|
|
156
|
+
this.currentAudio = null;
|
|
157
|
+
this.wordTimings = [];
|
|
158
|
+
// Only reject if this wasn't an intentional stop
|
|
159
|
+
if (!this.intentionallyStopped) {
|
|
160
|
+
reject(new Error("Failed to play audio from server"));
|
|
161
|
+
} else {
|
|
162
|
+
// Intentional stop, resolve normally
|
|
163
|
+
resolve();
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
audio.onpause = () => {
|
|
168
|
+
this.stopWordHighlighting();
|
|
169
|
+
this.pausedState = true;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Start playback
|
|
173
|
+
audio.play().catch(reject);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Call server API to synthesize speech
|
|
179
|
+
*/
|
|
180
|
+
private async synthesizeSpeech(
|
|
181
|
+
text: string,
|
|
182
|
+
): Promise<{ audioUrl: string; wordTimings: WordTiming[] }> {
|
|
183
|
+
const headers: Record<string, string> = {
|
|
184
|
+
"Content-Type": "application/json",
|
|
185
|
+
...this.config.headers,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Add authentication if provided
|
|
189
|
+
if (this.config.authToken) {
|
|
190
|
+
headers["Authorization"] = `Bearer ${this.config.authToken}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const requestBody = {
|
|
194
|
+
text,
|
|
195
|
+
provider: this.config.provider || "polly",
|
|
196
|
+
voice: this.config.voice,
|
|
197
|
+
language: this.config.language,
|
|
198
|
+
rate: this.config.rate,
|
|
199
|
+
includeSpeechMarks: true,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const response = await fetch(`${this.config.apiEndpoint}/synthesize`, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers,
|
|
205
|
+
body: JSON.stringify(requestBody),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
const errorData = await response.json().catch(() => ({}));
|
|
210
|
+
const errorMessage =
|
|
211
|
+
errorData.message ||
|
|
212
|
+
errorData.error?.message ||
|
|
213
|
+
`Server returned ${response.status}`;
|
|
214
|
+
throw new Error(errorMessage);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const data: SynthesizeAPIResponse = await response.json();
|
|
218
|
+
|
|
219
|
+
// Convert base64 audio to blob URL
|
|
220
|
+
const audioBlob = this.base64ToBlob(data.audio, data.contentType);
|
|
221
|
+
const audioUrl = URL.createObjectURL(audioBlob);
|
|
222
|
+
|
|
223
|
+
// Convert speech marks to word timings
|
|
224
|
+
const wordTimings = this.parseSpeechMarks(data.speechMarks);
|
|
225
|
+
|
|
226
|
+
return { audioUrl, wordTimings };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Convert base64 to Blob
|
|
231
|
+
*/
|
|
232
|
+
private base64ToBlob(base64: string, contentType: string): Blob {
|
|
233
|
+
const byteCharacters = atob(base64);
|
|
234
|
+
const byteNumbers = new Array(byteCharacters.length);
|
|
235
|
+
|
|
236
|
+
for (let i = 0; i < byteCharacters.length; i++) {
|
|
237
|
+
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const byteArray = new Uint8Array(byteNumbers);
|
|
241
|
+
return new Blob([byteArray], { type: contentType });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Parse speech marks into word timings
|
|
246
|
+
*/
|
|
247
|
+
private parseSpeechMarks(
|
|
248
|
+
marks: SynthesizeAPIResponse["speechMarks"],
|
|
249
|
+
): WordTiming[] {
|
|
250
|
+
return marks
|
|
251
|
+
.filter((mark) => mark.type === "word")
|
|
252
|
+
.map((mark, index) => ({
|
|
253
|
+
time: mark.time,
|
|
254
|
+
wordIndex: index,
|
|
255
|
+
charIndex: mark.start,
|
|
256
|
+
length: mark.end - mark.start,
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Start word highlighting synchronized with audio playback
|
|
262
|
+
*/
|
|
263
|
+
private startWordHighlighting(): void {
|
|
264
|
+
this.stopWordHighlighting();
|
|
265
|
+
|
|
266
|
+
if (
|
|
267
|
+
!this.currentAudio ||
|
|
268
|
+
!this.onWordBoundary ||
|
|
269
|
+
this.wordTimings.length === 0
|
|
270
|
+
) {
|
|
271
|
+
console.log("[ServerTTSProvider] Cannot start highlighting:", {
|
|
272
|
+
hasAudio: !!this.currentAudio,
|
|
273
|
+
hasCallback: !!this.onWordBoundary,
|
|
274
|
+
wordTimingsCount: this.wordTimings.length,
|
|
275
|
+
});
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
console.log(
|
|
280
|
+
"[ServerTTSProvider] Starting word highlighting with",
|
|
281
|
+
this.wordTimings.length,
|
|
282
|
+
"word timings",
|
|
283
|
+
);
|
|
284
|
+
console.log(
|
|
285
|
+
"[ServerTTSProvider] Playback rate:",
|
|
286
|
+
this.currentAudio.playbackRate,
|
|
287
|
+
);
|
|
288
|
+
console.log(
|
|
289
|
+
"[ServerTTSProvider] First 3 timings:",
|
|
290
|
+
this.wordTimings.slice(0, 3),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
let lastWordIndex = -1;
|
|
294
|
+
|
|
295
|
+
// Poll every 50ms to check current playback time
|
|
296
|
+
this.highlightInterval = window.setInterval(() => {
|
|
297
|
+
if (!this.currentAudio) {
|
|
298
|
+
this.stopWordHighlighting();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Get current playback time in milliseconds
|
|
303
|
+
const currentTime = this.currentAudio.currentTime * 1000;
|
|
304
|
+
|
|
305
|
+
// Find words that should be highlighted at current time
|
|
306
|
+
for (let i = 0; i < this.wordTimings.length; i++) {
|
|
307
|
+
const timing = this.wordTimings[i];
|
|
308
|
+
|
|
309
|
+
if (currentTime >= timing.time && i > lastWordIndex) {
|
|
310
|
+
// Fire word boundary callback
|
|
311
|
+
if (this.onWordBoundary) {
|
|
312
|
+
console.log(
|
|
313
|
+
"[ServerTTSProvider] Highlighting word at charIndex:",
|
|
314
|
+
timing.charIndex,
|
|
315
|
+
"length:",
|
|
316
|
+
timing.length,
|
|
317
|
+
"time:",
|
|
318
|
+
timing.time,
|
|
319
|
+
"currentTime:",
|
|
320
|
+
currentTime,
|
|
321
|
+
);
|
|
322
|
+
// Pass the length as the "word" parameter so TTSService can use it
|
|
323
|
+
this.onWordBoundary("", timing.charIndex, timing.length);
|
|
324
|
+
}
|
|
325
|
+
lastWordIndex = i;
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}, 50); // 50ms polling = 20 times per second
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Stop word highlighting
|
|
334
|
+
*/
|
|
335
|
+
private stopWordHighlighting(): void {
|
|
336
|
+
if (this.highlightInterval !== null) {
|
|
337
|
+
clearInterval(this.highlightInterval);
|
|
338
|
+
this.highlightInterval = null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
pause(): void {
|
|
343
|
+
if (this.currentAudio && !this.pausedState) {
|
|
344
|
+
this.currentAudio.pause();
|
|
345
|
+
this.stopWordHighlighting();
|
|
346
|
+
this.pausedState = true;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
resume(): void {
|
|
351
|
+
if (this.currentAudio && this.pausedState) {
|
|
352
|
+
this.currentAudio.play();
|
|
353
|
+
this.pausedState = false;
|
|
354
|
+
|
|
355
|
+
// Resume word highlighting
|
|
356
|
+
if (this.onWordBoundary && this.wordTimings.length > 0) {
|
|
357
|
+
this.startWordHighlighting();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
stop(): void {
|
|
363
|
+
this.stopWordHighlighting();
|
|
364
|
+
|
|
365
|
+
if (this.currentAudio) {
|
|
366
|
+
// Mark as intentionally stopped to prevent error handler from rejecting
|
|
367
|
+
this.intentionallyStopped = true;
|
|
368
|
+
this.currentAudio.pause();
|
|
369
|
+
if (this.currentAudio.src) {
|
|
370
|
+
URL.revokeObjectURL(this.currentAudio.src);
|
|
371
|
+
}
|
|
372
|
+
this.currentAudio.src = "";
|
|
373
|
+
this.currentAudio = null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
this.pausedState = false;
|
|
377
|
+
this.wordTimings = [];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
isPlaying(): boolean {
|
|
381
|
+
return this.currentAudio !== null && !this.pausedState;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
isPaused(): boolean {
|
|
385
|
+
return this.pausedState;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Update settings dynamically (rate, pitch, voice)
|
|
390
|
+
* Note: Voice changes require resynthesis, so voice updates are stored but
|
|
391
|
+
* take effect on the next speak() call. Rate can be applied to current playback.
|
|
392
|
+
*/
|
|
393
|
+
updateSettings(settings: Partial<ServerTTSProviderConfig>): void {
|
|
394
|
+
// Update config
|
|
395
|
+
if (settings.rate !== undefined) {
|
|
396
|
+
this.config.rate = settings.rate;
|
|
397
|
+
// Apply rate immediately to current playback if active
|
|
398
|
+
if (this.currentAudio) {
|
|
399
|
+
this.currentAudio.playbackRate = Math.max(
|
|
400
|
+
0.25,
|
|
401
|
+
Math.min(4.0, settings.rate),
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (settings.pitch !== undefined) {
|
|
406
|
+
// Server-side pitch is baked into audio, so this only affects next speak()
|
|
407
|
+
this.config.pitch = settings.pitch;
|
|
408
|
+
}
|
|
409
|
+
if (settings.voice !== undefined) {
|
|
410
|
+
// Voice change requires resynthesis, affects next speak()
|
|
411
|
+
this.config.voice = settings.voice;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Server TTS Provider
|
|
418
|
+
*
|
|
419
|
+
* Client-side provider that calls a server API for TTS synthesis.
|
|
420
|
+
* The server handles provider selection (Polly, Google, etc.) and credential management.
|
|
421
|
+
*/
|
|
422
|
+
export class ServerTTSProvider implements ITTSProvider {
|
|
423
|
+
readonly providerId = "server-tts";
|
|
424
|
+
readonly providerName = "Server TTS";
|
|
425
|
+
readonly version = "1.0.0";
|
|
426
|
+
|
|
427
|
+
private config: ServerTTSProviderConfig | null = null;
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Initialize the server TTS provider.
|
|
431
|
+
*
|
|
432
|
+
* This is designed to be fast by default (no API calls).
|
|
433
|
+
* Set validateEndpoint: true in config to test API availability during initialization.
|
|
434
|
+
*
|
|
435
|
+
* @performance Default: <10ms, With validation: 100-500ms
|
|
436
|
+
*/
|
|
437
|
+
async initialize(config: TTSConfig): Promise<ITTSProviderImplementation> {
|
|
438
|
+
const serverConfig = config as ServerTTSProviderConfig;
|
|
439
|
+
|
|
440
|
+
if (!serverConfig.apiEndpoint) {
|
|
441
|
+
throw new Error("apiEndpoint is required for ServerTTSProvider");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
this.config = serverConfig;
|
|
445
|
+
|
|
446
|
+
// Only test API availability if explicitly requested (slower but safer)
|
|
447
|
+
if (serverConfig.validateEndpoint) {
|
|
448
|
+
const available = await this.testAPIAvailability();
|
|
449
|
+
if (!available) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
`Server TTS API not available at ${serverConfig.apiEndpoint}`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return new ServerTTSProviderImpl(serverConfig);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Test if API endpoint is available (with timeout).
|
|
461
|
+
*
|
|
462
|
+
* @performance 100-500ms depending on network
|
|
463
|
+
*/
|
|
464
|
+
private async testAPIAvailability(): Promise<boolean> {
|
|
465
|
+
if (!this.config) return false;
|
|
466
|
+
|
|
467
|
+
try {
|
|
468
|
+
const headers: Record<string, string> = { ...this.config.headers };
|
|
469
|
+
|
|
470
|
+
if (this.config.authToken) {
|
|
471
|
+
headers["Authorization"] = `Bearer ${this.config.authToken}`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Create abort controller for timeout
|
|
475
|
+
const controller = new AbortController();
|
|
476
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
// Try to fetch voices to test API
|
|
480
|
+
const response = await fetch(`${this.config.apiEndpoint}/voices`, {
|
|
481
|
+
headers,
|
|
482
|
+
signal: controller.signal,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
clearTimeout(timeoutId);
|
|
486
|
+
return response.ok;
|
|
487
|
+
} catch (fetchError) {
|
|
488
|
+
clearTimeout(timeoutId);
|
|
489
|
+
// If aborted due to timeout or network error, consider API unavailable
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
} catch {
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
supportsFeature(feature: TTSFeature): boolean {
|
|
498
|
+
switch (feature) {
|
|
499
|
+
case "pause":
|
|
500
|
+
case "resume":
|
|
501
|
+
case "wordBoundary":
|
|
502
|
+
case "voiceSelection":
|
|
503
|
+
case "rateControl":
|
|
504
|
+
return true;
|
|
505
|
+
case "pitchControl":
|
|
506
|
+
// Depends on server provider, assume no for safety
|
|
507
|
+
return false;
|
|
508
|
+
default:
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
getCapabilities(): TTSProviderCapabilities {
|
|
514
|
+
return {
|
|
515
|
+
supportsPause: true,
|
|
516
|
+
supportsResume: true,
|
|
517
|
+
supportsWordBoundary: true, // ✅ Via speech marks from server
|
|
518
|
+
supportsVoiceSelection: true,
|
|
519
|
+
supportsRateControl: true,
|
|
520
|
+
supportsPitchControl: false, // Depends on server provider
|
|
521
|
+
maxTextLength: 3000, // Conservative estimate
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
destroy(): void {
|
|
526
|
+
this.config = null;
|
|
527
|
+
}
|
|
528
|
+
}
|
package/src/index.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2022", "DOM"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"declarationMap": true,
|
|
11
|
+
"sourceMap": true,
|
|
12
|
+
"composite": true,
|
|
13
|
+
"strict": true,
|
|
14
|
+
"esModuleInterop": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"forceConsistentCasingInFileNames": true,
|
|
17
|
+
"resolveJsonModule": true
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*"],
|
|
20
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts"],
|
|
21
|
+
"references": [
|
|
22
|
+
{ "path": "../tts" }
|
|
23
|
+
]
|
|
24
|
+
}
|