@chat21/chat21-web-widget 5.1.32-rc1 → 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/CHANGELOG.md +51 -0
- package/deploy_amazon_beta.sh +17 -7
- package/nginx.conf +22 -2
- package/package.json +1 -1
- package/src/app/app.module.ts +2 -0
- package/src/app/component/conversation-detail/conversation/conversation.component.html +2 -1
- package/src/app/component/conversation-detail/conversation/conversation.component.scss +10 -0
- package/src/app/component/conversation-detail/conversation/conversation.component.ts +13 -1
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +7 -3
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +18 -0
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +24 -41
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +37 -51
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +37 -26
- 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/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 +378 -17
- package/src/app/providers/tts-audio-playback-coordinator.service.ts +93 -0
- package/src/app/providers/voice/voice.service.ts +117 -5
- package/src/app/sass/_variables.scss +2 -0
- package/src/launch.js +41 -32
- package/src/launch_template.js +41 -32
- package/deploy_amazon_prod.sh +0 -41
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',
|
|
@@ -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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
200
|
+
this.botSpeakingSub?.unsubscribe();
|
|
201
|
+
this.botSpeakingSub = undefined;
|
|
202
|
+
this.isBotSpeaking = false;
|
|
193
203
|
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
+
}
|