@chat21/chat21-web-widget 5.1.32-rc1 → 5.1.32-rc13
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/CHANGELOG.md +51 -0
- package/deploy_amazon_beta.sh +17 -7
- package/nginx.conf +22 -2
- package/package.json +1 -1
- package/src/app/app.module.ts +2 -0
- package/src/app/component/conversation-detail/conversation/conversation.component.html +2 -1
- package/src/app/component/conversation-detail/conversation/conversation.component.scss +10 -0
- package/src/app/component/conversation-detail/conversation/conversation.component.ts +13 -1
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +7 -3
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +18 -0
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +24 -41
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +37 -51
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +37 -26
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +43 -0
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +79 -0
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +192 -0
- package/src/app/component/message/audio-sync/audio-sync.component.html +0 -1
- package/src/app/component/message/audio-sync/audio-sync.component.scss +0 -1
- package/src/app/component/message/audio-sync/audio-sync.component.ts +378 -17
- package/src/app/providers/tts-audio-playback-coordinator.service.ts +93 -0
- package/src/app/providers/voice/voice.service.ts +117 -5
- package/src/app/sass/_variables.scss +2 -0
- package/src/launch.js +41 -32
- package/src/launch_template.js +41 -32
- package/deploy_amazon_prod.sh +0 -41
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
SimpleChanges,
|
|
10
10
|
ViewChild,
|
|
11
11
|
} from '@angular/core';
|
|
12
|
+
import { Subscription } from 'rxjs';
|
|
12
13
|
import { MessageModel } from 'src/chat21-core/models/message';
|
|
14
|
+
import { TtsAudioPlaybackCoordinator } from 'src/app/providers/tts-audio-playback-coordinator.service';
|
|
15
|
+
import { Globals } from 'src/app/utils/globals';
|
|
13
16
|
|
|
14
17
|
/** HAVE_METADATA: metadati già disponibili (tipico audio servito da cache). */
|
|
15
18
|
const HAVE_METADATA = 1;
|
|
@@ -41,7 +44,20 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
41
44
|
private onMetadataLoaded: () => void;
|
|
42
45
|
private onPlaybackEnded: () => void;
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
/** Id univoco per il coordinatore (di solito `message.uid`). */
|
|
48
|
+
private playbackOwnerId = '';
|
|
49
|
+
private destroyed = false;
|
|
50
|
+
private playbackRequested = false;
|
|
51
|
+
private playbackStarted = false;
|
|
52
|
+
private streamAbort?: AbortController;
|
|
53
|
+
private mediaSourceObjectUrl?: string;
|
|
54
|
+
private stopAllSub?: Subscription;
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
private readonly cdr: ChangeDetectorRef,
|
|
58
|
+
private readonly ttsPlayback: TtsAudioPlaybackCoordinator,
|
|
59
|
+
private readonly globals: Globals,
|
|
60
|
+
) {}
|
|
45
61
|
|
|
46
62
|
/** `false` = messaggio già in storico: niente autoplay / karaoke. Da `message.isJustRecived`. */
|
|
47
63
|
private get skipSyncAnimation(): boolean {
|
|
@@ -53,11 +69,14 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
53
69
|
return;
|
|
54
70
|
}
|
|
55
71
|
if (this.audioRef?.nativeElement && this.timingReady) {
|
|
56
|
-
|
|
72
|
+
const d = this.audioRef.nativeElement.duration;
|
|
73
|
+
if (Number.isFinite(d) && d > 0) {
|
|
74
|
+
this.duration = d;
|
|
75
|
+
}
|
|
57
76
|
this.buildFakeTiming();
|
|
58
77
|
if (this.skipSyncAnimation) {
|
|
59
78
|
this.markAllWordsPast();
|
|
60
|
-
} else {
|
|
79
|
+
} else if (this.playbackStarted) {
|
|
61
80
|
this.syncStatesFromCurrentTime();
|
|
62
81
|
}
|
|
63
82
|
}
|
|
@@ -66,7 +85,14 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
66
85
|
ngAfterViewInit(): void {
|
|
67
86
|
const audio = this.audioRef.nativeElement;
|
|
68
87
|
|
|
88
|
+
this.playbackOwnerId =
|
|
89
|
+
(this.message?.uid && String(this.message.uid).trim()) ||
|
|
90
|
+
`tts-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
91
|
+
|
|
69
92
|
this.onPlaybackEnded = () => {
|
|
93
|
+
this.playbackStarted = false;
|
|
94
|
+
this.cleanupStreaming();
|
|
95
|
+
this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
|
|
70
96
|
if (this.skipSyncAnimation) {
|
|
71
97
|
return;
|
|
72
98
|
}
|
|
@@ -78,38 +104,98 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
78
104
|
};
|
|
79
105
|
|
|
80
106
|
this.onMetadataLoaded = () => {
|
|
81
|
-
|
|
82
|
-
|
|
107
|
+
// La durata potrebbe arrivare tardi (specie con streaming).
|
|
108
|
+
const d = audio.duration;
|
|
109
|
+
if (Number.isFinite(d) && d > 0) {
|
|
110
|
+
this.duration = d;
|
|
111
|
+
} else if (!this.timingReady) {
|
|
112
|
+
this.duration = this.estimateDurationSecondsFromText();
|
|
83
113
|
}
|
|
114
|
+
|
|
84
115
|
this.timingReady = true;
|
|
85
|
-
this.duration = audio.duration || 1;
|
|
86
116
|
this.buildFakeTiming();
|
|
87
117
|
if (this.skipSyncAnimation) {
|
|
88
118
|
this.markAllWordsPast();
|
|
89
119
|
this.cdr.detectChanges();
|
|
90
120
|
return;
|
|
91
121
|
}
|
|
92
|
-
this.
|
|
122
|
+
if (this.playbackStarted) {
|
|
123
|
+
this.syncStatesFromCurrentTime();
|
|
124
|
+
}
|
|
93
125
|
this.cdr.detectChanges();
|
|
94
|
-
|
|
95
|
-
setTimeout(() => {
|
|
96
|
-
audio.play().catch(() => {
|
|
97
|
-
this.syncStatesFromCurrentTime();
|
|
98
|
-
this.cdr.detectChanges();
|
|
99
|
-
});
|
|
100
|
-
}, 200);
|
|
101
126
|
};
|
|
102
127
|
|
|
103
128
|
audio.addEventListener('loadedmetadata', this.onMetadataLoaded);
|
|
104
129
|
audio.addEventListener('ended', this.onPlaybackEnded);
|
|
105
130
|
|
|
106
|
-
|
|
107
|
-
|
|
131
|
+
// Prepara subito le parole (durata stimata) e poi aggiorna quando arriva la metadata reale.
|
|
132
|
+
this.duration = this.estimateDurationSecondsFromText();
|
|
133
|
+
this.timingReady = true;
|
|
134
|
+
this.buildFakeTiming();
|
|
135
|
+
if (this.skipSyncAnimation) {
|
|
136
|
+
this.markAllWordsPast();
|
|
137
|
+
this.cdr.detectChanges();
|
|
138
|
+
return;
|
|
108
139
|
}
|
|
140
|
+
this.cdr.detectChanges();
|
|
141
|
+
|
|
142
|
+
setTimeout(() => {
|
|
143
|
+
if (this.playbackRequested || this.destroyed) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.playbackRequested = true;
|
|
147
|
+
this.ttsPlayback.requestStart(this.playbackOwnerId, () => {
|
|
148
|
+
if (this.destroyed) {
|
|
149
|
+
this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.playbackStarted = true;
|
|
153
|
+
this.syncStatesFromCurrentTime();
|
|
154
|
+
this.cdr.detectChanges();
|
|
155
|
+
this.startPlayback(audio);
|
|
156
|
+
});
|
|
157
|
+
}, 200);
|
|
158
|
+
|
|
159
|
+
// Stop signal: user pressed X while this TTS was playing or queued.
|
|
160
|
+
this.stopAllSub = this.ttsPlayback.stopAllPlayback$.subscribe(() => {
|
|
161
|
+
if (!this.playbackRequested && !this.playbackStarted) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.destroyed = true;
|
|
165
|
+
this.playbackStarted = false;
|
|
166
|
+
this.cleanupStreaming();
|
|
167
|
+
try {
|
|
168
|
+
audio.pause();
|
|
169
|
+
audio.currentTime = 0;
|
|
170
|
+
} catch {
|
|
171
|
+
/* ignore */
|
|
172
|
+
}
|
|
173
|
+
this.markAllWordsPast();
|
|
174
|
+
if (this.message) {
|
|
175
|
+
this.message.isJustRecived = false;
|
|
176
|
+
}
|
|
177
|
+
this.cdr.detectChanges();
|
|
178
|
+
});
|
|
109
179
|
}
|
|
110
180
|
|
|
111
181
|
ngOnDestroy(): void {
|
|
182
|
+
this.destroyed = true;
|
|
183
|
+
this.playbackStarted = false;
|
|
184
|
+
this.cleanupStreaming();
|
|
185
|
+
this.stopAllSub?.unsubscribe();
|
|
186
|
+
this.stopAllSub = undefined;
|
|
187
|
+
|
|
112
188
|
const audio = this.audioRef?.nativeElement;
|
|
189
|
+
if (audio) {
|
|
190
|
+
try {
|
|
191
|
+
audio.pause();
|
|
192
|
+
audio.currentTime = 0;
|
|
193
|
+
} catch {
|
|
194
|
+
/* ignore */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
this.ttsPlayback.release(this.playbackOwnerId);
|
|
198
|
+
|
|
113
199
|
if (!audio) {
|
|
114
200
|
return;
|
|
115
201
|
}
|
|
@@ -121,6 +207,266 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
121
207
|
}
|
|
122
208
|
}
|
|
123
209
|
|
|
210
|
+
private startPlayback(audio: HTMLAudioElement): void {
|
|
211
|
+
const src = (this.message as any)?.metadata?.src as string | undefined;
|
|
212
|
+
if (!src) {
|
|
213
|
+
this.playbackStarted = false;
|
|
214
|
+
this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
|
|
215
|
+
this.markAllWordsPast();
|
|
216
|
+
if (this.message) {
|
|
217
|
+
this.message.isJustRecived = false;
|
|
218
|
+
}
|
|
219
|
+
this.cdr.detectChanges();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (this.message?.type === 'tts') {
|
|
224
|
+
this.startStreamingFromEndpoint(audio, src);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
audio.src = src;
|
|
229
|
+
try {
|
|
230
|
+
audio.currentTime = 0;
|
|
231
|
+
} catch {
|
|
232
|
+
/* ignore */
|
|
233
|
+
}
|
|
234
|
+
audio.play().catch(() => this.handlePlaybackError());
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private startStreamingFromEndpoint(audio: HTMLAudioElement, endpoint: string): void {
|
|
238
|
+
this.cleanupStreaming();
|
|
239
|
+
|
|
240
|
+
const jwt = this.getJwtToken();
|
|
241
|
+
const voiceSettings = this.getVoiceSettingsBody();
|
|
242
|
+
const requestBody = this.buildTtsRequestBody(voiceSettings);
|
|
243
|
+
// <audio src="..."> non può inviare header/body: serve fetch().
|
|
244
|
+
const hasMse = typeof (window as any).MediaSource !== 'undefined';
|
|
245
|
+
if (!hasMse) {
|
|
246
|
+
this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const MediaSourceCtor = (window as any).MediaSource as typeof MediaSource;
|
|
251
|
+
const mediaSource = new MediaSourceCtor();
|
|
252
|
+
const objectUrl = URL.createObjectURL(mediaSource);
|
|
253
|
+
this.mediaSourceObjectUrl = objectUrl;
|
|
254
|
+
audio.src = objectUrl;
|
|
255
|
+
|
|
256
|
+
const abort = new AbortController();
|
|
257
|
+
this.streamAbort = abort;
|
|
258
|
+
|
|
259
|
+
const onSourceOpen = async () => {
|
|
260
|
+
mediaSource.removeEventListener('sourceopen', onSourceOpen);
|
|
261
|
+
try {
|
|
262
|
+
const headers: Record<string, string> = {
|
|
263
|
+
'Content-Type': 'application/json',
|
|
264
|
+
'Authorization': `${jwt}`
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const response = await fetch(endpoint, {
|
|
268
|
+
method: 'POST',
|
|
269
|
+
headers,
|
|
270
|
+
body: JSON.stringify(requestBody),
|
|
271
|
+
signal: abort.signal,
|
|
272
|
+
});
|
|
273
|
+
if (!response.ok || !response.body) {
|
|
274
|
+
throw new Error(`TTS stream request failed (${response.status})`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const headerType = (response.headers.get('content-type') || '').split(';')[0].trim();
|
|
278
|
+
const mime = (headerType && MediaSourceCtor.isTypeSupported(headerType))
|
|
279
|
+
? headerType
|
|
280
|
+
: 'audio/mpeg';
|
|
281
|
+
|
|
282
|
+
if (!MediaSourceCtor.isTypeSupported(mime)) {
|
|
283
|
+
this.cleanupStreaming();
|
|
284
|
+
// Fallback: fetch completo e play via blob (no streaming).
|
|
285
|
+
this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const sourceBuffer = mediaSource.addSourceBuffer(mime);
|
|
290
|
+
sourceBuffer.mode = 'sequence';
|
|
291
|
+
|
|
292
|
+
const reader = response.body.getReader();
|
|
293
|
+
const queue: Uint8Array[] = [];
|
|
294
|
+
let doneReading = false;
|
|
295
|
+
let started = false;
|
|
296
|
+
|
|
297
|
+
const tryEndOfStream = () => {
|
|
298
|
+
if (doneReading && queue.length === 0 && !sourceBuffer.updating) {
|
|
299
|
+
try {
|
|
300
|
+
mediaSource.endOfStream();
|
|
301
|
+
} catch {
|
|
302
|
+
/* ignore */
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const pump = () => {
|
|
308
|
+
if (abort.signal.aborted) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (sourceBuffer.updating) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const chunk = queue.shift();
|
|
315
|
+
if (!chunk) {
|
|
316
|
+
tryEndOfStream();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
const ab = chunk.buffer.slice(
|
|
321
|
+
chunk.byteOffset,
|
|
322
|
+
chunk.byteOffset + chunk.byteLength,
|
|
323
|
+
) as ArrayBuffer;
|
|
324
|
+
sourceBuffer.appendBuffer(ab);
|
|
325
|
+
} catch {
|
|
326
|
+
this.cleanupStreaming();
|
|
327
|
+
this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
sourceBuffer.addEventListener('updateend', () => {
|
|
332
|
+
if (!started && this.playbackStarted && !this.destroyed) {
|
|
333
|
+
started = true;
|
|
334
|
+
audio.play().catch(() => this.handlePlaybackError());
|
|
335
|
+
}
|
|
336
|
+
pump();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Primo pump (se arrivano subito chunk)
|
|
340
|
+
pump();
|
|
341
|
+
|
|
342
|
+
while (!abort.signal.aborted) {
|
|
343
|
+
const { value, done } = await reader.read();
|
|
344
|
+
if (done) {
|
|
345
|
+
doneReading = true;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
if (value && value.byteLength > 0) {
|
|
349
|
+
queue.push(value);
|
|
350
|
+
pump();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
doneReading = true;
|
|
355
|
+
tryEndOfStream();
|
|
356
|
+
} catch {
|
|
357
|
+
if (!abort.signal.aborted) {
|
|
358
|
+
this.handlePlaybackError();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
mediaSource.addEventListener('sourceopen', onSourceOpen);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private handlePlaybackError(): void {
|
|
367
|
+
this.playbackStarted = false;
|
|
368
|
+
this.cleanupStreaming();
|
|
369
|
+
this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
|
|
370
|
+
this.markAllWordsPast();
|
|
371
|
+
if (this.message) {
|
|
372
|
+
this.message.isJustRecived = false;
|
|
373
|
+
}
|
|
374
|
+
this.cdr.detectChanges();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private cleanupStreaming(): void {
|
|
378
|
+
try {
|
|
379
|
+
this.streamAbort?.abort();
|
|
380
|
+
} catch {
|
|
381
|
+
/* ignore */
|
|
382
|
+
}
|
|
383
|
+
this.streamAbort = undefined;
|
|
384
|
+
|
|
385
|
+
if (this.mediaSourceObjectUrl) {
|
|
386
|
+
try {
|
|
387
|
+
URL.revokeObjectURL(this.mediaSourceObjectUrl);
|
|
388
|
+
} catch {
|
|
389
|
+
/* ignore */
|
|
390
|
+
}
|
|
391
|
+
this.mediaSourceObjectUrl = undefined;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private getJwtToken(): string | null {
|
|
396
|
+
const token = (this.globals?.tiledeskToken || this.globals?.jwt || '').trim();
|
|
397
|
+
return token.length > 0 ? token : null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private getVoiceSettingsBody(): unknown {
|
|
401
|
+
const raw = (this.message as any)?.metadata?.voiceSettings;
|
|
402
|
+
if (raw === null || raw === undefined) {
|
|
403
|
+
return {};
|
|
404
|
+
}
|
|
405
|
+
if (typeof raw === 'string') {
|
|
406
|
+
const s = raw.trim();
|
|
407
|
+
if (!s) return {};
|
|
408
|
+
try {
|
|
409
|
+
return JSON.parse(s);
|
|
410
|
+
} catch {
|
|
411
|
+
// se non è JSON valido, invialo come stringa (il backend può gestirlo)
|
|
412
|
+
return { voiceSettings: raw };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return raw;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private async fetchAsBlobAndPlay(
|
|
419
|
+
audio: HTMLAudioElement,
|
|
420
|
+
endpoint: string,
|
|
421
|
+
jwt: string | null,
|
|
422
|
+
requestBody: unknown,
|
|
423
|
+
): Promise<void> {
|
|
424
|
+
try {
|
|
425
|
+
const headers: Record<string, string> = {
|
|
426
|
+
'Content-Type': 'application/json',
|
|
427
|
+
'Authorization': `${jwt}`
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
console.log('headers', headers);
|
|
431
|
+
console.log('requestBody', requestBody);
|
|
432
|
+
|
|
433
|
+
const response = await fetch(endpoint, {
|
|
434
|
+
method: 'POST',
|
|
435
|
+
headers,
|
|
436
|
+
body: JSON.stringify(requestBody ?? {}),
|
|
437
|
+
signal: this.streamAbort?.signal,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
if (!response.ok) {
|
|
441
|
+
throw new Error(`TTS request failed (${response.status})`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const blob = await response.blob();
|
|
445
|
+
if (this.destroyed) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
450
|
+
this.mediaSourceObjectUrl = objectUrl;
|
|
451
|
+
audio.src = objectUrl;
|
|
452
|
+
audio.play().catch(() => this.handlePlaybackError());
|
|
453
|
+
} catch {
|
|
454
|
+
this.handlePlaybackError();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private buildTtsRequestBody(voiceSettings: unknown): unknown {
|
|
459
|
+
const text = this.message?.text ?? '';
|
|
460
|
+
if (
|
|
461
|
+
voiceSettings &&
|
|
462
|
+
typeof voiceSettings === 'object' &&
|
|
463
|
+
!Array.isArray(voiceSettings)
|
|
464
|
+
) {
|
|
465
|
+
return { ...(voiceSettings as Record<string, unknown>), text, streaming: true };
|
|
466
|
+
}
|
|
467
|
+
return { voiceSettings, text, streaming: true };
|
|
468
|
+
}
|
|
469
|
+
|
|
124
470
|
private markAllWordsPast(): void {
|
|
125
471
|
this.words.forEach((w) => {
|
|
126
472
|
w.state = 'past';
|
|
@@ -128,6 +474,18 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
128
474
|
this.activeIndex = -1;
|
|
129
475
|
}
|
|
130
476
|
|
|
477
|
+
private estimateDurationSecondsFromText(): number {
|
|
478
|
+
const rawWords = (this.message?.text || '')
|
|
479
|
+
.trim()
|
|
480
|
+
.split(/\s+/)
|
|
481
|
+
.filter((w) => w.length > 0);
|
|
482
|
+
if (rawWords.length === 0) {
|
|
483
|
+
return 1;
|
|
484
|
+
}
|
|
485
|
+
// ~140 WPM → ~0.43s/word
|
|
486
|
+
return Math.max(1, rawWords.length * 0.43);
|
|
487
|
+
}
|
|
488
|
+
|
|
131
489
|
buildFakeTiming(): void {
|
|
132
490
|
const rawWords = (this.message?.text || '')
|
|
133
491
|
.trim()
|
|
@@ -176,6 +534,9 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
176
534
|
}
|
|
177
535
|
|
|
178
536
|
onTimeUpdate(): void {
|
|
537
|
+
if (!this.playbackStarted) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
179
540
|
this.syncStatesFromCurrentTime();
|
|
180
541
|
}
|
|
181
542
|
|
|
@@ -194,4 +555,4 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
194
555
|
trackByIndex(index: number): number {
|
|
195
556
|
return index;
|
|
196
557
|
}
|
|
197
|
-
}
|
|
558
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Garantisce un solo messaggio TTS in riproduzione alla volta.
|
|
6
|
+
* Se arrivano più messaggi TTS, vengono riprodotti in coda (FIFO) senza interrompere quello corrente.
|
|
7
|
+
*/
|
|
8
|
+
@Injectable({ providedIn: 'root' })
|
|
9
|
+
export class TtsAudioPlaybackCoordinator {
|
|
10
|
+
private currentOwnerId: string | null = null;
|
|
11
|
+
private readonly queue: Array<{ ownerId: string; start: () => void }> = [];
|
|
12
|
+
|
|
13
|
+
/** Emits true while any TTS is playing or queued; false when the queue is fully drained. */
|
|
14
|
+
private readonly _isTTSPlaying$ = new BehaviorSubject<boolean>(false);
|
|
15
|
+
readonly isTTSPlaying$ = this._isTTSPlaying$.asObservable();
|
|
16
|
+
|
|
17
|
+
/** Emits once when stopAll() is called — signals every AudioSyncComponent to abort immediately. */
|
|
18
|
+
private readonly _stopAll$ = new Subject<void>();
|
|
19
|
+
readonly stopAllPlayback$: Observable<void> = this._stopAll$.asObservable();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Richiede l'avvio della riproduzione TTS per `ownerId`.
|
|
23
|
+
* Se non c'è nessun TTS attivo, parte subito; altrimenti viene messo in coda.
|
|
24
|
+
*/
|
|
25
|
+
requestStart(ownerId: string, start: () => void): void {
|
|
26
|
+
const id = (ownerId || '').trim();
|
|
27
|
+
if (!id) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (this.currentOwnerId === id) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (this.queue.some((j) => j.ownerId === id)) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (this.currentOwnerId) {
|
|
37
|
+
this.queue.push({ ownerId: id, start });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this.currentOwnerId = id;
|
|
41
|
+
this._isTTSPlaying$.next(true);
|
|
42
|
+
try {
|
|
43
|
+
start();
|
|
44
|
+
} catch {
|
|
45
|
+
this.releaseIfCurrent(id);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Chiamare a fine riproduzione naturale (`ended`) se questo messaggio era ancora “attivo”. */
|
|
50
|
+
releaseIfCurrent(ownerId: string): void {
|
|
51
|
+
const id = (ownerId || '').trim();
|
|
52
|
+
if (!id) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (this.currentOwnerId !== id) {
|
|
56
|
+
// Se era in coda, rimuovilo.
|
|
57
|
+
const idx = this.queue.findIndex((j) => j.ownerId === id);
|
|
58
|
+
if (idx !== -1) {
|
|
59
|
+
this.queue.splice(idx, 1);
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.currentOwnerId = null;
|
|
65
|
+
const next = this.queue.shift();
|
|
66
|
+
if (!next) {
|
|
67
|
+
this._isTTSPlaying$.next(false);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.currentOwnerId = next.ownerId;
|
|
71
|
+
try {
|
|
72
|
+
next.start();
|
|
73
|
+
} catch {
|
|
74
|
+
this.releaseIfCurrent(next.ownerId);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Distruzione componente o stop esplicito. */
|
|
79
|
+
release(ownerId: string): void {
|
|
80
|
+
this.releaseIfCurrent(ownerId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stops all TTS playback immediately and clears the queue.
|
|
85
|
+
* Broadcasts on stopAllPlayback$ so every AudioSyncComponent can abort its stream and reveal all text.
|
|
86
|
+
*/
|
|
87
|
+
stopAll(): void {
|
|
88
|
+
this.queue.length = 0;
|
|
89
|
+
this.currentOwnerId = null;
|
|
90
|
+
this._isTTSPlaying$.next(false);
|
|
91
|
+
this._stopAll$.next();
|
|
92
|
+
}
|
|
93
|
+
}
|