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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/deploy_amazon_beta.sh +17 -7
  3. package/nginx.conf +22 -2
  4. package/package.json +1 -1
  5. package/src/app/app.module.ts +2 -0
  6. package/src/app/component/conversation-detail/conversation/conversation.component.html +2 -1
  7. package/src/app/component/conversation-detail/conversation/conversation.component.scss +10 -0
  8. package/src/app/component/conversation-detail/conversation/conversation.component.ts +13 -1
  9. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +7 -3
  10. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +18 -0
  11. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +24 -41
  12. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +37 -51
  13. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +37 -26
  14. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +43 -0
  15. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +79 -0
  16. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +192 -0
  17. package/src/app/component/message/audio-sync/audio-sync.component.html +0 -1
  18. package/src/app/component/message/audio-sync/audio-sync.component.scss +0 -1
  19. package/src/app/component/message/audio-sync/audio-sync.component.ts +378 -17
  20. package/src/app/providers/tts-audio-playback-coordinator.service.ts +93 -0
  21. package/src/app/providers/voice/voice.service.ts +117 -5
  22. package/src/app/sass/_variables.scss +2 -0
  23. package/src/launch.js +41 -32
  24. package/src/launch_template.js +41 -32
  25. package/deploy_amazon_prod.sh +0 -41
@@ -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',
@@ -93,14 +94,16 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
93
94
 
94
95
  /** Stream audio UI: icona equalizer → X; alert con onde animate sopra il footer */
95
96
  isStreamAudioActive = false;
97
+ /** True while the bot's TTS audio is playing — mic segments are suppressed, spectrum turns grey. */
98
+ isBotSpeaking = false;
96
99
  /** Sottoscrizione ai segmenti audio (VAD → WebM) dal {@link VoiceService}. */
97
100
  private voiceAudioSubscription?: Subscription;
98
101
  /** Sottoscrizione al volume audio (real-time) dal {@link VoiceService}. */
99
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. */
100
106
  currentVolume = 0;
101
- wavePath1 = '';
102
- wavePath2 = '';
103
- wavePath3 = '';
104
107
 
105
108
  file_size_limit = FILE_SIZE_LIMIT;
106
109
  attachmentTooltip: string = '';
@@ -112,13 +115,13 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
112
115
  constructor(private chatManager: ChatManager,
113
116
  private typingService: TypingService,
114
117
  private uploadService: UploadService,
115
- private voiceService: VoiceService) { }
118
+ private voiceService: VoiceService,
119
+ private ttsPlayback: TtsAudioPlaybackCoordinator) { }
116
120
 
117
121
  ngOnInit() {
118
122
  // this.updateAttachmentTooltip();
119
123
  }
120
124
 
121
-
122
125
  ngOnChanges(changes: SimpleChanges){
123
126
  if(changes['conversationWith'] && changes['conversationWith'].currentValue !== undefined){
124
127
  this.conversationHandlerService = this.chatManager.getConversationHandlerByConversationId(this.conversationWith);
@@ -169,6 +172,7 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
169
172
  async initVoice() {
170
173
  this.voiceAudioSubscription?.unsubscribe();
171
174
  this.voiceVolumeSubscription?.unsubscribe();
175
+ this.botSpeakingSub?.unsubscribe();
172
176
 
173
177
  this.voiceAudioSubscription = this.voiceService.audioSegment$.subscribe((rec) => {
174
178
  console.log('[CONV-FOOTER] audioSegment$', rec);
@@ -176,40 +180,46 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
176
180
  });
177
181
  this.voiceVolumeSubscription = this.voiceService.volume$.subscribe((volume) => {
178
182
  this.currentVolume = volume;
179
- this.updateWave(volume);
183
+ });
184
+ this.botSpeakingSub = this.voiceService.isAcquisitionBlocked$.subscribe((blocked) => {
185
+ this.isBotSpeaking = blocked;
180
186
  });
181
187
  await this.voiceService.startSession();
182
188
  }
183
189
 
184
- async stopVoice() {
190
+ async stopVoice(options?: { discardInProgressSegment?: boolean }) {
191
+ // Stop all active TTS audio immediately and reveal all text.
192
+ this.ttsPlayback.stopAll();
193
+
185
194
  this.voiceAudioSubscription?.unsubscribe();
186
195
  this.voiceAudioSubscription = undefined;
187
196
 
188
197
  this.voiceVolumeSubscription?.unsubscribe();
189
198
  this.voiceVolumeSubscription = undefined;
190
199
 
191
- await this.voiceService.stopSession();
192
- }
200
+ this.botSpeakingSub?.unsubscribe();
201
+ this.botSpeakingSub = undefined;
202
+ this.isBotSpeaking = false;
193
203
 
194
- updateWave(volume: number) {
195
- const intensity = Math.min(volume / 80, 1); // più sensibile
196
-
197
- const amp1 = 4 + intensity * 22;
198
- const amp2 = 2 + intensity * 16;
199
- const amp3 = 1 + intensity * 12;
200
-
201
- this.wavePath1 = this.buildWave(42, amp1);
202
- this.wavePath2 = this.buildWave(50, amp2);
203
- this.wavePath3 = this.buildWave(58, amp3);
204
+ await this.voiceService.stopSession(options);
205
+ this.currentVolume = 0;
204
206
  }
205
207
 
206
- buildWave(y: number, amp: number): string {
207
- return `
208
- M6 ${y}
209
- Q24 ${y - amp} 42 ${y}
210
- T78 ${y}
211
- T98 ${y}
212
- `;
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
+ }
213
223
  }
214
224
 
215
225
  ngOnDestroy() {
@@ -741,6 +751,7 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
741
751
  const turningOn = !this.isStreamAudioActive;
742
752
  if (turningOn) {
743
753
  try {
754
+ this.currentVolume = 0;
744
755
  await this.initVoice();
745
756
  this.isStreamAudioActive = true;
746
757
  } catch (e) {
@@ -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
+ }
@@ -2,7 +2,6 @@
2
2
 
3
3
  <audio
4
4
  #audioPlayer
5
- [src]="message?.metadata?.src"
6
5
  (timeupdate)="onTimeUpdate()"
7
6
  style="display:none">
8
7
  </audio>
@@ -17,7 +17,6 @@
17
17
  .lyrics {
18
18
  font-size: inherit;
19
19
  margin: 0;
20
- line-height: 1.4em;
21
20
  font-style: normal;
22
21
  letter-spacing: normal;
23
22
  font-stretch: normal;