@chat21/chat21-web-widget 5.1.30-rc1 → 5.1.32-rc1
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 +44 -71
- package/angular.json +3 -1
- package/deploy_amazon_beta.sh +7 -17
- package/deploy_amazon_prod.sh +41 -0
- package/docs/changelog/this-branch.md +47 -0
- package/package.json +4 -1
- package/src/app/app.component.ts +1 -2
- package/src/app/app.module.ts +9 -0
- package/src/app/component/conversation-detail/conversation/conversation.component.html +4 -0
- package/src/app/component/conversation-detail/conversation/conversation.component.ts +8 -0
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +2 -2
- 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 +42 -0
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +91 -0
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +101 -7
- package/src/app/component/message/audio/audio.component.ts +0 -5
- package/src/app/component/message/audio-sync/audio-sync.component.html +19 -0
- package/src/app/component/message/audio-sync/audio-sync.component.scss +65 -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 +197 -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 +10 -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 +264 -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 +3 -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/utils/utils-message.ts +7 -0
- package/tsconfig.json +5 -0
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,15 @@ 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';
|
|
18
20
|
|
|
19
21
|
@Component({
|
|
20
22
|
selector: 'chat-conversation-footer',
|
|
21
23
|
templateUrl: './conversation-footer.component.html',
|
|
22
24
|
styleUrls: ['./conversation-footer.component.scss']
|
|
23
25
|
})
|
|
24
|
-
export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
26
|
+
export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy {
|
|
25
27
|
|
|
26
28
|
@Input() conversationWith: string;
|
|
27
29
|
@Input() attributes: string;
|
|
@@ -32,8 +34,9 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
32
34
|
@Input() userFullname: string;
|
|
33
35
|
@Input() userEmail: string;
|
|
34
36
|
@Input() showAttachmentFooterButton: boolean;
|
|
35
|
-
@Input() showEmojiFooterButton: boolean
|
|
36
|
-
@Input() showAudioRecorderFooterButton: boolean
|
|
37
|
+
@Input() showEmojiFooterButton: boolean;
|
|
38
|
+
@Input() showAudioRecorderFooterButton: boolean;
|
|
39
|
+
@Input() showAudioStreamFooterButton: boolean;
|
|
37
40
|
// @Input() showContinueConversationButton: boolean;
|
|
38
41
|
@Input() isConversationArchived: boolean;
|
|
39
42
|
@Input() hideTextAreaContent: boolean;
|
|
@@ -53,6 +56,7 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
53
56
|
@Output() onChangeTextArea = new EventEmitter<any>();
|
|
54
57
|
@Output() onAttachmentFileButtonClicked = new EventEmitter<any>();
|
|
55
58
|
@Output() onNewConversationButtonClicked = new EventEmitter();
|
|
59
|
+
@Output() onStreamAudioActiveChange = new EventEmitter<boolean>();
|
|
56
60
|
@Output() onCloseChatButtonClicked = new EventEmitter();
|
|
57
61
|
|
|
58
62
|
@ViewChild('chat21_file') public chat21_file: ElementRef;
|
|
@@ -87,6 +91,17 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
87
91
|
|
|
88
92
|
showAlertEmoji: boolean = false
|
|
89
93
|
|
|
94
|
+
/** Stream audio UI: icona equalizer → X; alert con onde animate sopra il footer */
|
|
95
|
+
isStreamAudioActive = false;
|
|
96
|
+
/** Sottoscrizione ai segmenti audio (VAD → WebM) dal {@link VoiceService}. */
|
|
97
|
+
private voiceAudioSubscription?: Subscription;
|
|
98
|
+
/** Sottoscrizione al volume audio (real-time) dal {@link VoiceService}. */
|
|
99
|
+
private voiceVolumeSubscription?: Subscription;
|
|
100
|
+
currentVolume = 0;
|
|
101
|
+
wavePath1 = '';
|
|
102
|
+
wavePath2 = '';
|
|
103
|
+
wavePath3 = '';
|
|
104
|
+
|
|
90
105
|
file_size_limit = FILE_SIZE_LIMIT;
|
|
91
106
|
attachmentTooltip: string = '';
|
|
92
107
|
isErrorNetwork: boolean = false;
|
|
@@ -96,7 +111,8 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
96
111
|
private logger: LoggerService = LoggerInstance.getInstance()
|
|
97
112
|
constructor(private chatManager: ChatManager,
|
|
98
113
|
private typingService: TypingService,
|
|
99
|
-
private uploadService: UploadService
|
|
114
|
+
private uploadService: UploadService,
|
|
115
|
+
private voiceService: VoiceService) { }
|
|
100
116
|
|
|
101
117
|
ngOnInit() {
|
|
102
118
|
// this.updateAttachmentTooltip();
|
|
@@ -106,6 +122,8 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
106
122
|
ngOnChanges(changes: SimpleChanges){
|
|
107
123
|
if(changes['conversationWith'] && changes['conversationWith'].currentValue !== undefined){
|
|
108
124
|
this.conversationHandlerService = this.chatManager.getConversationHandlerByConversationId(this.conversationWith);
|
|
125
|
+
this.isStreamAudioActive = false;
|
|
126
|
+
void this.stopVoice();
|
|
109
127
|
}
|
|
110
128
|
if(changes['hideTextReply'] && changes['hideTextReply'].currentValue !== undefined){
|
|
111
129
|
this.restoreTextArea();
|
|
@@ -145,6 +163,59 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
145
163
|
// }, 500);
|
|
146
164
|
// }
|
|
147
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Microfono + VAD: ogni fine parlato il servizio emette su `audioSegment$` → upload.
|
|
168
|
+
*/
|
|
169
|
+
async initVoice() {
|
|
170
|
+
this.voiceAudioSubscription?.unsubscribe();
|
|
171
|
+
this.voiceVolumeSubscription?.unsubscribe();
|
|
172
|
+
|
|
173
|
+
this.voiceAudioSubscription = this.voiceService.audioSegment$.subscribe((rec) => {
|
|
174
|
+
console.log('[CONV-FOOTER] audioSegment$', rec);
|
|
175
|
+
this.prepareAndUpload(rec.blob);
|
|
176
|
+
});
|
|
177
|
+
this.voiceVolumeSubscription = this.voiceService.volume$.subscribe((volume) => {
|
|
178
|
+
this.currentVolume = volume;
|
|
179
|
+
this.updateWave(volume);
|
|
180
|
+
});
|
|
181
|
+
await this.voiceService.startSession();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async stopVoice() {
|
|
185
|
+
this.voiceAudioSubscription?.unsubscribe();
|
|
186
|
+
this.voiceAudioSubscription = undefined;
|
|
187
|
+
|
|
188
|
+
this.voiceVolumeSubscription?.unsubscribe();
|
|
189
|
+
this.voiceVolumeSubscription = undefined;
|
|
190
|
+
|
|
191
|
+
await this.voiceService.stopSession();
|
|
192
|
+
}
|
|
193
|
+
|
|
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
|
+
}
|
|
205
|
+
|
|
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
|
+
`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
ngOnDestroy() {
|
|
216
|
+
void this.stopVoice();
|
|
217
|
+
}
|
|
218
|
+
|
|
148
219
|
// ========= begin:: functions send image ======= //
|
|
149
220
|
// START LOAD IMAGE //
|
|
150
221
|
/** load the selected image locally and open the pop up preview */
|
|
@@ -524,7 +595,7 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
524
595
|
}
|
|
525
596
|
}
|
|
526
597
|
|
|
527
|
-
prepareAndUpload(audioBlob: Blob) {
|
|
598
|
+
prepareAndUpload(audioBlob: Blob, text: string = '') {
|
|
528
599
|
|
|
529
600
|
this.isFilePendingToUpload = true;
|
|
530
601
|
|
|
@@ -554,7 +625,7 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
554
625
|
this.logger.log('[UPLOAD] metadata:', metadata);
|
|
555
626
|
|
|
556
627
|
// stesso metodo che già usi
|
|
557
|
-
this.uploadSingle(metadata, file,
|
|
628
|
+
this.uploadSingle(metadata, file, text);
|
|
558
629
|
}
|
|
559
630
|
|
|
560
631
|
// Funzione per convertire Blob in Base64 usando FileReader
|
|
@@ -661,6 +732,29 @@ export class ConversationFooterComponent implements OnInit, OnChanges {
|
|
|
661
732
|
}
|
|
662
733
|
}
|
|
663
734
|
|
|
735
|
+
async onStreamPressed(event: Event) {
|
|
736
|
+
this.logger.log('[CONV-FOOTER] onStreamPressed:event', event);
|
|
737
|
+
event.preventDefault();
|
|
738
|
+
if (this.showAlertEmoji) {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const turningOn = !this.isStreamAudioActive;
|
|
742
|
+
if (turningOn) {
|
|
743
|
+
try {
|
|
744
|
+
await this.initVoice();
|
|
745
|
+
this.isStreamAudioActive = true;
|
|
746
|
+
} catch (e) {
|
|
747
|
+
this.logger.error('[CONV-FOOTER] onStreamPressed: initVoice failed', e);
|
|
748
|
+
this.isStreamAudioActive = false;
|
|
749
|
+
}
|
|
750
|
+
} else {
|
|
751
|
+
await this.stopVoice();
|
|
752
|
+
this.isStreamAudioActive = false;
|
|
753
|
+
}
|
|
754
|
+
this.onStreamAudioActiveChange.emit(this.isStreamAudioActive);
|
|
755
|
+
this.logger.log('[CONV-FOOTER] isStreamAudioActive', this.isStreamAudioActive);
|
|
756
|
+
}
|
|
757
|
+
|
|
664
758
|
async onEmojiiPickerClicked(){
|
|
665
759
|
// if(this.loadPickerModule){
|
|
666
760
|
// this.loadPickerModule = false;
|
|
@@ -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,19 @@
|
|
|
1
|
+
<div class="lyrics-container">
|
|
2
|
+
|
|
3
|
+
<audio
|
|
4
|
+
#audioPlayer
|
|
5
|
+
[src]="message?.metadata?.src"
|
|
6
|
+
(timeupdate)="onTimeUpdate()"
|
|
7
|
+
style="display:none">
|
|
8
|
+
</audio>
|
|
9
|
+
|
|
10
|
+
<p class="lyrics message_innerhtml marked" #transcriptBox [style.color]="color">
|
|
11
|
+
<span
|
|
12
|
+
*ngFor="let w of words; let i = index; trackBy: trackByIndex"
|
|
13
|
+
class="word"
|
|
14
|
+
[ngClass]="w.state">
|
|
15
|
+
{{ w.text }}
|
|
16
|
+
</span>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
</div>
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
line-height: 1.4em;
|
|
21
|
+
font-style: normal;
|
|
22
|
+
letter-spacing: normal;
|
|
23
|
+
font-stretch: normal;
|
|
24
|
+
font-variant: normal;
|
|
25
|
+
font-weight: 300;
|
|
26
|
+
overflow: hidden;
|
|
27
|
+
|
|
28
|
+
display: flex;
|
|
29
|
+
flex-wrap: wrap;
|
|
30
|
+
gap: 6px;
|
|
31
|
+
/* Colore bubble: da [style.color] / @Input() color — ereditato dalle .word */
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* base word */
|
|
35
|
+
.word {
|
|
36
|
+
transition:
|
|
37
|
+
transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1),
|
|
38
|
+
color 0.3s ease,
|
|
39
|
+
opacity 0.3s ease,
|
|
40
|
+
filter 0.3s ease;
|
|
41
|
+
|
|
42
|
+
will-change: transform;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* FUTURE */
|
|
46
|
+
.word.future {
|
|
47
|
+
opacity: 0;
|
|
48
|
+
transform: scale(0.98);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* PAST: stesso colore del testo bubble (@Input color sul <p>) */
|
|
52
|
+
.word.past {
|
|
53
|
+
opacity: 1;
|
|
54
|
+
color: inherit;
|
|
55
|
+
transform: scale(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ACTIVE (solo momentaneo, tipo “karaoke flash”) */
|
|
59
|
+
.word.active {
|
|
60
|
+
opacity: 1;
|
|
61
|
+
color: #00c3ff;
|
|
62
|
+
font-weight: 700;
|
|
63
|
+
transform: scale(1.18);
|
|
64
|
+
text-shadow: 0 0 10px rgba(0, 195, 255, 0.35);
|
|
65
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AfterViewInit,
|
|
3
|
+
ChangeDetectorRef,
|
|
4
|
+
Component,
|
|
5
|
+
ElementRef,
|
|
6
|
+
Input,
|
|
7
|
+
OnChanges,
|
|
8
|
+
OnDestroy,
|
|
9
|
+
SimpleChanges,
|
|
10
|
+
ViewChild,
|
|
11
|
+
} from '@angular/core';
|
|
12
|
+
import { MessageModel } from 'src/chat21-core/models/message';
|
|
13
|
+
|
|
14
|
+
/** HAVE_METADATA: metadati già disponibili (tipico audio servito da cache). */
|
|
15
|
+
const HAVE_METADATA = 1;
|
|
16
|
+
|
|
17
|
+
@Component({
|
|
18
|
+
selector: 'chat-audio-sync',
|
|
19
|
+
templateUrl: './audio-sync.component.html',
|
|
20
|
+
styleUrl: './audio-sync.component.scss',
|
|
21
|
+
})
|
|
22
|
+
export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
|
|
23
|
+
@Input() message: MessageModel | null = null;
|
|
24
|
+
@Input() color?: string;
|
|
25
|
+
|
|
26
|
+
@ViewChild('audioPlayer') audioRef!: ElementRef<HTMLAudioElement>;
|
|
27
|
+
@ViewChild('transcriptBox') transcriptBox!: ElementRef<HTMLElement>;
|
|
28
|
+
|
|
29
|
+
words: {
|
|
30
|
+
text: string;
|
|
31
|
+
start: number;
|
|
32
|
+
end: number;
|
|
33
|
+
state: 'future' | 'active' | 'past';
|
|
34
|
+
}[] = [];
|
|
35
|
+
|
|
36
|
+
currentTime = 0;
|
|
37
|
+
duration = 1;
|
|
38
|
+
activeIndex = -1;
|
|
39
|
+
|
|
40
|
+
private timingReady = false;
|
|
41
|
+
private onMetadataLoaded: () => void;
|
|
42
|
+
private onPlaybackEnded: () => void;
|
|
43
|
+
|
|
44
|
+
constructor(private readonly cdr: ChangeDetectorRef) {}
|
|
45
|
+
|
|
46
|
+
/** `false` = messaggio già in storico: niente autoplay / karaoke. Da `message.isJustRecived`. */
|
|
47
|
+
private get skipSyncAnimation(): boolean {
|
|
48
|
+
return this.message?.isJustRecived === false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
ngOnChanges(changes: SimpleChanges): void {
|
|
52
|
+
if (!changes['message']) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (this.audioRef?.nativeElement && this.timingReady) {
|
|
56
|
+
this.duration = this.audioRef.nativeElement.duration || 1;
|
|
57
|
+
this.buildFakeTiming();
|
|
58
|
+
if (this.skipSyncAnimation) {
|
|
59
|
+
this.markAllWordsPast();
|
|
60
|
+
} else {
|
|
61
|
+
this.syncStatesFromCurrentTime();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
ngAfterViewInit(): void {
|
|
67
|
+
const audio = this.audioRef.nativeElement;
|
|
68
|
+
|
|
69
|
+
this.onPlaybackEnded = () => {
|
|
70
|
+
if (this.skipSyncAnimation) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.markAllWordsPast();
|
|
74
|
+
if (this.message) {
|
|
75
|
+
this.message.isJustRecived = false;
|
|
76
|
+
}
|
|
77
|
+
this.cdr.detectChanges();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
this.onMetadataLoaded = () => {
|
|
81
|
+
if (this.timingReady) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.timingReady = true;
|
|
85
|
+
this.duration = audio.duration || 1;
|
|
86
|
+
this.buildFakeTiming();
|
|
87
|
+
if (this.skipSyncAnimation) {
|
|
88
|
+
this.markAllWordsPast();
|
|
89
|
+
this.cdr.detectChanges();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.syncStatesFromCurrentTime();
|
|
93
|
+
this.cdr.detectChanges();
|
|
94
|
+
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
audio.play().catch(() => {
|
|
97
|
+
this.syncStatesFromCurrentTime();
|
|
98
|
+
this.cdr.detectChanges();
|
|
99
|
+
});
|
|
100
|
+
}, 200);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
audio.addEventListener('loadedmetadata', this.onMetadataLoaded);
|
|
104
|
+
audio.addEventListener('ended', this.onPlaybackEnded);
|
|
105
|
+
|
|
106
|
+
if (audio.readyState >= HAVE_METADATA) {
|
|
107
|
+
this.onMetadataLoaded();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
ngOnDestroy(): void {
|
|
112
|
+
const audio = this.audioRef?.nativeElement;
|
|
113
|
+
if (!audio) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (this.onMetadataLoaded) {
|
|
117
|
+
audio.removeEventListener('loadedmetadata', this.onMetadataLoaded);
|
|
118
|
+
}
|
|
119
|
+
if (this.onPlaybackEnded) {
|
|
120
|
+
audio.removeEventListener('ended', this.onPlaybackEnded);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private markAllWordsPast(): void {
|
|
125
|
+
this.words.forEach((w) => {
|
|
126
|
+
w.state = 'past';
|
|
127
|
+
});
|
|
128
|
+
this.activeIndex = -1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
buildFakeTiming(): void {
|
|
132
|
+
const rawWords = (this.message?.text || '')
|
|
133
|
+
.trim()
|
|
134
|
+
.split(/\s+/)
|
|
135
|
+
.filter((w) => w.length > 0);
|
|
136
|
+
if (rawWords.length === 0) {
|
|
137
|
+
this.words = [];
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const step = this.duration / rawWords.length;
|
|
141
|
+
|
|
142
|
+
this.words = rawWords.map((w, i) => ({
|
|
143
|
+
text: w,
|
|
144
|
+
start: i * step,
|
|
145
|
+
end: (i + 1) * step,
|
|
146
|
+
state: 'future' as const,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
syncStatesFromCurrentTime(): void {
|
|
151
|
+
if (this.skipSyncAnimation) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const audio = this.audioRef?.nativeElement;
|
|
155
|
+
if (!audio || this.words.length === 0) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.currentTime = audio.currentTime;
|
|
159
|
+
let newActiveIndex = -1;
|
|
160
|
+
|
|
161
|
+
this.words.forEach((w, i) => {
|
|
162
|
+
if (this.currentTime >= w.end) {
|
|
163
|
+
w.state = 'past';
|
|
164
|
+
} else if (this.currentTime >= w.start && this.currentTime < w.end) {
|
|
165
|
+
w.state = 'active';
|
|
166
|
+
newActiveIndex = i;
|
|
167
|
+
} else {
|
|
168
|
+
w.state = 'future';
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (newActiveIndex !== this.activeIndex) {
|
|
173
|
+
this.activeIndex = newActiveIndex;
|
|
174
|
+
this.scrollToActive();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
onTimeUpdate(): void {
|
|
179
|
+
this.syncStatesFromCurrentTime();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
scrollToActive(): void {
|
|
183
|
+
const container = this.transcriptBox?.nativeElement;
|
|
184
|
+
const active = container?.querySelector('.active') as HTMLElement;
|
|
185
|
+
|
|
186
|
+
if (active) {
|
|
187
|
+
active.scrollIntoView({
|
|
188
|
+
behavior: 'smooth',
|
|
189
|
+
block: 'center',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
trackByIndex(index: number): number {
|
|
195
|
+
return index;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -64,6 +64,11 @@
|
|
|
64
64
|
[stylesMap]="stylesMap">
|
|
65
65
|
</chat-audio>
|
|
66
66
|
|
|
67
|
+
<chat-audio-sync *ngIf="isAudioTTS(message)"
|
|
68
|
+
[message]="message"
|
|
69
|
+
[color]="fontColor">
|
|
70
|
+
</chat-audio-sync>
|
|
71
|
+
|
|
67
72
|
|
|
68
73
|
<!-- <chat-frame *ngIf="message.metadata && message.metadata.type && message.metadata.type.includes('video')"
|
|
69
74
|
[metadata]="message.metadata"
|
|
@@ -75,7 +80,7 @@
|
|
|
75
80
|
<!-- <div *ngIf="message.type == 'text'"> -->
|
|
76
81
|
|
|
77
82
|
<!-- tooltip="{{message.timestamp | dateAgo}} ({{message.timestamp | date:'shortDate'}} {{message.timestamp | date:'HH:mm:ss'}})" placement="bottom" -->
|
|
78
|
-
<div *ngIf="message?.text && !isAudio(message)" >
|
|
83
|
+
<div *ngIf="message?.text && (!isAudio(message) && !isAudioTTS(message))" >
|
|
79
84
|
|
|
80
85
|
<!-- [htmlEnabled]="(message?.type==='html')? true : false" -->
|
|
81
86
|
<chat-text *ngIf="message?.type !=='html'"
|
|
@@ -5,7 +5,7 @@ import { LoggerService } from 'src/chat21-core/providers/abstract/logger.service
|
|
|
5
5
|
import { LoggerInstance } from 'src/chat21-core/providers/logger/loggerInstance';
|
|
6
6
|
import { MAX_WIDTH_IMAGES, MESSAGE_TYPE_MINE, MESSAGE_TYPE_OTHERS, MIN_WIDTH_IMAGES } from 'src/chat21-core/utils/constants';
|
|
7
7
|
import { convertColorToRGBA } from 'src/chat21-core/utils/utils';
|
|
8
|
-
import { isAudio, isFile, isFrame, isImage, messageType } from 'src/chat21-core/utils/utils-message';
|
|
8
|
+
import { isAudio, isAudioTTS, isFile, isFrame, isImage, messageType } from 'src/chat21-core/utils/utils-message';
|
|
9
9
|
import { getColorBck } from 'src/chat21-core/utils/utils-user';
|
|
10
10
|
|
|
11
11
|
@Component({
|
|
@@ -26,6 +26,7 @@ export class BubbleMessageComponent implements OnInit {
|
|
|
26
26
|
isFile = isFile;
|
|
27
27
|
isFrame = isFrame;
|
|
28
28
|
isAudio = isAudio;
|
|
29
|
+
isAudioTTS=isAudioTTS;
|
|
29
30
|
convertColorToRGBA = convertColorToRGBA
|
|
30
31
|
|
|
31
32
|
// ========== begin:: check message type functions ======= //
|
|
@@ -1125,6 +1125,11 @@ export class GlobalSettingsService {
|
|
|
1125
1125
|
if (TEMP !== undefined) {
|
|
1126
1126
|
globals.showAudioRecorderFooterButton = (TEMP === true) ? true : false;
|
|
1127
1127
|
}
|
|
1128
|
+
TEMP = tiledeskSettings['showAudioStreamFooterButton'];
|
|
1129
|
+
// this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > showAudioStreamFooterButton:: ', TEMP]);
|
|
1130
|
+
if (TEMP !== undefined) {
|
|
1131
|
+
globals.showAudioStreamFooterButton = (TEMP === true) ? true : false;
|
|
1132
|
+
}
|
|
1128
1133
|
TEMP = tiledeskSettings['size'];
|
|
1129
1134
|
// this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > size:: ', TEMP]);
|
|
1130
1135
|
if (TEMP !== undefined) {
|
|
@@ -1873,6 +1878,11 @@ export class GlobalSettingsService {
|
|
|
1873
1878
|
globals.showAttachmentFooterButton = stringToBoolean(TEMP);
|
|
1874
1879
|
}
|
|
1875
1880
|
|
|
1881
|
+
TEMP = getParameterByName(windowContext, 'tiledesk_showAudioStreamFooterButton');
|
|
1882
|
+
if (TEMP) {
|
|
1883
|
+
globals.showAudioStreamFooterButton = stringToBoolean(TEMP);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1876
1886
|
TEMP = getParameterByName(windowContext, 'tiledesk_showEmojiFooterButton');
|
|
1877
1887
|
if (TEMP) {
|
|
1878
1888
|
globals.showEmojiFooterButton = stringToBoolean(TEMP);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configurazione opzionale per i servizi voce OpenAI (da `environment` o runtime).
|
|
3
|
+
*/
|
|
4
|
+
export interface OpenAiVoiceEnvironmentConfig {
|
|
5
|
+
/** Obbligatoria per chiamate API reali; se assente, STT/TTS non inviano richieste. */
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
transcriptionModel?: string;
|
|
9
|
+
ttsModel?: string;
|
|
10
|
+
/** Voce predefinita TTS (es. `alloy`). */
|
|
11
|
+
ttsVoice?: string;
|
|
12
|
+
}
|