@chat21/chat21-web-widget 5.1.32-rc1 → 5.1.32-rc14

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.
Files changed (25) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/deploy_amazon_beta.sh +17 -7
  3. package/nginx.conf +22 -2
  4. package/package.json +1 -1
  5. package/src/app/app.module.ts +2 -0
  6. package/src/app/component/conversation-detail/conversation/conversation.component.html +2 -1
  7. package/src/app/component/conversation-detail/conversation/conversation.component.scss +10 -0
  8. package/src/app/component/conversation-detail/conversation/conversation.component.ts +13 -1
  9. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +7 -3
  10. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +18 -0
  11. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +24 -41
  12. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +37 -51
  13. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +37 -26
  14. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +43 -0
  15. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +79 -0
  16. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +192 -0
  17. package/src/app/component/message/audio-sync/audio-sync.component.html +0 -1
  18. package/src/app/component/message/audio-sync/audio-sync.component.scss +0 -1
  19. package/src/app/component/message/audio-sync/audio-sync.component.ts +378 -17
  20. package/src/app/providers/tts-audio-playback-coordinator.service.ts +93 -0
  21. package/src/app/providers/voice/voice.service.ts +117 -5
  22. package/src/app/sass/_variables.scss +2 -0
  23. package/src/launch.js +41 -32
  24. package/src/launch_template.js +41 -32
  25. 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
- constructor(private readonly cdr: ChangeDetectorRef) {}
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
- this.duration = this.audioRef.nativeElement.duration || 1;
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
- if (this.timingReady) {
82
- return;
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.syncStatesFromCurrentTime();
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
- if (audio.readyState >= HAVE_METADATA) {
107
- this.onMetadataLoaded();
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
+ }