@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.
@@ -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
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Client-side TTS provider for calling server APIs
3
+ * @module @pie-players/tts-client-server
4
+ */
5
+
6
+ export type { ServerTTSProviderConfig } from "./ServerTTSProvider.js";
7
+ export { ServerTTSProvider } from "./ServerTTSProvider.js";
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
+ }