@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.
Files changed (64) hide show
  1. package/.github/workflows/docker-community-push-latest.yml +23 -13
  2. package/.github/workflows/docker-image-tag-community-tag-push.yml +22 -12
  3. package/CHANGELOG.md +89 -2
  4. package/Dockerfile +4 -5
  5. package/angular.json +5 -2
  6. package/deploy_amazon_beta.sh +17 -7
  7. package/docs/changelog/this-branch.md +36 -0
  8. package/nginx.conf +22 -2
  9. package/package.json +4 -1
  10. package/src/app/app.component.ts +10 -9
  11. package/src/app/app.module.ts +11 -0
  12. package/src/app/component/conversation-detail/conversation/conversation.component.html +9 -2
  13. package/src/app/component/conversation-detail/conversation/conversation.component.scss +12 -2
  14. package/src/app/component/conversation-detail/conversation/conversation.component.ts +46 -5
  15. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +9 -5
  16. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +19 -1
  17. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +2 -0
  18. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +128 -80
  19. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +117 -13
  20. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +120 -8
  21. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +43 -0
  22. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +79 -0
  23. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +192 -0
  24. package/src/app/component/last-message/last-message.component.ts +4 -1
  25. package/src/app/component/message/audio/audio.component.ts +0 -5
  26. package/src/app/component/message/audio-sync/audio-sync.component.html +18 -0
  27. package/src/app/component/message/audio-sync/audio-sync.component.scss +64 -0
  28. package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +23 -0
  29. package/src/app/component/message/audio-sync/audio-sync.component.ts +558 -0
  30. package/src/app/component/message/bubble-message/bubble-message.component.html +6 -1
  31. package/src/app/component/message/bubble-message/bubble-message.component.ts +2 -1
  32. package/src/app/providers/global-settings.service.ts +21 -0
  33. package/src/app/providers/translator.service.ts +2 -0
  34. package/src/app/providers/tts-audio-playback-coordinator.service.ts +93 -0
  35. package/src/app/providers/voice/STT&TTS/openai-voice.config.ts +12 -0
  36. package/src/app/providers/voice/STT&TTS/openai-voice.provider.ts +171 -0
  37. package/src/app/providers/voice/STT&TTS/speech-provider.abstract.ts +39 -0
  38. package/src/app/providers/voice/audio.types.ts +34 -0
  39. package/src/app/providers/voice/vad.service.spec.ts +28 -0
  40. package/src/app/providers/voice/vad.service.ts +70 -0
  41. package/src/app/providers/voice/voice.service.spec.ts +60 -0
  42. package/src/app/providers/voice/voice.service.ts +376 -0
  43. package/src/app/sass/_variables.scss +3 -0
  44. package/src/app/shims/onnxruntime-web-wasm.ts +4 -0
  45. package/src/app/utils/conversation-sender-classifier.ts +21 -0
  46. package/src/app/utils/globals.ts +7 -1
  47. package/src/assets/i18n/en.json +1 -0
  48. package/src/assets/i18n/es.json +1 -0
  49. package/src/assets/i18n/fr.json +1 -0
  50. package/src/assets/i18n/it.json +1 -0
  51. package/src/assets/onnx/ort-wasm-simd-threaded.mjs +59 -0
  52. package/src/assets/onnx/ort-wasm-simd-threaded.wasm +0 -0
  53. package/src/assets/vad/silero_vad_legacy.onnx +0 -0
  54. package/src/assets/vad/vad.worklet.bundle.min.js +1 -0
  55. package/src/chat21-core/models/message.ts +2 -1
  56. package/src/chat21-core/providers/firebase/firebase-conversation-handler.ts +3 -2
  57. package/src/chat21-core/providers/mqtt/mqtt-conversation-handler.ts +12 -0
  58. package/src/chat21-core/providers/tiledesk/tiledesk-requests.service.ts +1 -1
  59. package/src/chat21-core/utils/utils-message.ts +7 -0
  60. package/src/chat21-core/utils/utils.ts +5 -2
  61. package/src/launch.js +41 -32
  62. package/src/launch_template.js +41 -32
  63. package/tsconfig.json +5 -0
  64. package/deploy_amazon_prod.sh +0 -41
@@ -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
+ });