@chat21/chat21-web-widget 5.1.32-rc4 → 5.1.32-rc9
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 +23 -6
- package/package.json +1 -1
- package/src/app/app.module.ts +2 -0
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +6 -3
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +10 -0
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +4 -18
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +0 -48
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +11 -27
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +18 -0
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +25 -0
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +62 -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 +415 -16
- package/src/app/providers/tts-audio-playback-coordinator.service.ts +86 -0
- package/src/app/providers/voice/voice.service.ts +5 -0
- package/src/app/sass/_variables.scss +1 -1
- package/src/launch.js +41 -32
- package/src/launch_template.js +41 -32
package/CHANGELOG.md
CHANGED
|
@@ -6,18 +6,35 @@
|
|
|
6
6
|
### **Copyrigth**:
|
|
7
7
|
*Tiledesk SRL*
|
|
8
8
|
|
|
9
|
+
# 5.1.32-rc9
|
|
10
|
+
- **added**: mic-triggered TTS interruption — when VAD detects user speech, stop current TTS playback, clear the queue, and reveal the full message text
|
|
11
|
+
- **added**: global TTS cancel API (`TtsAudioPlaybackCoordinator.cancelAll()` + `cancelAll$`) to stop current + queued TTS playback from UI/events (e.g. close stream)
|
|
12
|
+
- **changed**: `chat-audio-sync` TTS playback now streams audio via authenticated POST to `message.metadata.src`, sending `voiceSettings` + `text` and `streaming: true`
|
|
13
|
+
- **changed**: stream UI spectrum — removed circular orb and stretched the spectrum line to fill the `#streamAudioAlert` width with 10px side padding
|
|
14
|
+
- **changed**: conversation content layout while streaming — adjusted received bubble left margin and loading spinner margins for full-size mode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# 5.1.32-rc8
|
|
18
|
+
- **changed**: updated the dev environment defaults to align with the stage setup (remote config URL, API endpoints, logging level, storage prefix, and related settings)
|
|
19
|
+
|
|
20
|
+
# 5.1.32-rc7
|
|
21
|
+
- **added**: `StreamAudioSpectrum` component for audio visualization in the streaming footer UI
|
|
22
|
+
- **added**: TTS playback coordinator queue — ensures TTS messages play sequentially without interrupting the previous one
|
|
23
|
+
- **changed**: `chat-audio-sync` — updated TTS audio handling to support streaming playback and improved autoplay/animation timing
|
|
24
|
+
- **changed**: iframe loader (`launch.js`, `launch_template.js`) — streamlined loading logic and improved error handling, with fixes for localhost environments
|
|
25
|
+
|
|
9
26
|
# 5.1.32-rc4
|
|
10
|
-
- **added**:
|
|
11
|
-
- **added**: `VoiceService.discardCurrentRecordingSegment()` —
|
|
12
|
-
- **changed**: `#streamAudioAlert` —
|
|
27
|
+
- **added**: “Close stream” control (`.close-stream-button`) — content and sheet bottom offset in fullscreen using `--chat-footer-stream-button-height` only while the stream is listening (`isStreamAudioActive`); variables in `_variables.scss`.
|
|
28
|
+
- **added**: `VoiceService.discardCurrentRecordingSegment()` — when a message arrives from another sender during streaming, the current WebM segment is discarded (no upload) without stopping mic/VAD; `interruptStreamDueToPeerMessage()` in the footer no longer clears `isStreamAudioActive`.
|
|
29
|
+
- **changed**: `#streamAudioAlert` — band above the footer with a frosted-glass look (`backdrop-filter`, semi-transparent `color-mix`).
|
|
13
30
|
|
|
14
31
|
# 5.1.32-rc3
|
|
15
|
-
- **changed**: `nginx.conf` (
|
|
16
|
-
- **chore**:
|
|
32
|
+
- **changed**: `nginx.conf` (Docker image) — explicit MIME types for `.mjs`, `.wasm`, `.onnx` and `default_type` at `http` level (avoids `text/plain` on ONNX/VAD modules behind containerized deploys).
|
|
33
|
+
- **chore**: removed deprecated Amazon beta/prod deploy scripts from the repository.
|
|
17
34
|
|
|
18
35
|
# 5.1.32-rc2
|
|
19
36
|
- **bug fixed**: minor streaming icon UI fixed
|
|
20
|
-
- **changed**: Refactor
|
|
37
|
+
- **changed**: Refactor stream audio button UI in the conversation footer (layout / classes).
|
|
21
38
|
|
|
22
39
|
# 5.1.32-rc1
|
|
23
40
|
- **added**: Voice pipeline — VAD (`@ricky0123/vad-web`) with ONNX Runtime WASM served from `/assets/onnx` (`copy-onnx-wasm`), `VoiceService` with `audioSegment$` (WebM segments) and optional STT/TTS via unified OpenAI provider using `HttpClient`, transcript / error fields on segment payloads.
|
package/package.json
CHANGED
package/src/app/app.module.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { ConversationFooterComponent } from './component/conversation-detail/con
|
|
|
16
16
|
import { ConversationInternalFrameComponent } from './component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component';
|
|
17
17
|
import { ConversationPreviewComponent } from './component/conversation-detail/conversation-preview/conversation-preview.component';
|
|
18
18
|
import { ConversationAudioRecorderComponent } from './component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component';
|
|
19
|
+
import { StreamAudioSpectrumComponent } from './component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component';
|
|
19
20
|
/** CONVERSATION-DETAIL COMPONENTS */
|
|
20
21
|
import { BubbleMessageComponent } from './component/message/bubble-message/bubble-message.component';
|
|
21
22
|
import { AvatarComponent } from './component/message/avatar/avatar.component';
|
|
@@ -294,6 +295,7 @@ export function uploadFactory(http: HttpClient, appConfig: AppConfigService, app
|
|
|
294
295
|
ConversationPreviewComponent,
|
|
295
296
|
ConversationInternalFrameComponent,
|
|
296
297
|
ConversationAudioRecorderComponent,
|
|
298
|
+
StreamAudioSpectrumComponent,
|
|
297
299
|
BubbleMessageComponent,
|
|
298
300
|
AvatarComponent,
|
|
299
301
|
FrameComponent,
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
<!-- message RECIPIENT:: -->
|
|
50
50
|
<div role="messaggio" *ngIf="messageType(MESSAGE_TYPE_OTHERS, message)" class="msg_container base_receive">
|
|
51
51
|
|
|
52
|
-
<chat-avatar-image *ngIf="!isSameSender(message?.sender, i)"
|
|
52
|
+
<chat-avatar-image *ngIf="!isSameSender(message?.sender, i) && !isStreamAudioActive"
|
|
53
53
|
[ngClass]="{'slide-in-left': false}"
|
|
54
54
|
[senderID]="message?.sender"
|
|
55
55
|
[senderFullname]="message?.sender_fullname"
|
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
[ngClass]="{'slide-in-left': false}"
|
|
63
63
|
[class.no-background]="(isImage(message) || isFrame(message) || isCarousel(message)) && ((message?.text && message?.text.trim() === '') || !message?.text)"
|
|
64
64
|
[class.emoticon]="isEmojii(message?.text)"
|
|
65
|
-
[
|
|
65
|
+
[class.fullSizeMessage]="isStreamAudioActive"
|
|
66
|
+
[style.margin-left]="isSameSender(message?.sender, i) ? 'calc(var(--avatar-width) + 10px)' : null"
|
|
66
67
|
[ngStyle]="{'background': stylesMap.get('bubbleReceivedBackground'), 'color': stylesMap.get('bubbleReceivedTextColor'), 'width':isFrame(message) ?'100%' : null}"
|
|
67
68
|
[isSameSender]="isSameSender(message?.sender, i)"
|
|
68
69
|
[message]="message"
|
|
@@ -134,9 +135,10 @@
|
|
|
134
135
|
[senderFullname]="nameUserTypingNow"
|
|
135
136
|
[baseLocation]="baseLocation">
|
|
136
137
|
</chat-avatar-image>
|
|
138
|
+
|
|
137
139
|
<user-typing
|
|
138
|
-
[ngClass]="{'userTypingNowExist': !idUserTypingNow}"
|
|
139
140
|
[color]="stylesMap?.get('iconColor')"
|
|
141
|
+
[ngClass]="{'userTypingNowExist': !idUserTypingNow}"
|
|
140
142
|
[translationMap]="translationMap"
|
|
141
143
|
[idUserTypingNow]="idUserTypingNow"
|
|
142
144
|
[nameUserTypingNow]="nameUserTypingNow">
|
|
@@ -145,6 +147,7 @@
|
|
|
145
147
|
|
|
146
148
|
<div *ngIf="showThinkingMessage && lastServerSenderKind === 'bot'" class="msg_container base_receive thinking_receive">
|
|
147
149
|
<user-typing class="loading thinking-dots"
|
|
150
|
+
[class.fullSize]="isStreamAudioActive"
|
|
148
151
|
[color]="stylesMap?.get('iconColor')"
|
|
149
152
|
[translationMap]="translationMap"
|
|
150
153
|
[idUserTypingNow]="idUserTypingNow"
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
margin: 25px 50px
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
|
|
31
|
+
:host .loading.fullSize ::ng-deep > div.spinner{
|
|
32
|
+
margin: 50px 0px !important;
|
|
33
|
+
}
|
|
34
|
+
|
|
30
35
|
// ============= CSS c21-body ================= //
|
|
31
36
|
.c21-body {
|
|
32
37
|
// -webkit-box-shadow: inset 0 10px 10px -10px rgba(0,0,0,0.4);
|
|
@@ -236,6 +241,11 @@
|
|
|
236
241
|
height: fit-content;
|
|
237
242
|
width: auto;
|
|
238
243
|
|
|
244
|
+
&.fullSizeMessage {
|
|
245
|
+
max-width: 100%;
|
|
246
|
+
margin: auto 0 auto 0 !important;
|
|
247
|
+
}
|
|
248
|
+
|
|
239
249
|
}
|
|
240
250
|
|
|
241
251
|
|
package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html
CHANGED
|
@@ -14,24 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
<!-- STREAM AUDIO: cerchio con onde animate -->
|
|
16
16
|
<div id="streamAudioAlert" *ngIf="!hideTextAreaContent && isStreamAudioActive" class="fade-in-bottom stream-audio-alert" [class.hideTextReply]="hideTextReply" role="status" [attr.aria-label]="translationMap?.get('STREAM_AUDIO_LISTENING') || 'Stream audio attivo'">
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
<g class="stream-audio-alert__wave-layer stream-audio-alert__wave-layer--1">
|
|
22
|
-
<path [attr.d]="wavePath1"></path>
|
|
23
|
-
</g>
|
|
24
|
-
|
|
25
|
-
<g class="stream-audio-alert__wave-layer stream-audio-alert__wave-layer--2">
|
|
26
|
-
<path [attr.d]="wavePath2"></path>
|
|
27
|
-
</g>
|
|
28
|
-
|
|
29
|
-
<g class="stream-audio-alert__wave-layer stream-audio-alert__wave-layer--3">
|
|
30
|
-
<path [attr.d]="wavePath3"></path>
|
|
31
|
-
</g>
|
|
32
|
-
</g>
|
|
33
|
-
</svg>
|
|
34
|
-
</div>
|
|
17
|
+
<chat-stream-audio-spectrum
|
|
18
|
+
[volume]="currentVolume"
|
|
19
|
+
[accentColor]="stylesMap?.get('themeColor')">
|
|
20
|
+
</chat-stream-audio-spectrum>
|
|
35
21
|
</div>
|
|
36
22
|
|
|
37
23
|
</div>
|
package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss
CHANGED
|
@@ -418,54 +418,6 @@ textarea:active{
|
|
|
418
418
|
}
|
|
419
419
|
}
|
|
420
420
|
|
|
421
|
-
.stream-audio-alert__orb {
|
|
422
|
-
display: flex;
|
|
423
|
-
align-items: center;
|
|
424
|
-
justify-content: center;
|
|
425
|
-
width: 88px;
|
|
426
|
-
height: 88px;
|
|
427
|
-
border-radius: 50%;
|
|
428
|
-
border: 2px solid currentColor;
|
|
429
|
-
background: var(--content-background-color);
|
|
430
|
-
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.04);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
.stream-audio-alert__svg {
|
|
434
|
-
width: 72px;
|
|
435
|
-
height: 72px;
|
|
436
|
-
display: block;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
.stream-audio-alert__wave-layer {
|
|
440
|
-
transform-origin: 50px 50px;
|
|
441
|
-
transform-box: fill-box;
|
|
442
|
-
animation: stream-wave-float 1.35s ease-in-out infinite;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
.stream-audio-alert__wave-layer--1 {
|
|
446
|
-
animation-delay: 0s;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
.stream-audio-alert__wave-layer--2 {
|
|
450
|
-
animation-delay: 0.18s;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
.stream-audio-alert__wave-layer--3 {
|
|
454
|
-
animation-delay: 0.36s;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
@keyframes stream-wave-float {
|
|
458
|
-
0%,
|
|
459
|
-
100% {
|
|
460
|
-
transform: translateY(0);
|
|
461
|
-
opacity: 0.85;
|
|
462
|
-
}
|
|
463
|
-
50% {
|
|
464
|
-
transform: translateY(-6px);
|
|
465
|
-
opacity: 1;
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
421
|
#textAlert{
|
|
470
422
|
bottom: 100%;
|
|
471
423
|
width: 100%;
|
package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { findAndRemoveEmoji, isImage } from 'src/chat21-core/utils/utils-message
|
|
|
17
17
|
import { ProjectModel } from 'src/models/project';
|
|
18
18
|
import { Subscription } from 'rxjs';
|
|
19
19
|
import { VoiceService } from 'src/app/providers/voice/voice.service';
|
|
20
|
+
import { TtsAudioPlaybackCoordinator } from 'src/app/providers/tts-audio-playback-coordinator.service';
|
|
20
21
|
|
|
21
22
|
@Component({
|
|
22
23
|
selector: 'chat-conversation-footer',
|
|
@@ -97,10 +98,8 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
|
|
|
97
98
|
private voiceAudioSubscription?: Subscription;
|
|
98
99
|
/** Sottoscrizione al volume audio (real-time) dal {@link VoiceService}. */
|
|
99
100
|
private voiceVolumeSubscription?: Subscription;
|
|
101
|
+
/** Passato a {@link StreamAudioSpectrumComponent} per disegnare la linea spettro. */
|
|
100
102
|
currentVolume = 0;
|
|
101
|
-
wavePath1 = '';
|
|
102
|
-
wavePath2 = '';
|
|
103
|
-
wavePath3 = '';
|
|
104
103
|
|
|
105
104
|
file_size_limit = FILE_SIZE_LIMIT;
|
|
106
105
|
attachmentTooltip: string = '';
|
|
@@ -112,17 +111,18 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
|
|
|
112
111
|
constructor(private chatManager: ChatManager,
|
|
113
112
|
private typingService: TypingService,
|
|
114
113
|
private uploadService: UploadService,
|
|
115
|
-
private voiceService: VoiceService
|
|
114
|
+
private voiceService: VoiceService,
|
|
115
|
+
private ttsPlayback: TtsAudioPlaybackCoordinator) { }
|
|
116
116
|
|
|
117
117
|
ngOnInit() {
|
|
118
118
|
// this.updateAttachmentTooltip();
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
|
|
122
121
|
ngOnChanges(changes: SimpleChanges){
|
|
123
122
|
if(changes['conversationWith'] && changes['conversationWith'].currentValue !== undefined){
|
|
124
123
|
this.conversationHandlerService = this.chatManager.getConversationHandlerByConversationId(this.conversationWith);
|
|
125
124
|
this.isStreamAudioActive = false;
|
|
125
|
+
this.ttsPlayback.cancelAll();
|
|
126
126
|
void this.stopVoice();
|
|
127
127
|
}
|
|
128
128
|
if(changes['hideTextReply'] && changes['hideTextReply'].currentValue !== undefined){
|
|
@@ -176,7 +176,6 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
|
|
|
176
176
|
});
|
|
177
177
|
this.voiceVolumeSubscription = this.voiceService.volume$.subscribe((volume) => {
|
|
178
178
|
this.currentVolume = volume;
|
|
179
|
-
this.updateWave(volume);
|
|
180
179
|
});
|
|
181
180
|
await this.voiceService.startSession();
|
|
182
181
|
}
|
|
@@ -189,9 +188,11 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
|
|
|
189
188
|
this.voiceVolumeSubscription = undefined;
|
|
190
189
|
|
|
191
190
|
await this.voiceService.stopSession(options);
|
|
191
|
+
this.currentVolume = 0;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
/**
|
|
195
|
+
* CHIAMATO DA: conversation.component.ts
|
|
195
196
|
* Messaggio in arrivo da un altro mittente mentre lo stream è attivo: scarta solo il segmento
|
|
196
197
|
* registrato in quel momento (nessun upload); mic + VAD restano attivi, `isStreamAudioActive` true.
|
|
197
198
|
*/
|
|
@@ -207,27 +208,6 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
|
|
|
207
208
|
}
|
|
208
209
|
}
|
|
209
210
|
|
|
210
|
-
updateWave(volume: number) {
|
|
211
|
-
const intensity = Math.min(volume / 80, 1); // più sensibile
|
|
212
|
-
|
|
213
|
-
const amp1 = 4 + intensity * 22;
|
|
214
|
-
const amp2 = 2 + intensity * 16;
|
|
215
|
-
const amp3 = 1 + intensity * 12;
|
|
216
|
-
|
|
217
|
-
this.wavePath1 = this.buildWave(42, amp1);
|
|
218
|
-
this.wavePath2 = this.buildWave(50, amp2);
|
|
219
|
-
this.wavePath3 = this.buildWave(58, amp3);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
buildWave(y: number, amp: number): string {
|
|
223
|
-
return `
|
|
224
|
-
M6 ${y}
|
|
225
|
-
Q24 ${y - amp} 42 ${y}
|
|
226
|
-
T78 ${y}
|
|
227
|
-
T98 ${y}
|
|
228
|
-
`;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
211
|
ngOnDestroy() {
|
|
232
212
|
void this.stopVoice();
|
|
233
213
|
}
|
|
@@ -757,15 +737,19 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
|
|
|
757
737
|
const turningOn = !this.isStreamAudioActive;
|
|
758
738
|
if (turningOn) {
|
|
759
739
|
try {
|
|
740
|
+
this.currentVolume = 0;
|
|
760
741
|
await this.initVoice();
|
|
761
742
|
this.isStreamAudioActive = true;
|
|
762
743
|
} catch (e) {
|
|
763
744
|
this.logger.error('[CONV-FOOTER] onStreamPressed: initVoice failed', e);
|
|
764
745
|
this.isStreamAudioActive = false;
|
|
746
|
+
this.ttsPlayback.cancelAll();
|
|
765
747
|
}
|
|
766
748
|
} else {
|
|
767
749
|
await this.stopVoice();
|
|
768
750
|
this.isStreamAudioActive = false;
|
|
751
|
+
// Close-stream-button clicked: stop any playing/queued TTS audio.
|
|
752
|
+
this.ttsPlayback.cancelAll();
|
|
769
753
|
}
|
|
770
754
|
this.onStreamAudioActiveChange.emit(this.isStreamAudioActive);
|
|
771
755
|
this.logger.log('[CONV-FOOTER] isStreamAudioActive', this.isStreamAudioActive);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<div class="stream-audio-spectrum" [ngStyle]="accentColor ? { color: accentColor } : null">
|
|
2
|
+
<svg class="stream-audio-spectrum__svg" viewBox="0 0 100 32" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
3
|
+
<defs>
|
|
4
|
+
<linearGradient [attr.id]="gradientId" x1="0" y1="16" x2="100" y2="16" gradientUnits="userSpaceOnUse">
|
|
5
|
+
<stop offset="0%" stop-color="currentColor" stop-opacity="0.45"/>
|
|
6
|
+
<stop offset="50%" stop-color="currentColor" stop-opacity="1"/>
|
|
7
|
+
<stop offset="100%" stop-color="currentColor" stop-opacity="0.45"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
</defs>
|
|
10
|
+
<path class="stream-audio-spectrum__line"
|
|
11
|
+
[attr.d]="spectrumLinePath"
|
|
12
|
+
fill="none"
|
|
13
|
+
[attr.stroke]="'url(#' + gradientId + ')'"
|
|
14
|
+
stroke-width="2.4"
|
|
15
|
+
stroke-linecap="round"
|
|
16
|
+
stroke-linejoin="round"/>
|
|
17
|
+
</svg>
|
|
18
|
+
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
width: 100%;
|
|
4
|
+
flex: 1 1 auto;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.stream-audio-spectrum {
|
|
8
|
+
display: flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
width: 100%;
|
|
12
|
+
padding: 0 10px;
|
|
13
|
+
box-sizing: border-box;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.stream-audio-spectrum__svg {
|
|
17
|
+
width: 100%;
|
|
18
|
+
height: 32px;
|
|
19
|
+
display: block;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.stream-audio-spectrum__line {
|
|
23
|
+
pointer-events: none;
|
|
24
|
+
filter: drop-shadow(0 0 1px color-mix(in srgb, currentColor 35%, transparent));
|
|
25
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Icona stream: cerchio con linea orizzontale tipo spettro, reattiva al volume del microfono.
|
|
5
|
+
* Il parent (es. conversation-footer) aggiorna solo {@link volume} da VoiceService.
|
|
6
|
+
*/
|
|
7
|
+
@Component({
|
|
8
|
+
selector: 'chat-stream-audio-spectrum',
|
|
9
|
+
templateUrl: './stream-audio-spectrum.component.html',
|
|
10
|
+
styleUrl: './stream-audio-spectrum.component.scss',
|
|
11
|
+
})
|
|
12
|
+
export class StreamAudioSpectrumComponent implements OnInit, OnChanges {
|
|
13
|
+
private static gradSeq = 0;
|
|
14
|
+
readonly gradientId = `streamSpectrumGrad-${++StreamAudioSpectrumComponent.gradSeq}`;
|
|
15
|
+
|
|
16
|
+
/** Volume normalizzato come emesso da VoiceService (stessa scala del footer). */
|
|
17
|
+
@Input() volume = 0;
|
|
18
|
+
/** Colore tema (stroke / gradient); opzionale. */
|
|
19
|
+
@Input() accentColor?: string;
|
|
20
|
+
|
|
21
|
+
spectrumLinePath = 'M0,16 L100,16';
|
|
22
|
+
|
|
23
|
+
ngOnInit(): void {
|
|
24
|
+
this.refreshPath();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
ngOnChanges(changes: SimpleChanges): void {
|
|
28
|
+
if (changes['volume']) {
|
|
29
|
+
this.refreshPath();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private refreshPath(): void {
|
|
34
|
+
const intensity = Math.min(this.volume / 80, 1);
|
|
35
|
+
const t = Date.now() / 175;
|
|
36
|
+
this.spectrumLinePath = this.buildSpectrumLinePath(intensity, t);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private buildSpectrumLinePath(intensity: number, t: number): string {
|
|
40
|
+
const x0 = 0;
|
|
41
|
+
const x1 = 100;
|
|
42
|
+
const cy = 16;
|
|
43
|
+
const segments = 100;
|
|
44
|
+
const amp = 0.8 + intensity * 6.5;
|
|
45
|
+
const parts: string[] = [];
|
|
46
|
+
for (let i = 0; i <= segments; i++) {
|
|
47
|
+
const p = i / segments;
|
|
48
|
+
const x = x0 + p * (x1 - x0);
|
|
49
|
+
const u = p * Math.PI * 6;
|
|
50
|
+
const wobble =
|
|
51
|
+
Math.sin(u + t) * 0.34 +
|
|
52
|
+
Math.sin(u * 2.35 + t * 1.12) * 0.24 +
|
|
53
|
+
Math.sin(u * 4.2 + t * 0.72) * 0.18 +
|
|
54
|
+
Math.sin(u * 6.8 + t * 1.05) * 0.14 +
|
|
55
|
+
Math.sin(u * 9.1 + t * 0.88) * 0.1;
|
|
56
|
+
const y = cy + amp * wobble;
|
|
57
|
+
const yClamped = Math.min(30, Math.max(2, y));
|
|
58
|
+
parts.push(i === 0 ? `M${x.toFixed(2)},${yClamped.toFixed(2)}` : `L${x.toFixed(2)},${yClamped.toFixed(2)}`);
|
|
59
|
+
}
|
|
60
|
+
return parts.join('');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -9,7 +9,11 @@ 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';
|
|
16
|
+
import { VoiceService } from 'src/app/providers/voice/voice.service';
|
|
13
17
|
|
|
14
18
|
/** HAVE_METADATA: metadati già disponibili (tipico audio servito da cache). */
|
|
15
19
|
const HAVE_METADATA = 1;
|
|
@@ -41,7 +45,23 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
41
45
|
private onMetadataLoaded: () => void;
|
|
42
46
|
private onPlaybackEnded: () => void;
|
|
43
47
|
|
|
44
|
-
|
|
48
|
+
/** Id univoco per il coordinatore (di solito `message.uid`). */
|
|
49
|
+
private playbackOwnerId = '';
|
|
50
|
+
private destroyed = false;
|
|
51
|
+
private playbackRequested = false;
|
|
52
|
+
private playbackStarted = false;
|
|
53
|
+
private micInterrupted = false;
|
|
54
|
+
private streamAbort?: AbortController;
|
|
55
|
+
private mediaSourceObjectUrl?: string;
|
|
56
|
+
private cancelAllSub?: Subscription;
|
|
57
|
+
private micSpeechSub?: Subscription;
|
|
58
|
+
|
|
59
|
+
constructor(
|
|
60
|
+
private readonly cdr: ChangeDetectorRef,
|
|
61
|
+
private readonly ttsPlayback: TtsAudioPlaybackCoordinator,
|
|
62
|
+
private readonly globals: Globals,
|
|
63
|
+
private readonly voiceService: VoiceService,
|
|
64
|
+
) {}
|
|
45
65
|
|
|
46
66
|
/** `false` = messaggio già in storico: niente autoplay / karaoke. Da `message.isJustRecived`. */
|
|
47
67
|
private get skipSyncAnimation(): boolean {
|
|
@@ -53,11 +73,14 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
53
73
|
return;
|
|
54
74
|
}
|
|
55
75
|
if (this.audioRef?.nativeElement && this.timingReady) {
|
|
56
|
-
|
|
76
|
+
const d = this.audioRef.nativeElement.duration;
|
|
77
|
+
if (Number.isFinite(d) && d > 0) {
|
|
78
|
+
this.duration = d;
|
|
79
|
+
}
|
|
57
80
|
this.buildFakeTiming();
|
|
58
81
|
if (this.skipSyncAnimation) {
|
|
59
82
|
this.markAllWordsPast();
|
|
60
|
-
} else {
|
|
83
|
+
} else if (this.playbackStarted) {
|
|
61
84
|
this.syncStatesFromCurrentTime();
|
|
62
85
|
}
|
|
63
86
|
}
|
|
@@ -66,7 +89,36 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
66
89
|
ngAfterViewInit(): void {
|
|
67
90
|
const audio = this.audioRef.nativeElement;
|
|
68
91
|
|
|
92
|
+
this.playbackOwnerId =
|
|
93
|
+
(this.message?.uid && String(this.message.uid).trim()) ||
|
|
94
|
+
`tts-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
95
|
+
|
|
96
|
+
// Se l’utente parla al microfono mentre sta ascoltando, interrompi TUTTO (corrente + coda).
|
|
97
|
+
this.micSpeechSub = this.voiceService.speechStart$.subscribe(() => {
|
|
98
|
+
if (this.destroyed) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// interrompi solo se questo messaggio era in riproduzione o in attesa
|
|
102
|
+
if (this.playbackStarted || this.playbackRequested) {
|
|
103
|
+
this.micInterrupted = true;
|
|
104
|
+
this.ttsPlayback.cancelAll();
|
|
105
|
+
this.interruptPlaybackAndRevealText();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Stop globale (es. mic) notificato dal coordinatore: ogni istanza deve fermarsi e mostrare testo intero.
|
|
110
|
+
this.cancelAllSub = this.ttsPlayback.cancelAll$.subscribe(() => {
|
|
111
|
+
if (this.destroyed) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this.micInterrupted = true;
|
|
115
|
+
this.interruptPlaybackAndRevealText();
|
|
116
|
+
});
|
|
117
|
+
|
|
69
118
|
this.onPlaybackEnded = () => {
|
|
119
|
+
this.playbackStarted = false;
|
|
120
|
+
this.cleanupStreaming();
|
|
121
|
+
this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
|
|
70
122
|
if (this.skipSyncAnimation) {
|
|
71
123
|
return;
|
|
72
124
|
}
|
|
@@ -78,38 +130,84 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
78
130
|
};
|
|
79
131
|
|
|
80
132
|
this.onMetadataLoaded = () => {
|
|
81
|
-
|
|
82
|
-
|
|
133
|
+
// La durata potrebbe arrivare tardi (specie con streaming).
|
|
134
|
+
const d = audio.duration;
|
|
135
|
+
if (Number.isFinite(d) && d > 0) {
|
|
136
|
+
this.duration = d;
|
|
137
|
+
} else if (!this.timingReady) {
|
|
138
|
+
this.duration = this.estimateDurationSecondsFromText();
|
|
83
139
|
}
|
|
140
|
+
|
|
84
141
|
this.timingReady = true;
|
|
85
|
-
this.duration = audio.duration || 1;
|
|
86
142
|
this.buildFakeTiming();
|
|
87
143
|
if (this.skipSyncAnimation) {
|
|
88
144
|
this.markAllWordsPast();
|
|
89
145
|
this.cdr.detectChanges();
|
|
90
146
|
return;
|
|
91
147
|
}
|
|
92
|
-
this.
|
|
148
|
+
if (this.playbackStarted) {
|
|
149
|
+
this.syncStatesFromCurrentTime();
|
|
150
|
+
}
|
|
93
151
|
this.cdr.detectChanges();
|
|
94
|
-
|
|
95
|
-
setTimeout(() => {
|
|
96
|
-
audio.play().catch(() => {
|
|
97
|
-
this.syncStatesFromCurrentTime();
|
|
98
|
-
this.cdr.detectChanges();
|
|
99
|
-
});
|
|
100
|
-
}, 200);
|
|
101
152
|
};
|
|
102
153
|
|
|
103
154
|
audio.addEventListener('loadedmetadata', this.onMetadataLoaded);
|
|
104
155
|
audio.addEventListener('ended', this.onPlaybackEnded);
|
|
105
156
|
|
|
106
|
-
|
|
107
|
-
|
|
157
|
+
// Prepara subito le parole (durata stimata) e poi aggiorna quando arriva la metadata reale.
|
|
158
|
+
this.duration = this.estimateDurationSecondsFromText();
|
|
159
|
+
this.timingReady = true;
|
|
160
|
+
this.buildFakeTiming();
|
|
161
|
+
if (this.skipSyncAnimation) {
|
|
162
|
+
this.markAllWordsPast();
|
|
163
|
+
this.cdr.detectChanges();
|
|
164
|
+
return;
|
|
108
165
|
}
|
|
166
|
+
this.cdr.detectChanges();
|
|
167
|
+
|
|
168
|
+
setTimeout(() => {
|
|
169
|
+
if (this.playbackRequested || this.destroyed || this.micInterrupted) {
|
|
170
|
+
if (this.micInterrupted) {
|
|
171
|
+
this.markAllWordsPast();
|
|
172
|
+
if (this.message) {
|
|
173
|
+
this.message.isJustRecived = false;
|
|
174
|
+
}
|
|
175
|
+
this.cdr.detectChanges();
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
this.playbackRequested = true;
|
|
180
|
+
this.ttsPlayback.requestStart(this.playbackOwnerId, () => {
|
|
181
|
+
if (this.destroyed || this.micInterrupted) {
|
|
182
|
+
this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.playbackStarted = true;
|
|
186
|
+
this.syncStatesFromCurrentTime();
|
|
187
|
+
this.cdr.detectChanges();
|
|
188
|
+
this.startPlayback(audio);
|
|
189
|
+
});
|
|
190
|
+
}, 200);
|
|
109
191
|
}
|
|
110
192
|
|
|
111
193
|
ngOnDestroy(): void {
|
|
194
|
+
this.destroyed = true;
|
|
195
|
+
this.playbackStarted = false;
|
|
196
|
+
this.cleanupStreaming();
|
|
197
|
+
this.cancelAllSub?.unsubscribe();
|
|
198
|
+
this.micSpeechSub?.unsubscribe();
|
|
199
|
+
|
|
112
200
|
const audio = this.audioRef?.nativeElement;
|
|
201
|
+
if (audio) {
|
|
202
|
+
try {
|
|
203
|
+
audio.pause();
|
|
204
|
+
audio.currentTime = 0;
|
|
205
|
+
} catch {
|
|
206
|
+
/* ignore */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
this.ttsPlayback.release(this.playbackOwnerId);
|
|
210
|
+
|
|
113
211
|
if (!audio) {
|
|
114
212
|
return;
|
|
115
213
|
}
|
|
@@ -121,6 +219,292 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
121
219
|
}
|
|
122
220
|
}
|
|
123
221
|
|
|
222
|
+
private interruptPlaybackAndRevealText(): void {
|
|
223
|
+
this.playbackStarted = false;
|
|
224
|
+
this.cleanupStreaming();
|
|
225
|
+
|
|
226
|
+
const audio = this.audioRef?.nativeElement;
|
|
227
|
+
if (audio) {
|
|
228
|
+
try {
|
|
229
|
+
audio.pause();
|
|
230
|
+
audio.currentTime = 0;
|
|
231
|
+
} catch {
|
|
232
|
+
/* ignore */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Rimuove se era in coda (o rilascia se era corrente).
|
|
237
|
+
this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
|
|
238
|
+
|
|
239
|
+
// Mostra tutto il testo (niente "future" invisibili).
|
|
240
|
+
this.markAllWordsPast();
|
|
241
|
+
if (this.message) {
|
|
242
|
+
this.message.isJustRecived = false;
|
|
243
|
+
}
|
|
244
|
+
this.cdr.detectChanges();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private startPlayback(audio: HTMLAudioElement): void {
|
|
248
|
+
const src = (this.message as any)?.metadata?.src as string | undefined;
|
|
249
|
+
if (!src) {
|
|
250
|
+
this.playbackStarted = false;
|
|
251
|
+
this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
|
|
252
|
+
this.markAllWordsPast();
|
|
253
|
+
if (this.message) {
|
|
254
|
+
this.message.isJustRecived = false;
|
|
255
|
+
}
|
|
256
|
+
this.cdr.detectChanges();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (this.message?.type === 'tts') {
|
|
261
|
+
this.startStreamingFromEndpoint(audio, src);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
audio.src = src;
|
|
266
|
+
try {
|
|
267
|
+
audio.currentTime = 0;
|
|
268
|
+
} catch {
|
|
269
|
+
/* ignore */
|
|
270
|
+
}
|
|
271
|
+
audio.play().catch(() => this.handlePlaybackError());
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private startStreamingFromEndpoint(audio: HTMLAudioElement, endpoint: string): void {
|
|
275
|
+
this.cleanupStreaming();
|
|
276
|
+
|
|
277
|
+
const jwt = this.getJwtToken();
|
|
278
|
+
const voiceSettings = this.getVoiceSettingsBody();
|
|
279
|
+
const requestBody = this.buildTtsRequestBody(voiceSettings);
|
|
280
|
+
// <audio src="..."> non può inviare header/body: serve fetch().
|
|
281
|
+
const hasMse = typeof (window as any).MediaSource !== 'undefined';
|
|
282
|
+
if (!hasMse) {
|
|
283
|
+
this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const MediaSourceCtor = (window as any).MediaSource as typeof MediaSource;
|
|
288
|
+
const mediaSource = new MediaSourceCtor();
|
|
289
|
+
const objectUrl = URL.createObjectURL(mediaSource);
|
|
290
|
+
this.mediaSourceObjectUrl = objectUrl;
|
|
291
|
+
audio.src = objectUrl;
|
|
292
|
+
|
|
293
|
+
const abort = new AbortController();
|
|
294
|
+
this.streamAbort = abort;
|
|
295
|
+
|
|
296
|
+
const onSourceOpen = async () => {
|
|
297
|
+
mediaSource.removeEventListener('sourceopen', onSourceOpen);
|
|
298
|
+
try {
|
|
299
|
+
const headers: Record<string, string> = {
|
|
300
|
+
'Content-Type': 'application/json',
|
|
301
|
+
};
|
|
302
|
+
if (jwt) {
|
|
303
|
+
headers['Authorization'] = jwt;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const response = await fetch(endpoint, {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers,
|
|
309
|
+
body: JSON.stringify(requestBody),
|
|
310
|
+
signal: abort.signal,
|
|
311
|
+
});
|
|
312
|
+
if (!response.ok || !response.body) {
|
|
313
|
+
throw new Error(`TTS stream request failed (${response.status})`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const headerType = (response.headers.get('content-type') || '').split(';')[0].trim();
|
|
317
|
+
const mime = (headerType && MediaSourceCtor.isTypeSupported(headerType))
|
|
318
|
+
? headerType
|
|
319
|
+
: 'audio/mpeg';
|
|
320
|
+
|
|
321
|
+
if (!MediaSourceCtor.isTypeSupported(mime)) {
|
|
322
|
+
this.cleanupStreaming();
|
|
323
|
+
// Fallback: fetch completo e play via blob (no streaming).
|
|
324
|
+
this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const sourceBuffer = mediaSource.addSourceBuffer(mime);
|
|
329
|
+
sourceBuffer.mode = 'sequence';
|
|
330
|
+
|
|
331
|
+
const reader = response.body.getReader();
|
|
332
|
+
const queue: Uint8Array[] = [];
|
|
333
|
+
let doneReading = false;
|
|
334
|
+
let started = false;
|
|
335
|
+
|
|
336
|
+
const tryEndOfStream = () => {
|
|
337
|
+
if (doneReading && queue.length === 0 && !sourceBuffer.updating) {
|
|
338
|
+
try {
|
|
339
|
+
mediaSource.endOfStream();
|
|
340
|
+
} catch {
|
|
341
|
+
/* ignore */
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const pump = () => {
|
|
347
|
+
if (abort.signal.aborted) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (sourceBuffer.updating) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const chunk = queue.shift();
|
|
354
|
+
if (!chunk) {
|
|
355
|
+
tryEndOfStream();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
const ab = chunk.buffer.slice(
|
|
360
|
+
chunk.byteOffset,
|
|
361
|
+
chunk.byteOffset + chunk.byteLength,
|
|
362
|
+
) as ArrayBuffer;
|
|
363
|
+
sourceBuffer.appendBuffer(ab);
|
|
364
|
+
} catch {
|
|
365
|
+
this.cleanupStreaming();
|
|
366
|
+
this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
sourceBuffer.addEventListener('updateend', () => {
|
|
371
|
+
if (!started && this.playbackStarted && !this.destroyed) {
|
|
372
|
+
started = true;
|
|
373
|
+
audio.play().catch(() => this.handlePlaybackError());
|
|
374
|
+
}
|
|
375
|
+
pump();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Primo pump (se arrivano subito chunk)
|
|
379
|
+
pump();
|
|
380
|
+
|
|
381
|
+
while (!abort.signal.aborted) {
|
|
382
|
+
const { value, done } = await reader.read();
|
|
383
|
+
if (done) {
|
|
384
|
+
doneReading = true;
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
if (value && value.byteLength > 0) {
|
|
388
|
+
queue.push(value);
|
|
389
|
+
pump();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
doneReading = true;
|
|
394
|
+
tryEndOfStream();
|
|
395
|
+
} catch {
|
|
396
|
+
if (!abort.signal.aborted) {
|
|
397
|
+
this.handlePlaybackError();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
mediaSource.addEventListener('sourceopen', onSourceOpen);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private handlePlaybackError(): void {
|
|
406
|
+
this.playbackStarted = false;
|
|
407
|
+
this.cleanupStreaming();
|
|
408
|
+
this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
|
|
409
|
+
this.markAllWordsPast();
|
|
410
|
+
if (this.message) {
|
|
411
|
+
this.message.isJustRecived = false;
|
|
412
|
+
}
|
|
413
|
+
this.cdr.detectChanges();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private cleanupStreaming(): void {
|
|
417
|
+
try {
|
|
418
|
+
this.streamAbort?.abort();
|
|
419
|
+
} catch {
|
|
420
|
+
/* ignore */
|
|
421
|
+
}
|
|
422
|
+
this.streamAbort = undefined;
|
|
423
|
+
|
|
424
|
+
if (this.mediaSourceObjectUrl) {
|
|
425
|
+
try {
|
|
426
|
+
URL.revokeObjectURL(this.mediaSourceObjectUrl);
|
|
427
|
+
} catch {
|
|
428
|
+
/* ignore */
|
|
429
|
+
}
|
|
430
|
+
this.mediaSourceObjectUrl = undefined;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private getJwtToken(): string | null {
|
|
435
|
+
const token = (this.globals?.tiledeskToken || this.globals?.jwt || '').trim();
|
|
436
|
+
return token.length > 0 ? token : null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private getVoiceSettingsBody(): unknown {
|
|
440
|
+
const raw = (this.message as any)?.metadata?.voiceSettings;
|
|
441
|
+
if (raw === null || raw === undefined) {
|
|
442
|
+
return {};
|
|
443
|
+
}
|
|
444
|
+
if (typeof raw === 'string') {
|
|
445
|
+
const s = raw.trim();
|
|
446
|
+
if (!s) return {};
|
|
447
|
+
try {
|
|
448
|
+
return JSON.parse(s);
|
|
449
|
+
} catch {
|
|
450
|
+
// se non è JSON valido, invialo come stringa (il backend può gestirlo)
|
|
451
|
+
return { voiceSettings: raw };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return raw;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private async fetchAsBlobAndPlay(
|
|
458
|
+
audio: HTMLAudioElement,
|
|
459
|
+
endpoint: string,
|
|
460
|
+
jwt: string | null,
|
|
461
|
+
requestBody: unknown,
|
|
462
|
+
): Promise<void> {
|
|
463
|
+
try {
|
|
464
|
+
const headers: Record<string, string> = {
|
|
465
|
+
'Content-Type': 'application/json',
|
|
466
|
+
};
|
|
467
|
+
if (jwt) {
|
|
468
|
+
headers['Authorization'] = jwt;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const response = await fetch(endpoint, {
|
|
472
|
+
method: 'POST',
|
|
473
|
+
headers,
|
|
474
|
+
body: JSON.stringify(requestBody ?? {}),
|
|
475
|
+
signal: this.streamAbort?.signal,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
if (!response.ok) {
|
|
479
|
+
throw new Error(`TTS request failed (${response.status})`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const blob = await response.blob();
|
|
483
|
+
if (this.destroyed) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
488
|
+
this.mediaSourceObjectUrl = objectUrl;
|
|
489
|
+
audio.src = objectUrl;
|
|
490
|
+
audio.play().catch(() => this.handlePlaybackError());
|
|
491
|
+
} catch {
|
|
492
|
+
this.handlePlaybackError();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private buildTtsRequestBody(voiceSettings: unknown): unknown {
|
|
497
|
+
const text = this.message?.text ?? '';
|
|
498
|
+
if (
|
|
499
|
+
voiceSettings &&
|
|
500
|
+
typeof voiceSettings === 'object' &&
|
|
501
|
+
!Array.isArray(voiceSettings)
|
|
502
|
+
) {
|
|
503
|
+
return { ...(voiceSettings as Record<string, unknown>), text, streaming: true };
|
|
504
|
+
}
|
|
505
|
+
return { voiceSettings, text, streaming: true };
|
|
506
|
+
}
|
|
507
|
+
|
|
124
508
|
private markAllWordsPast(): void {
|
|
125
509
|
this.words.forEach((w) => {
|
|
126
510
|
w.state = 'past';
|
|
@@ -128,6 +512,18 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
128
512
|
this.activeIndex = -1;
|
|
129
513
|
}
|
|
130
514
|
|
|
515
|
+
private estimateDurationSecondsFromText(): number {
|
|
516
|
+
const rawWords = (this.message?.text || '')
|
|
517
|
+
.trim()
|
|
518
|
+
.split(/\s+/)
|
|
519
|
+
.filter((w) => w.length > 0);
|
|
520
|
+
if (rawWords.length === 0) {
|
|
521
|
+
return 1;
|
|
522
|
+
}
|
|
523
|
+
// ~140 WPM → ~0.43s/word
|
|
524
|
+
return Math.max(1, rawWords.length * 0.43);
|
|
525
|
+
}
|
|
526
|
+
|
|
131
527
|
buildFakeTiming(): void {
|
|
132
528
|
const rawWords = (this.message?.text || '')
|
|
133
529
|
.trim()
|
|
@@ -176,6 +572,9 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
|
176
572
|
}
|
|
177
573
|
|
|
178
574
|
onTimeUpdate(): void {
|
|
575
|
+
if (!this.playbackStarted) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
179
578
|
this.syncStatesFromCurrentTime();
|
|
180
579
|
}
|
|
181
580
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
import { 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
|
+
private readonly cancelAllSource = new Subject<void>();
|
|
14
|
+
/** Emesso quando la riproduzione TTS va interrotta globalmente (es. l’utente parla al microfono). */
|
|
15
|
+
readonly cancelAll$: Observable<void> = this.cancelAllSource.asObservable();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Richiede l'avvio della riproduzione TTS per `ownerId`.
|
|
19
|
+
* Se non c'è nessun TTS attivo, parte subito; altrimenti viene messo in coda.
|
|
20
|
+
*/
|
|
21
|
+
requestStart(ownerId: string, start: () => void): void {
|
|
22
|
+
const id = (ownerId || '').trim();
|
|
23
|
+
if (!id) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (this.currentOwnerId === id) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (this.queue.some((j) => j.ownerId === id)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (this.currentOwnerId) {
|
|
33
|
+
this.queue.push({ ownerId: id, start });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.currentOwnerId = id;
|
|
37
|
+
try {
|
|
38
|
+
start();
|
|
39
|
+
} catch {
|
|
40
|
+
this.releaseIfCurrent(id);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Chiamare a fine riproduzione naturale (`ended`) se questo messaggio era ancora “attivo”. */
|
|
45
|
+
releaseIfCurrent(ownerId: string): void {
|
|
46
|
+
const id = (ownerId || '').trim();
|
|
47
|
+
if (!id) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (this.currentOwnerId !== id) {
|
|
51
|
+
// Se era in coda, rimuovilo.
|
|
52
|
+
const idx = this.queue.findIndex((j) => j.ownerId === id);
|
|
53
|
+
if (idx !== -1) {
|
|
54
|
+
this.queue.splice(idx, 1);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
this.currentOwnerId = null;
|
|
60
|
+
const next = this.queue.shift();
|
|
61
|
+
if (!next) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
this.currentOwnerId = next.ownerId;
|
|
65
|
+
try {
|
|
66
|
+
next.start();
|
|
67
|
+
} catch {
|
|
68
|
+
this.releaseIfCurrent(next.ownerId);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Distruzione componente o stop esplicito. */
|
|
73
|
+
release(ownerId: string): void {
|
|
74
|
+
this.releaseIfCurrent(ownerId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Interrompe TUTTA la riproduzione TTS (corrente + coda) e notifica i componenti.
|
|
79
|
+
* I componenti devono fermare l’audio e mostrare il testo per intero.
|
|
80
|
+
*/
|
|
81
|
+
cancelAll(): void {
|
|
82
|
+
this.queue.splice(0, this.queue.length);
|
|
83
|
+
this.currentOwnerId = null;
|
|
84
|
+
this.cancelAllSource.next();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -33,6 +33,10 @@ export class VoiceService {
|
|
|
33
33
|
/** Emesso a ogni fine segmento parlato: audio WebM + opzionalmente `transcript` / `transcriptionError`. */
|
|
34
34
|
readonly audioSegment$: Observable<VoiceSegmentPayload> = this.audioSegmentSubject.asObservable();
|
|
35
35
|
|
|
36
|
+
private readonly speechStartSubject = new Subject<void>();
|
|
37
|
+
/** Emesso quando il microfono intercetta parlato (VAD speech start). */
|
|
38
|
+
readonly speechStart$: Observable<void> = this.speechStartSubject.asObservable();
|
|
39
|
+
|
|
36
40
|
// 🔊 REALTIME VOLUME STREAM
|
|
37
41
|
private readonly volumeSubject = new BehaviorSubject<number>(0);
|
|
38
42
|
readonly volume$: Observable<number> = this.volumeSubject.asObservable();
|
|
@@ -83,6 +87,7 @@ export class VoiceService {
|
|
|
83
87
|
},
|
|
84
88
|
onSpeechStart: () => {
|
|
85
89
|
this.logger.log('[VoiceService] speech start');
|
|
90
|
+
this.speechStartSubject.next();
|
|
86
91
|
this.startMediaRecorderSegment();
|
|
87
92
|
},
|
|
88
93
|
onSpeechEnd: () => {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
--chat-footer-logo-height: 30px;
|
|
39
39
|
--chat-footer-close-button-height: 30px;
|
|
40
40
|
--chat-footer-border-radius: 16px;
|
|
41
|
-
--chat-footer-stream-button-height:
|
|
41
|
+
--chat-footer-stream-button-height: 50px;
|
|
42
42
|
--chat-footer-stream-button-padding: 10px 0;
|
|
43
43
|
--chat-footer-background-color: #f6f7fb;
|
|
44
44
|
--chat-footer-color: #1a1a1a;
|
package/src/launch.js
CHANGED
|
@@ -218,67 +218,76 @@ function loadIframe(tiledeskScriptBaseLocation) {
|
|
|
218
218
|
iDiv.appendChild(ifrm);
|
|
219
219
|
|
|
220
220
|
// Funzione helper per caricare iframe con fallback per compatibilità CSP (Wix, etc.)
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
// Priorità: document.write / srcdoc prima della Blob URL. Le Blob URL spesso danno origine opaca
|
|
222
|
+
// (blob:null): l'iframe non può leggere window.parent.tiledeskSettings → projectid mancante.
|
|
223
|
+
function loadIframeContent(iframe, htmlContent) {
|
|
224
224
|
var blobUrl = null;
|
|
225
|
-
|
|
226
|
-
//
|
|
227
|
-
|
|
225
|
+
|
|
226
|
+
// 1) document.write: iframe stessa origine della pagina host → tiledeskSettings sul parent accessibile
|
|
227
|
+
try {
|
|
228
|
+
var cw = iframe.contentWindow;
|
|
229
|
+
if (cw && cw.document) {
|
|
230
|
+
cw.document.open();
|
|
231
|
+
cw.document.write(htmlContent);
|
|
232
|
+
cw.document.close();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
} catch (e) {
|
|
236
|
+
console.warn('[Tiledesk] iframe document.write failed, trying srcdoc/blob:', e);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 2) srcdoc: stessa origine del parent (HTML5); utile se document.write è bloccato
|
|
240
|
+
if ('srcdoc' in iframe) {
|
|
241
|
+
try {
|
|
242
|
+
iframe.srcdoc = htmlContent;
|
|
243
|
+
return;
|
|
244
|
+
} catch (e) {
|
|
245
|
+
console.warn('[Tiledesk] iframe srcdoc failed, trying blob:', e);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 3) Blob URL (spesso permesso da CSP dove srcdoc/write no; può rompere lettura parent.tiledeskSettings)
|
|
228
250
|
if (typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) {
|
|
229
251
|
try {
|
|
230
252
|
var blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
|
|
231
253
|
blobUrl = URL.createObjectURL(blob);
|
|
232
254
|
iframe.src = blobUrl;
|
|
233
|
-
|
|
234
|
-
// Cleanup del blob URL dopo il caricamento per liberare memoria
|
|
255
|
+
|
|
235
256
|
var originalOnload = iframe.onload;
|
|
236
257
|
iframe.onload = function() {
|
|
237
|
-
// Revoca il blob URL dopo un delay per assicurarsi che tutto sia caricato
|
|
238
258
|
setTimeout(function() {
|
|
239
259
|
if (blobUrl) {
|
|
240
260
|
try {
|
|
241
261
|
URL.revokeObjectURL(blobUrl);
|
|
242
262
|
blobUrl = null;
|
|
243
|
-
} catch(
|
|
244
|
-
console.warn('Error revoking blob URL:',
|
|
263
|
+
} catch (err) {
|
|
264
|
+
console.warn('Error revoking blob URL:', err);
|
|
245
265
|
}
|
|
246
266
|
}
|
|
247
267
|
}, 1000);
|
|
248
268
|
if (originalOnload) originalOnload.call(this);
|
|
249
269
|
};
|
|
250
|
-
return;
|
|
251
|
-
} catch(e) {
|
|
252
|
-
console.warn('Blob URL not available
|
|
270
|
+
return;
|
|
271
|
+
} catch (e) {
|
|
272
|
+
console.warn('Blob URL not available:', e);
|
|
253
273
|
}
|
|
254
274
|
}
|
|
255
|
-
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
if (!isLocalhost && 'srcdoc' in iframe) {
|
|
259
|
-
try {
|
|
260
|
-
iframe.srcdoc = htmlContent;
|
|
261
|
-
return; // srcdoc impostato
|
|
262
|
-
} catch(e) {
|
|
263
|
-
console.warn('srcdoc not allowed, trying document.write:', e);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Metodo 3: document.write (fallback finale, funziona su localhost e browser vecchi)
|
|
268
|
-
if (isLocalhost || (iframe.contentWindow && iframe.contentWindow.document)) {
|
|
275
|
+
|
|
276
|
+
// 4) Ultimo tentativo document.write (iframe magari non pronto al primo passo)
|
|
277
|
+
if (iframe.contentWindow && iframe.contentWindow.document) {
|
|
269
278
|
try {
|
|
270
279
|
iframe.contentWindow.document.open();
|
|
271
280
|
iframe.contentWindow.document.write(htmlContent);
|
|
272
281
|
iframe.contentWindow.document.close();
|
|
273
|
-
return;
|
|
274
|
-
} catch(e) {
|
|
275
|
-
console.error('All iframe loading methods failed:', e);
|
|
282
|
+
return;
|
|
283
|
+
} catch (e) {
|
|
284
|
+
console.error('[Tiledesk] All iframe loading methods failed:', e);
|
|
276
285
|
}
|
|
277
286
|
}
|
|
278
287
|
}
|
|
279
288
|
|
|
280
289
|
// Carica il contenuto dell'iframe con fallback automatico
|
|
281
|
-
loadIframeContent(ifrm, srcTileDesk
|
|
290
|
+
loadIframeContent(ifrm, srcTileDesk);
|
|
282
291
|
|
|
283
292
|
|
|
284
293
|
}
|
package/src/launch_template.js
CHANGED
|
@@ -219,67 +219,76 @@ function loadIframe(tiledeskScriptBaseLocation) {
|
|
|
219
219
|
iDiv.appendChild(ifrm);
|
|
220
220
|
|
|
221
221
|
// Funzione helper per caricare iframe con fallback per compatibilità CSP (Wix, etc.)
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
// Priorità: document.write / srcdoc prima della Blob URL. Le Blob URL spesso danno origine opaca
|
|
223
|
+
// (blob:null): l'iframe non può leggere window.parent.tiledeskSettings → projectid mancante.
|
|
224
|
+
function loadIframeContent(iframe, htmlContent) {
|
|
225
225
|
var blobUrl = null;
|
|
226
|
-
|
|
227
|
-
//
|
|
228
|
-
|
|
226
|
+
|
|
227
|
+
// 1) document.write: iframe stessa origine della pagina host → tiledeskSettings sul parent accessibile
|
|
228
|
+
try {
|
|
229
|
+
var cw = iframe.contentWindow;
|
|
230
|
+
if (cw && cw.document) {
|
|
231
|
+
cw.document.open();
|
|
232
|
+
cw.document.write(htmlContent);
|
|
233
|
+
cw.document.close();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
} catch (e) {
|
|
237
|
+
console.warn('[Tiledesk] iframe document.write failed, trying srcdoc/blob:', e);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 2) srcdoc: stessa origine del parent (HTML5); utile se document.write è bloccato
|
|
241
|
+
if ('srcdoc' in iframe) {
|
|
242
|
+
try {
|
|
243
|
+
iframe.srcdoc = htmlContent;
|
|
244
|
+
return;
|
|
245
|
+
} catch (e) {
|
|
246
|
+
console.warn('[Tiledesk] iframe srcdoc failed, trying blob:', e);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 3) Blob URL (spesso permesso da CSP dove srcdoc/write no; può rompere lettura parent.tiledeskSettings)
|
|
229
251
|
if (typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) {
|
|
230
252
|
try {
|
|
231
253
|
var blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
|
|
232
254
|
blobUrl = URL.createObjectURL(blob);
|
|
233
255
|
iframe.src = blobUrl;
|
|
234
|
-
|
|
235
|
-
// Cleanup del blob URL dopo il caricamento per liberare memoria
|
|
256
|
+
|
|
236
257
|
var originalOnload = iframe.onload;
|
|
237
258
|
iframe.onload = function() {
|
|
238
|
-
// Revoca il blob URL dopo un delay per assicurarsi che tutto sia caricato
|
|
239
259
|
setTimeout(function() {
|
|
240
260
|
if (blobUrl) {
|
|
241
261
|
try {
|
|
242
262
|
URL.revokeObjectURL(blobUrl);
|
|
243
263
|
blobUrl = null;
|
|
244
|
-
} catch(
|
|
245
|
-
console.warn('Error revoking blob URL:',
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.warn('Error revoking blob URL:', err);
|
|
246
266
|
}
|
|
247
267
|
}
|
|
248
268
|
}, 1000);
|
|
249
269
|
if (originalOnload) originalOnload.call(this);
|
|
250
270
|
};
|
|
251
|
-
return;
|
|
252
|
-
} catch(e) {
|
|
253
|
-
console.warn('Blob URL not available
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Metodo 2: srcdoc (fallback se Blob URL non disponibile)
|
|
258
|
-
// Skip per localhost (usa document.write per compatibilità sviluppo)
|
|
259
|
-
if (!isLocalhost && 'srcdoc' in iframe) {
|
|
260
|
-
try {
|
|
261
|
-
iframe.srcdoc = htmlContent;
|
|
262
|
-
return; // srcdoc impostato
|
|
263
|
-
} catch(e) {
|
|
264
|
-
console.warn('srcdoc not allowed, trying document.write:', e);
|
|
271
|
+
return;
|
|
272
|
+
} catch (e) {
|
|
273
|
+
console.warn('Blob URL not available:', e);
|
|
265
274
|
}
|
|
266
275
|
}
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
if (
|
|
276
|
+
|
|
277
|
+
// 4) Ultimo tentativo document.write (iframe magari non pronto al primo passo)
|
|
278
|
+
if (iframe.contentWindow && iframe.contentWindow.document) {
|
|
270
279
|
try {
|
|
271
280
|
iframe.contentWindow.document.open();
|
|
272
281
|
iframe.contentWindow.document.write(htmlContent);
|
|
273
282
|
iframe.contentWindow.document.close();
|
|
274
|
-
return;
|
|
275
|
-
} catch(e) {
|
|
276
|
-
console.error('All iframe loading methods failed:', e);
|
|
283
|
+
return;
|
|
284
|
+
} catch (e) {
|
|
285
|
+
console.error('[Tiledesk] All iframe loading methods failed:', e);
|
|
277
286
|
}
|
|
278
287
|
}
|
|
279
288
|
}
|
|
280
289
|
|
|
281
290
|
// Carica il contenuto dell'iframe con fallback automatico
|
|
282
|
-
loadIframeContent(ifrm, srcTileDesk
|
|
291
|
+
loadIframeContent(ifrm, srcTileDesk);
|
|
283
292
|
|
|
284
293
|
|
|
285
294
|
}
|