@chat21/chat21-web-widget 5.1.30 → 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/.github/workflows/docker-community-push-latest.yml +23 -13
- package/.github/workflows/docker-image-tag-community-tag-push.yml +22 -12
- package/CHANGELOG.md +89 -2
- package/Dockerfile +4 -5
- package/angular.json +5 -2
- package/deploy_amazon_beta.sh +17 -7
- package/docs/changelog/this-branch.md +36 -0
- package/nginx.conf +22 -2
- package/package.json +4 -1
- package/src/app/app.component.ts +10 -9
- package/src/app/app.module.ts +11 -0
- package/src/app/component/conversation-detail/conversation/conversation.component.html +9 -2
- package/src/app/component/conversation-detail/conversation/conversation.component.scss +12 -2
- package/src/app/component/conversation-detail/conversation/conversation.component.ts +46 -5
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +9 -5
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +19 -1
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +2 -0
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +128 -80
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +117 -13
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +120 -8
- 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/last-message/last-message.component.ts +4 -1
- package/src/app/component/message/audio/audio.component.ts +0 -5
- package/src/app/component/message/audio-sync/audio-sync.component.html +18 -0
- package/src/app/component/message/audio-sync/audio-sync.component.scss +64 -0
- package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +23 -0
- package/src/app/component/message/audio-sync/audio-sync.component.ts +558 -0
- package/src/app/component/message/bubble-message/bubble-message.component.html +6 -1
- package/src/app/component/message/bubble-message/bubble-message.component.ts +2 -1
- package/src/app/providers/global-settings.service.ts +21 -0
- package/src/app/providers/translator.service.ts +2 -0
- package/src/app/providers/tts-audio-playback-coordinator.service.ts +93 -0
- package/src/app/providers/voice/STT&TTS/openai-voice.config.ts +12 -0
- package/src/app/providers/voice/STT&TTS/openai-voice.provider.ts +171 -0
- package/src/app/providers/voice/STT&TTS/speech-provider.abstract.ts +39 -0
- package/src/app/providers/voice/audio.types.ts +34 -0
- package/src/app/providers/voice/vad.service.spec.ts +28 -0
- package/src/app/providers/voice/vad.service.ts +70 -0
- package/src/app/providers/voice/voice.service.spec.ts +60 -0
- package/src/app/providers/voice/voice.service.ts +376 -0
- package/src/app/sass/_variables.scss +3 -0
- package/src/app/shims/onnxruntime-web-wasm.ts +4 -0
- package/src/app/utils/conversation-sender-classifier.ts +21 -0
- package/src/app/utils/globals.ts +7 -1
- package/src/assets/i18n/en.json +1 -0
- package/src/assets/i18n/es.json +1 -0
- package/src/assets/i18n/fr.json +1 -0
- package/src/assets/i18n/it.json +1 -0
- package/src/assets/onnx/ort-wasm-simd-threaded.mjs +59 -0
- package/src/assets/onnx/ort-wasm-simd-threaded.wasm +0 -0
- package/src/assets/vad/silero_vad_legacy.onnx +0 -0
- package/src/assets/vad/vad.worklet.bundle.min.js +1 -0
- package/src/chat21-core/models/message.ts +2 -1
- package/src/chat21-core/providers/firebase/firebase-conversation-handler.ts +3 -2
- package/src/chat21-core/providers/mqtt/mqtt-conversation-handler.ts +12 -0
- package/src/chat21-core/providers/tiledesk/tiledesk-requests.service.ts +1 -1
- package/src/chat21-core/utils/utils-message.ts +7 -0
- package/src/chat21-core/utils/utils.ts +5 -2
- package/src/launch.js +41 -32
- package/src/launch_template.js +41 -32
- package/tsconfig.json +5 -0
- package/deploy_amazon_prod.sh +0 -41
package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Component, ComponentFactoryResolver, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, ViewChild, ViewContainerRef } from '@angular/core';
|
|
1
|
+
import { Component, ComponentFactoryResolver, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild, ViewContainerRef } from '@angular/core';
|
|
2
2
|
import { error } from 'console';
|
|
3
3
|
import { FILE_SIZE_LIMIT } from 'src/app/utils/constants';
|
|
4
4
|
import { Globals } from 'src/app/utils/globals';
|
|
@@ -15,13 +15,16 @@ import { TYPE_MSG_FILE, TYPE_MSG_IMAGE, TYPE_MSG_TEXT } from 'src/chat21-core/ut
|
|
|
15
15
|
import { convertColorToRGBA, isAllowedUrlInText, isEmoji } from 'src/chat21-core/utils/utils';
|
|
16
16
|
import { findAndRemoveEmoji, isImage } from 'src/chat21-core/utils/utils-message';
|
|
17
17
|
import { ProjectModel } from 'src/models/project';
|
|
18
|
+
import { Subscription } from 'rxjs';
|
|
19
|
+
import { VoiceService } from 'src/app/providers/voice/voice.service';
|
|
20
|
+
import { TtsAudioPlaybackCoordinator } from 'src/app/providers/tts-audio-playback-coordinator.service';
|
|
18
21
|
|
|
19
22
|
@Component({
|
|
20
23
|
selector: 'chat-conversation-footer',
|
|
21
24
|
templateUrl: './conversation-footer.component.html',
|
|
22
25
|
styleUrls: ['./conversation-footer.component.scss']
|
|
23
26
|
})
|
|
24
|
-
export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
27
|
+
export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy {
|
|
25
28
|
|
|
26
29
|
@Input() conversationWith: string;
|
|
27
30
|
@Input() attributes: string;
|
|
@@ -32,8 +35,9 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
32
35
|
@Input() userFullname: string;
|
|
33
36
|
@Input() userEmail: string;
|
|
34
37
|
@Input() showAttachmentFooterButton: boolean;
|
|
35
|
-
@Input() showEmojiFooterButton: boolean
|
|
36
|
-
@Input() showAudioRecorderFooterButton: boolean
|
|
38
|
+
@Input() showEmojiFooterButton: boolean;
|
|
39
|
+
@Input() showAudioRecorderFooterButton: boolean;
|
|
40
|
+
@Input() showAudioStreamFooterButton: boolean;
|
|
37
41
|
// @Input() showContinueConversationButton: boolean;
|
|
38
42
|
@Input() isConversationArchived: boolean;
|
|
39
43
|
@Input() hideTextAreaContent: boolean;
|
|
@@ -42,6 +46,7 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
42
46
|
@Input() isEmojiiPickerShow: boolean;
|
|
43
47
|
@Input() footerMessagePlaceholder: string;
|
|
44
48
|
@Input() fileUploadAccept: string;
|
|
49
|
+
@Input() closeChatInConversation: boolean;
|
|
45
50
|
@Input() dropEvent: Event;
|
|
46
51
|
@Input() poweredBy: string;
|
|
47
52
|
@Input() stylesMap: Map<string, string>
|
|
@@ -52,6 +57,8 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
52
57
|
@Output() onChangeTextArea = new EventEmitter<any>();
|
|
53
58
|
@Output() onAttachmentFileButtonClicked = new EventEmitter<any>();
|
|
54
59
|
@Output() onNewConversationButtonClicked = new EventEmitter();
|
|
60
|
+
@Output() onStreamAudioActiveChange = new EventEmitter<boolean>();
|
|
61
|
+
@Output() onCloseChatButtonClicked = new EventEmitter();
|
|
55
62
|
|
|
56
63
|
@ViewChild('chat21_file') public chat21_file: ElementRef;
|
|
57
64
|
// @ViewChild('emojii_container', {read: ViewContainerRef}) selector;
|
|
@@ -85,24 +92,41 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
85
92
|
|
|
86
93
|
showAlertEmoji: boolean = false
|
|
87
94
|
|
|
95
|
+
/** Stream audio UI: icona equalizer → X; alert con onde animate sopra il footer */
|
|
96
|
+
isStreamAudioActive = false;
|
|
97
|
+
/** True while the bot's TTS audio is playing — mic segments are suppressed, spectrum turns grey. */
|
|
98
|
+
isBotSpeaking = false;
|
|
99
|
+
/** Sottoscrizione ai segmenti audio (VAD → WebM) dal {@link VoiceService}. */
|
|
100
|
+
private voiceAudioSubscription?: Subscription;
|
|
101
|
+
/** Sottoscrizione al volume audio (real-time) dal {@link VoiceService}. */
|
|
102
|
+
private voiceVolumeSubscription?: Subscription;
|
|
103
|
+
/** Sottoscrizione allo stato TTS (bot sta parlando). */
|
|
104
|
+
private botSpeakingSub?: Subscription;
|
|
105
|
+
/** Passato a {@link StreamAudioSpectrumComponent} per disegnare la linea spettro. */
|
|
106
|
+
currentVolume = 0;
|
|
107
|
+
|
|
88
108
|
file_size_limit = FILE_SIZE_LIMIT;
|
|
89
109
|
attachmentTooltip: string = '';
|
|
110
|
+
isErrorNetwork: boolean = false;
|
|
90
111
|
|
|
91
112
|
|
|
92
113
|
convertColorToRGBA = convertColorToRGBA;
|
|
93
114
|
private logger: LoggerService = LoggerInstance.getInstance()
|
|
94
115
|
constructor(private chatManager: ChatManager,
|
|
95
116
|
private typingService: TypingService,
|
|
96
|
-
private uploadService: UploadService
|
|
117
|
+
private uploadService: UploadService,
|
|
118
|
+
private voiceService: VoiceService,
|
|
119
|
+
private ttsPlayback: TtsAudioPlaybackCoordinator) { }
|
|
97
120
|
|
|
98
121
|
ngOnInit() {
|
|
99
122
|
// this.updateAttachmentTooltip();
|
|
100
123
|
}
|
|
101
124
|
|
|
102
|
-
|
|
103
125
|
ngOnChanges(changes: SimpleChanges){
|
|
104
126
|
if(changes['conversationWith'] && changes['conversationWith'].currentValue !== undefined){
|
|
105
127
|
this.conversationHandlerService = this.chatManager.getConversationHandlerByConversationId(this.conversationWith);
|
|
128
|
+
this.isStreamAudioActive = false;
|
|
129
|
+
void this.stopVoice();
|
|
106
130
|
}
|
|
107
131
|
if(changes['hideTextReply'] && changes['hideTextReply'].currentValue !== undefined){
|
|
108
132
|
this.restoreTextArea();
|
|
@@ -142,6 +166,66 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
142
166
|
// }, 500);
|
|
143
167
|
// }
|
|
144
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Microfono + VAD: ogni fine parlato il servizio emette su `audioSegment$` → upload.
|
|
171
|
+
*/
|
|
172
|
+
async initVoice() {
|
|
173
|
+
this.voiceAudioSubscription?.unsubscribe();
|
|
174
|
+
this.voiceVolumeSubscription?.unsubscribe();
|
|
175
|
+
this.botSpeakingSub?.unsubscribe();
|
|
176
|
+
|
|
177
|
+
this.voiceAudioSubscription = this.voiceService.audioSegment$.subscribe((rec) => {
|
|
178
|
+
console.log('[CONV-FOOTER] audioSegment$', rec);
|
|
179
|
+
this.prepareAndUpload(rec.blob);
|
|
180
|
+
});
|
|
181
|
+
this.voiceVolumeSubscription = this.voiceService.volume$.subscribe((volume) => {
|
|
182
|
+
this.currentVolume = volume;
|
|
183
|
+
});
|
|
184
|
+
this.botSpeakingSub = this.voiceService.isAcquisitionBlocked$.subscribe((blocked) => {
|
|
185
|
+
this.isBotSpeaking = blocked;
|
|
186
|
+
});
|
|
187
|
+
await this.voiceService.startSession();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async stopVoice(options?: { discardInProgressSegment?: boolean }) {
|
|
191
|
+
// Stop all active TTS audio immediately and reveal all text.
|
|
192
|
+
this.ttsPlayback.stopAll();
|
|
193
|
+
|
|
194
|
+
this.voiceAudioSubscription?.unsubscribe();
|
|
195
|
+
this.voiceAudioSubscription = undefined;
|
|
196
|
+
|
|
197
|
+
this.voiceVolumeSubscription?.unsubscribe();
|
|
198
|
+
this.voiceVolumeSubscription = undefined;
|
|
199
|
+
|
|
200
|
+
this.botSpeakingSub?.unsubscribe();
|
|
201
|
+
this.botSpeakingSub = undefined;
|
|
202
|
+
this.isBotSpeaking = false;
|
|
203
|
+
|
|
204
|
+
await this.voiceService.stopSession(options);
|
|
205
|
+
this.currentVolume = 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* CHIAMATO DA: conversation.component.ts
|
|
210
|
+
* Messaggio in arrivo da un altro mittente mentre lo stream è attivo: scarta solo il segmento
|
|
211
|
+
* registrato in quel momento (nessun upload); mic + VAD restano attivi, `isStreamAudioActive` true.
|
|
212
|
+
*/
|
|
213
|
+
interruptStreamDueToPeerMessage(): void {
|
|
214
|
+
if (!this.isStreamAudioActive) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
this.logger.log('[CONV-FOOTER] discard recording segment: incoming message from peer (stream stays on)');
|
|
218
|
+
try {
|
|
219
|
+
this.voiceService.discardCurrentRecordingSegment();
|
|
220
|
+
} catch (e) {
|
|
221
|
+
this.logger.error('[CONV-FOOTER] interruptStreamDueToPeerMessage', e);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
ngOnDestroy() {
|
|
226
|
+
void this.stopVoice();
|
|
227
|
+
}
|
|
228
|
+
|
|
145
229
|
// ========= begin:: functions send image ======= //
|
|
146
230
|
// START LOAD IMAGE //
|
|
147
231
|
/** load the selected image locally and open the pop up preview */
|
|
@@ -521,7 +605,7 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
521
605
|
}
|
|
522
606
|
}
|
|
523
607
|
|
|
524
|
-
prepareAndUpload(audioBlob: Blob) {
|
|
608
|
+
prepareAndUpload(audioBlob: Blob, text: string = '') {
|
|
525
609
|
|
|
526
610
|
this.isFilePendingToUpload = true;
|
|
527
611
|
|
|
@@ -551,7 +635,7 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
551
635
|
this.logger.log('[UPLOAD] metadata:', metadata);
|
|
552
636
|
|
|
553
637
|
// stesso metodo che già usi
|
|
554
|
-
this.uploadSingle(metadata, file,
|
|
638
|
+
this.uploadSingle(metadata, file, text);
|
|
555
639
|
}
|
|
556
640
|
|
|
557
641
|
// Funzione per convertire Blob in Base64 usando FileReader
|
|
@@ -658,6 +742,30 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
658
742
|
}
|
|
659
743
|
}
|
|
660
744
|
|
|
745
|
+
async onStreamPressed(event: Event) {
|
|
746
|
+
this.logger.log('[CONV-FOOTER] onStreamPressed:event', event);
|
|
747
|
+
event.preventDefault();
|
|
748
|
+
if (this.showAlertEmoji) {
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const turningOn = !this.isStreamAudioActive;
|
|
752
|
+
if (turningOn) {
|
|
753
|
+
try {
|
|
754
|
+
this.currentVolume = 0;
|
|
755
|
+
await this.initVoice();
|
|
756
|
+
this.isStreamAudioActive = true;
|
|
757
|
+
} catch (e) {
|
|
758
|
+
this.logger.error('[CONV-FOOTER] onStreamPressed: initVoice failed', e);
|
|
759
|
+
this.isStreamAudioActive = false;
|
|
760
|
+
}
|
|
761
|
+
} else {
|
|
762
|
+
await this.stopVoice();
|
|
763
|
+
this.isStreamAudioActive = false;
|
|
764
|
+
}
|
|
765
|
+
this.onStreamAudioActiveChange.emit(this.isStreamAudioActive);
|
|
766
|
+
this.logger.log('[CONV-FOOTER] isStreamAudioActive', this.isStreamAudioActive);
|
|
767
|
+
}
|
|
768
|
+
|
|
661
769
|
async onEmojiiPickerClicked(){
|
|
662
770
|
// if(this.loadPickerModule){
|
|
663
771
|
// this.loadPickerModule = false;
|
|
@@ -709,6 +817,10 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
709
817
|
this.onNewConversationButtonClicked.emit();
|
|
710
818
|
}
|
|
711
819
|
|
|
820
|
+
onCloseChat(event){
|
|
821
|
+
this.onCloseChatButtonClicked.emit();
|
|
822
|
+
}
|
|
823
|
+
|
|
712
824
|
// onContinueConversation(){
|
|
713
825
|
// this.hideTextAreaContent = false;
|
|
714
826
|
// this.onBackButton.emit(false)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<ng-container [ngSwitch]="mode">
|
|
2
|
+
<!-- ALERT: spectrum line (fills streamAudioAlert width) -->
|
|
3
|
+
<div *ngSwitchCase="'alert'" class="stream-audio-spectrum" [ngStyle]="accentColor ? { color: accentColor } : null">
|
|
4
|
+
<svg class="stream-audio-spectrum__svg" viewBox="0 0 100 32" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
5
|
+
<defs>
|
|
6
|
+
<linearGradient [attr.id]="gradientId" x1="0" y1="16" x2="100" y2="16" gradientUnits="userSpaceOnUse">
|
|
7
|
+
<stop offset="0%" stop-color="currentColor" stop-opacity="0.45"/>
|
|
8
|
+
<stop offset="50%" stop-color="currentColor" stop-opacity="1"/>
|
|
9
|
+
<stop offset="100%" stop-color="currentColor" stop-opacity="0.45"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
</defs>
|
|
12
|
+
<path class="stream-audio-spectrum__line"
|
|
13
|
+
[attr.d]="spectrumLinePath"
|
|
14
|
+
fill="none"
|
|
15
|
+
[attr.stroke]="'url(#' + gradientId + ')'"
|
|
16
|
+
stroke-width="2.4"
|
|
17
|
+
stroke-linecap="round"
|
|
18
|
+
stroke-linejoin="round"/>
|
|
19
|
+
</svg>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<!-- BUTTON: inactive icon / expanded pill content -->
|
|
23
|
+
<ng-container *ngSwitchCase="'button'">
|
|
24
|
+
<span class="stream-audio-button__icon" *ngIf="!active" aria-hidden="true">
|
|
25
|
+
<svg role="img" xmlns="http://www.w3.org/2000/svg" version="1.1" width="24" height="24" viewBox="0 0 30 30" fill="currentColor" preserveAspectRatio="xMidYMid meet">
|
|
26
|
+
<path class="s0" d="m5.21 7.41c-1.21 0-2.21 0.99-2.21 2.21v8.14c0 1.21 0.99 2.21 2.21 2.21 1.22 0 2.21-0.99 2.21-2.21v-8.14c0-1.21-0.99-2.21-2.21-2.21z"/>
|
|
27
|
+
<path class="s0" d="m11.64 3.01c-1.22 0-2.21 0.99-2.21 2.2v16.94c0 1.21 0.99 2.2 2.21 2.2 1.22 0 2.21-0.98 2.21-2.2v-16.94c0-1.21-0.99-2.21-2.21-2.21z"/>
|
|
28
|
+
<path class="s0" d="m15.86 9.25v8.88c0 1.21 0.99 2.21 2.21 2.21 1.22 0 2.21-0.99 2.21-2.21v-8.88c0-1.22-0.99-2.21-2.21-2.21-1.22 0-2.21 0.99-2.21 2.21z"/>
|
|
29
|
+
<path class="s0" d="m24.5 8.97c-1.22 0-2.21 0.99-2.21 2.21v5.02c0 1.22 0.99 2.21 2.21 2.21 1.22 0 2.21-0.99 2.21-2.21v-5.02c0-1.21-0.99-2.21-2.21-2.21z"/>
|
|
30
|
+
</svg>
|
|
31
|
+
</span>
|
|
32
|
+
|
|
33
|
+
<span class="stream-audio-button__expanded" *ngIf="active">
|
|
34
|
+
<span class="stream-audio-button__bars" aria-hidden="true">
|
|
35
|
+
<span class="bar" [style.transform]="'scaleY(' + barScales[0] + ')'"></span>
|
|
36
|
+
<span class="bar" [style.transform]="'scaleY(' + barScales[1] + ')'"></span>
|
|
37
|
+
<span class="bar" [style.transform]="'scaleY(' + barScales[2] + ')'"></span>
|
|
38
|
+
<span class="bar" [style.transform]="'scaleY(' + barScales[3] + ')'"></span>
|
|
39
|
+
</span>
|
|
40
|
+
<span class="stream-audio-button__label">{{ translationMap.get('CLOSE') }}</span>
|
|
41
|
+
</span>
|
|
42
|
+
</ng-container>
|
|
43
|
+
</ng-container>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
justify-content: center;
|
|
5
|
+
width: 100%;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.stream-audio-spectrum {
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
justify-content: center;
|
|
12
|
+
width: 100%;
|
|
13
|
+
padding: 0 10px;
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.stream-audio-spectrum__svg {
|
|
18
|
+
width: 100%;
|
|
19
|
+
height: 32px;
|
|
20
|
+
display: block;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.stream-audio-spectrum__line {
|
|
24
|
+
pointer-events: none;
|
|
25
|
+
filter: drop-shadow(0 0 1px color-mix(in srgb, currentColor 35%, transparent));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* ===========================
|
|
29
|
+
* BUTTON (pill content)
|
|
30
|
+
* =========================== */
|
|
31
|
+
.stream-audio-button__icon {
|
|
32
|
+
display: inline-flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
justify-content: center;
|
|
35
|
+
width: 100%;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.stream-audio-button__icon svg {
|
|
39
|
+
width: 20px;
|
|
40
|
+
height: 20px;
|
|
41
|
+
display: block;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.stream-audio-button__expanded {
|
|
45
|
+
display: inline-flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
justify-content: center;
|
|
48
|
+
gap: 12px;
|
|
49
|
+
width: 100%;
|
|
50
|
+
user-select: none;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.stream-audio-button__label {
|
|
54
|
+
font-size: 14px;
|
|
55
|
+
line-height: 1;
|
|
56
|
+
font-weight: 500;
|
|
57
|
+
letter-spacing: 0.2px;
|
|
58
|
+
white-space: nowrap;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.stream-audio-button__bars {
|
|
62
|
+
display: inline-flex;
|
|
63
|
+
align-items: center;
|
|
64
|
+
justify-content: center;
|
|
65
|
+
gap: 3px;
|
|
66
|
+
width: 26px;
|
|
67
|
+
height: 18px;
|
|
68
|
+
transform-origin: center;
|
|
69
|
+
margin: 0;
|
|
70
|
+
line-height: 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.stream-audio-button__bars .bar {
|
|
74
|
+
width: 3px;
|
|
75
|
+
height: 100%;
|
|
76
|
+
border-radius: 2px;
|
|
77
|
+
background: rgba(255, 255, 255, 0.92);
|
|
78
|
+
transform-origin: center;
|
|
79
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { Component, Input, OnChanges, OnDestroy, OnInit, Optional, SimpleChanges } from '@angular/core';
|
|
2
|
+
import { Subscription } from 'rxjs';
|
|
3
|
+
import { VoiceService } from 'src/app/providers/voice/voice.service';
|
|
4
|
+
|
|
5
|
+
export type StreamAudioSpectrumMode = 'alert' | 'button';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Icona stream: cerchio con linea orizzontale tipo spettro, reattiva al volume del microfono.
|
|
9
|
+
* Il parent (es. conversation-footer) aggiorna solo {@link volume} da VoiceService.
|
|
10
|
+
*/
|
|
11
|
+
@Component({
|
|
12
|
+
selector: 'chat-stream-audio-spectrum',
|
|
13
|
+
templateUrl: './stream-audio-spectrum.component.html',
|
|
14
|
+
styleUrl: './stream-audio-spectrum.component.scss',
|
|
15
|
+
})
|
|
16
|
+
export class StreamAudioSpectrumComponent implements OnInit, OnChanges {
|
|
17
|
+
private static gradSeq = 0;
|
|
18
|
+
readonly gradientId = `streamSpectrumGrad-${++StreamAudioSpectrumComponent.gradSeq}`;
|
|
19
|
+
|
|
20
|
+
/** Volume normalizzato come emesso da VoiceService (stessa scala del footer). */
|
|
21
|
+
@Input() volume = 0;
|
|
22
|
+
/** Colore tema (stroke / gradient); opzionale. */
|
|
23
|
+
@Input() accentColor?: string;
|
|
24
|
+
|
|
25
|
+
/** UI variant. `alert` = spectrum line (in #streamAudioAlert). `button` = icon / pill with bars + label. */
|
|
26
|
+
@Input() mode: StreamAudioSpectrumMode = 'alert';
|
|
27
|
+
/** For `mode="button"`: whether the stream is active (expanded pill). */
|
|
28
|
+
@Input() active = false;
|
|
29
|
+
/** For `mode="button"`: VAD speech flag; if omitted, we fall back to a volume threshold heuristic. */
|
|
30
|
+
@Input() isUserSpeaking?: boolean;
|
|
31
|
+
/** For `mode="button"`: label on the pill. */
|
|
32
|
+
@Input() translationMap: Map< string, string>;
|
|
33
|
+
|
|
34
|
+
// ALERT (spectrum line)
|
|
35
|
+
spectrumLinePath = 'M0,16 L100,16';
|
|
36
|
+
|
|
37
|
+
// BUTTON (bars)
|
|
38
|
+
barScales: [number, number, number, number] = [0.65, 0.65, 0.65, 0.65];
|
|
39
|
+
private rafId: number | null = null;
|
|
40
|
+
private lastSpeaking = false;
|
|
41
|
+
private voiceSpeechStartSub?: Subscription;
|
|
42
|
+
private voiceSpeechEndSub?: Subscription;
|
|
43
|
+
private internalIsUserSpeaking = false;
|
|
44
|
+
|
|
45
|
+
constructor(@Optional() private readonly voiceService: VoiceService | null) {}
|
|
46
|
+
|
|
47
|
+
ngOnInit(): void {
|
|
48
|
+
// Optional: use VAD speech events to improve idle/speaking detection.
|
|
49
|
+
if (this.voiceService) {
|
|
50
|
+
this.voiceSpeechStartSub = this.voiceService.speechStart$?.subscribe(() => {
|
|
51
|
+
this.internalIsUserSpeaking = true;
|
|
52
|
+
});
|
|
53
|
+
this.voiceSpeechEndSub = this.voiceService.speechEnd$?.subscribe(() => {
|
|
54
|
+
this.internalIsUserSpeaking = false;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
this.refreshAll();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
ngOnChanges(changes: SimpleChanges): void {
|
|
61
|
+
if (changes['volume'] || changes['mode'] || changes['active'] || changes['isUserSpeaking']) {
|
|
62
|
+
this.refreshAll();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
ngOnDestroy(): void {
|
|
67
|
+
this.stopRaf();
|
|
68
|
+
this.voiceSpeechStartSub?.unsubscribe();
|
|
69
|
+
this.voiceSpeechEndSub?.unsubscribe();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private refreshAll(): void {
|
|
73
|
+
if (this.mode === 'alert') {
|
|
74
|
+
this.refreshSpectrumPath();
|
|
75
|
+
this.stopRaf();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.refreshBars();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private refreshSpectrumPath(): void {
|
|
82
|
+
const intensity = Math.min(this.volume / 80, 1);
|
|
83
|
+
const t = Date.now() / 175;
|
|
84
|
+
this.spectrumLinePath = this.buildSpectrumLinePath(intensity, t);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private buildSpectrumLinePath(intensity: number, t: number): string {
|
|
88
|
+
const x0 = 0;
|
|
89
|
+
const x1 = 100;
|
|
90
|
+
const cy = 16;
|
|
91
|
+
const segments = 100;
|
|
92
|
+
const amp = 0.8 + intensity * 6.5;
|
|
93
|
+
const parts: string[] = [];
|
|
94
|
+
for (let i = 0; i <= segments; i++) {
|
|
95
|
+
const p = i / segments;
|
|
96
|
+
const x = x0 + p * (x1 - x0);
|
|
97
|
+
const u = p * Math.PI * 6;
|
|
98
|
+
const wobble =
|
|
99
|
+
Math.sin(u + t) * 0.34 +
|
|
100
|
+
Math.sin(u * 2.35 + t * 1.12) * 0.24 +
|
|
101
|
+
Math.sin(u * 4.2 + t * 0.72) * 0.18 +
|
|
102
|
+
Math.sin(u * 6.8 + t * 1.05) * 0.14 +
|
|
103
|
+
Math.sin(u * 9.1 + t * 0.88) * 0.1;
|
|
104
|
+
const y = cy + amp * wobble;
|
|
105
|
+
const yClamped = Math.min(30, Math.max(2, y));
|
|
106
|
+
parts.push(i === 0 ? `M${x.toFixed(2)},${yClamped.toFixed(2)}` : `L${x.toFixed(2)},${yClamped.toFixed(2)}`);
|
|
107
|
+
}
|
|
108
|
+
return parts.join('');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private refreshBars(): void {
|
|
112
|
+
if (!this.active) {
|
|
113
|
+
this.stopRaf();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const speaking = this.computeSpeaking();
|
|
118
|
+
if (!speaking) {
|
|
119
|
+
this.stopRaf();
|
|
120
|
+
this.barScales = [0.65, 0.65, 0.65, 0.65];
|
|
121
|
+
this.lastSpeaking = false;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// speaking: animate bars with volume-driven intensity
|
|
126
|
+
if (!this.lastSpeaking) {
|
|
127
|
+
this.lastSpeaking = true;
|
|
128
|
+
}
|
|
129
|
+
this.startRaf();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private computeSpeaking(): boolean {
|
|
133
|
+
if (typeof this.isUserSpeaking === 'boolean') {
|
|
134
|
+
return this.isUserSpeaking;
|
|
135
|
+
}
|
|
136
|
+
if (this.voiceService) {
|
|
137
|
+
return this.internalIsUserSpeaking;
|
|
138
|
+
}
|
|
139
|
+
// Fallback heuristic: treat as speaking when volume crosses a low threshold.
|
|
140
|
+
return (this.volume || 0) >= 4;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private startRaf(): void {
|
|
144
|
+
if (this.rafId !== null) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const tick = () => {
|
|
148
|
+
if (!this.active) {
|
|
149
|
+
this.stopRaf();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const speaking = this.computeSpeaking();
|
|
153
|
+
if (!speaking) {
|
|
154
|
+
this.stopRaf();
|
|
155
|
+
this.barScales = [0.65, 0.65, 0.65, 0.65];
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const intensity = Math.min((this.volume || 0) / 80, 1);
|
|
160
|
+
const t = performance.now() / 220;
|
|
161
|
+
const targets: [number, number, number, number] = [0.35, 0.35, 0.35, 0.35];
|
|
162
|
+
|
|
163
|
+
for (let i = 0; i < 4; i++) {
|
|
164
|
+
const phase = i * 0.9;
|
|
165
|
+
const w1 = (Math.sin(t * 1.35 + phase) + 1) / 2;
|
|
166
|
+
const w2 = (Math.sin(t * 2.05 + phase * 1.7) + 1) / 2;
|
|
167
|
+
const mix = w1 * 0.62 + w2 * 0.38;
|
|
168
|
+
const s = 0.25 + intensity * (0.25 + 0.95 * mix);
|
|
169
|
+
targets[i as 0 | 1 | 2 | 3] = Math.max(0.35, Math.min(1.2, s));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Smooth toward targets to avoid jitter on rapid volume changes.
|
|
173
|
+
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
|
|
174
|
+
this.barScales = [
|
|
175
|
+
lerp(this.barScales[0], targets[0], 0.35),
|
|
176
|
+
lerp(this.barScales[1], targets[1], 0.35),
|
|
177
|
+
lerp(this.barScales[2], targets[2], 0.35),
|
|
178
|
+
lerp(this.barScales[3], targets[3], 0.35),
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
this.rafId = requestAnimationFrame(tick);
|
|
182
|
+
};
|
|
183
|
+
this.rafId = requestAnimationFrame(tick);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private stopRaf(): void {
|
|
187
|
+
if (this.rafId !== null) {
|
|
188
|
+
cancelAnimationFrame(this.rafId);
|
|
189
|
+
this.rafId = null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -12,7 +12,7 @@ import { MIN_WIDTH_IMAGES } from 'src/app/utils/constants';
|
|
|
12
12
|
import { ConversationModel } from 'src/chat21-core/models/conversation';
|
|
13
13
|
import { LoggerService } from 'src/chat21-core/providers/abstract/logger.service';
|
|
14
14
|
import { LoggerInstance } from 'src/chat21-core/providers/logger/loggerInstance';
|
|
15
|
-
import { commandToMessage, conversationToMessage, isEmojii, isFrame, isImage, isSameSender } from 'src/chat21-core/utils/utils-message';
|
|
15
|
+
import { commandToMessage, conversationToMessage, isEmojii, isFrame, isImage, isMine, isSameSender, isSender } from 'src/chat21-core/utils/utils-message';
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
@Component({
|
|
@@ -59,6 +59,9 @@ export class LastMessageComponent implements OnInit, AfterViewInit, OnDestroy {
|
|
|
59
59
|
ngOnChanges(changes: SimpleChanges) {
|
|
60
60
|
this.logger.debug('[LASTMESSAGE] onChanges', changes)
|
|
61
61
|
if(this.conversation){
|
|
62
|
+
|
|
63
|
+
/** if the message is sent by the logged user, do not add it to the messages array */
|
|
64
|
+
if(isSender(this.conversation.sender, this.g.senderId)) return;
|
|
62
65
|
|
|
63
66
|
if(this.conversation.attributes && this.conversation.attributes.commands){
|
|
64
67
|
this.addCommandMessage(this.conversation)
|
|
@@ -154,15 +154,10 @@ export class AudioComponent implements AfterViewInit {
|
|
|
154
154
|
// });
|
|
155
155
|
|
|
156
156
|
const response = await fetch(this.rawAudioUrl!);
|
|
157
|
-
this.logger.debug('getAudioDuration: response ---> ', response)
|
|
158
157
|
const arrayBuffer = await response.arrayBuffer();
|
|
159
|
-
this.logger.debug('getAudioDuration: arrayBuffer ---> ', arrayBuffer)
|
|
160
158
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
|
161
|
-
this.logger.debug('getAudioDuration: audioContext ---> ', audioContext)
|
|
162
159
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
163
|
-
this.logger.debug('getAudioDuration: audioBuffer ---> ', audioBuffer)
|
|
164
160
|
this.audioDuration = audioBuffer.duration;
|
|
165
|
-
this.logger.debug('getAudioDuration: audioDuration ---> ', this.audioDuration)
|
|
166
161
|
|
|
167
162
|
}
|
|
168
163
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<div class="lyrics-container">
|
|
2
|
+
|
|
3
|
+
<audio
|
|
4
|
+
#audioPlayer
|
|
5
|
+
(timeupdate)="onTimeUpdate()"
|
|
6
|
+
style="display:none">
|
|
7
|
+
</audio>
|
|
8
|
+
|
|
9
|
+
<p class="lyrics message_innerhtml marked" #transcriptBox [style.color]="color">
|
|
10
|
+
<span
|
|
11
|
+
*ngFor="let w of words; let i = index; trackBy: trackByIndex"
|
|
12
|
+
class="word"
|
|
13
|
+
[ngClass]="w.state">
|
|
14
|
+
{{ w.text }}
|
|
15
|
+
</span>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
</div>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
font-size: var(--font-size-bubble-message, 14px);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/* Allineato a text.component.scss (.message_innerhtml, p) */
|
|
7
|
+
.message_innerhtml {
|
|
8
|
+
margin: 0;
|
|
9
|
+
|
|
10
|
+
&.marked {
|
|
11
|
+
padding: 12px 16px;
|
|
12
|
+
margin-block-start: 0em !important;
|
|
13
|
+
margin-block-end: 0em !important;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.lyrics {
|
|
18
|
+
font-size: inherit;
|
|
19
|
+
margin: 0;
|
|
20
|
+
font-style: normal;
|
|
21
|
+
letter-spacing: normal;
|
|
22
|
+
font-stretch: normal;
|
|
23
|
+
font-variant: normal;
|
|
24
|
+
font-weight: 300;
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-wrap: wrap;
|
|
29
|
+
gap: 6px;
|
|
30
|
+
/* Colore bubble: da [style.color] / @Input() color — ereditato dalle .word */
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* base word */
|
|
34
|
+
.word {
|
|
35
|
+
transition:
|
|
36
|
+
transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1),
|
|
37
|
+
color 0.3s ease,
|
|
38
|
+
opacity 0.3s ease,
|
|
39
|
+
filter 0.3s ease;
|
|
40
|
+
|
|
41
|
+
will-change: transform;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* FUTURE */
|
|
45
|
+
.word.future {
|
|
46
|
+
opacity: 0;
|
|
47
|
+
transform: scale(0.98);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* PAST: stesso colore del testo bubble (@Input color sul <p>) */
|
|
51
|
+
.word.past {
|
|
52
|
+
opacity: 1;
|
|
53
|
+
color: inherit;
|
|
54
|
+
transform: scale(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ACTIVE (solo momentaneo, tipo “karaoke flash”) */
|
|
58
|
+
.word.active {
|
|
59
|
+
opacity: 1;
|
|
60
|
+
color: #00c3ff;
|
|
61
|
+
font-weight: 700;
|
|
62
|
+
transform: scale(1.18);
|
|
63
|
+
text-shadow: 0 0 10px rgba(0, 195, 255, 0.35);
|
|
64
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
2
|
+
|
|
3
|
+
import { AudioSyncComponent } from './audio-sync.component';
|
|
4
|
+
|
|
5
|
+
describe('AudioSyncComponent', () => {
|
|
6
|
+
let component: AudioSyncComponent;
|
|
7
|
+
let fixture: ComponentFixture<AudioSyncComponent>;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
await TestBed.configureTestingModule({
|
|
11
|
+
imports: [AudioSyncComponent]
|
|
12
|
+
})
|
|
13
|
+
.compileComponents();
|
|
14
|
+
|
|
15
|
+
fixture = TestBed.createComponent(AudioSyncComponent);
|
|
16
|
+
component = fixture.componentInstance;
|
|
17
|
+
fixture.detectChanges();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should create', () => {
|
|
21
|
+
expect(component).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
});
|